diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..47f6d98b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.ai binary diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..9621c232 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://signal.org/donate/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..907e4fdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,54 @@ +--- +name: 🛠️ Bug report +about: Let us know that something isn't working as intended +title: '' +labels: '' +assignees: '' + +--- + + + +- [ ] I have searched open and closed issues for duplicates +- [ ] I am submitting a bug report for existing functionality that does not work as intended +- [ ] I have read https://github.com/signalapp/Signal-Android/wiki/Submitting-useful-bug-reports +- [ ] This isn't a feature request or a discussion topic + +---------------------------------------- + +### Bug description +Describe here the issue that you are experiencing. + +### Steps to reproduce +- using hyphens as bullet points +- list the steps +- that reproduce the bug + +**Actual result:** Describe here what happens after you run the steps above (i.e. the buggy behaviour) +**Expected result:** Describe here what should happen after you run the steps above (i.e. what would be the correct behaviour) + +### Screenshots + + + +### Device info + +**Device:** Manufacturer Model XVI +**Android version:** 0.0.0 +**Signal version:** 0.0.0 + +### Link to debug log + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..62f847d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,20 @@ +blank_issues_enabled: false +contact_links: + - name: 📃Support Center + url: https://support.signal.org/ + about: Find answers to many common questions. + - name: ✨ Feature request + url: https://community.signalusers.org/c/feature-requests/ + about: Missing something in Signal? Let us know. + - name: 💬 Community support + url: https://community.signalusers.org/c/support/ + about: Feel free to ask anything. + - name: 📖 Developer documentation + url: https://signal.org/docs/ + about: Official Signal developer documentation. + - name: 📚 Translation feedback. + url: https://community.signalusers.org/c/translation-feedback/ + about: Share feedback on translations. + - name: ❓ Other issue? + url: https://community.signalusers.org/ + about: Search on the community forums. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..543e0064 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + +### First time contributor checklist + +- [ ] I have read [how to contribute](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md) to this project +- [ ] I have signed the [Contributor License Agreement](https://whispersystems.org/cla/) + +### Contributor checklist + +- [ ] I am following the [Code Style Guidelines](https://github.com/signalapp/Signal-Android/wiki/Code-Style-Guidelines) +- [ ] I have tested my contribution on these devices: + * Device A, Android X.Y.Z + * Device B, Android Z.Y + * Virtual device W, Android Y.Y.Z +- [ ] My contribution is fully baked and ready to be merged as is +- [ ] I ensure that all the open issues my contribution fixes are mentioned in the commit message of my first commit using the `Fixes #1234` [syntax](https://help.github.com/articles/closing-issues-via-commit-messages/) + +---------- + +### Description + diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..44ed19bc --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,35 @@ +name: Android CI + +on: + pull_request: + push: + branches: + - 'master' + - '4.**' + - '5.**' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Build with Gradle + run: ./gradlew qa + + - name: Archive reports for failed build + if: ${{ failure() }} + uses: actions/upload-artifact@v2 + with: + name: reports + path: '*/build/reports' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..93b8b76c --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,18 @@ +name: Reproducible Build Check + +on: + schedule: + - cron: '0 5 * * *' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Build image + run: cd reproducible-builds && docker build -t signal-android . && cd .. + + - name: Test build + run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assembleRelease diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7706868c --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +.classpath +captures/ +project.properties +keystore.debug.properties +keystore.staging.properties +.project +.settings +bin/ +gen/ +.idea/ +*.iml +out +tests +local.properties +ant.properties +.DS_Store +build.log +build-log.xml +.gradle +build +signing.properties +library/lib/ +library/obj/ +ffpr +test/androidTestEspresso/res/values/arrays.xml +obj/ +jni/libspeex/.deps/ +pkcs11.password +dev.keystore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a534dcfc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to Signal Android + +Thank you for supporting Signal and looking for ways to help. Please note that some conventions here might be a bit different than what you are used to, even if you have contributed to other open source projects before. Reading this document will help you save time and work effectively with the developers and other contributors. + + +## Development Ideology + +Truths which we believe to be self-evident: + +1. **The answer is not more options.** If you feel compelled to add a preference that's exposed to the user, it's very possible you've made a wrong turn somewhere. +1. **The user doesn't know what a key is.** We need to minimize the points at which a user is exposed to this sort of terminology as extremely as possible. +1. **There are no power users.** The idea that some users "understand" concepts better than others has proven to be, for the most part, false. If anything, "power users" are more dangerous than the rest, and we should avoid exposing dangerous functionality to them. +1. **If it's "like PGP," it's wrong.** PGP is our guide for what not to do. +1. **It's an asynchronous world.** Be wary of anything that is anti-asynchronous: ACKs, protocol confirmations, or any protocol-level "advisory" message. +1. **There is no such thing as time.** Protocol ideas that require synchronized clocks are doomed to failure. + + +## Translations + +Thanks to a dedicated community of volunteer translators, Signal is now available in more than one hundred languages. We use Transifex to manage our translation efforts, not GitHub. Any suggestions, corrections, or new translations should be submitted to the [Signal localization project for Android](https://www.transifex.com/signalapp/signal-android/). + + +## Issues + +### Useful bug reports +1. Please search both open and closed issues to make sure your bug report is not a duplicate. +1. Read the [guide to submitting useful bug reports](https://github.com/signalapp/Signal-Android/wiki/Submitting-useful-bug-reports) before posting a bug. + +### The issue tracker is for bugs, not feature requests +The GitHub issue tracker is not used for feature requests, but new ideas can be submitted and discussed on the [community forum](https://community.signalusers.org/c/feature-requests). The purpose of this issue tracker is to track bugs in the Android client. Bug reports should only be submitted for existing functionality that does not work as intended. Comments that are relevant and concise will help the developers solve issues more quickly. + +### Send support questions to support +You can reach support by sending an email to support@signal.org or by visiting the [Signal Support Center](https://support.signal.org/) where you can also search for existing troubleshooting articles and find answers to frequently asked questions. Please do not post support questions on the GitHub issue tracker. + +### GitHub is not a generic discussion forum +Conversations about open bug reports belong here. However, all other discussions should take place on the [community forum](https://community.signalusers.org). You can use the community forum to discuss anything that is related to Signal or to hang out with your fellow users in the "Off Topic" category. + +### Don't bump issues +Every time someone comments on an issue, GitHub sends an email to [hundreds of people](https://github.com/signalapp/Signal-Android/watchers). Bumping issues with a "+1" (or asking for updates) generates a lot of unnecessary email notifications and does not help anyone solve the issue any faster. Please be respectful of everyone's time and only comment when you have new information to add. + +### Open issues + +#### If it's open, it's tracked +The developers read every issue, but high-priority bugs or features can take precedence over others. Signal is an open source project, and everyone is encouraged to play an active role in diagnosing and fixing open issues. + +### Closed issues + +#### "My issue was closed without giving a reason!" +Although we do our best, writing detailed explanations for every issue can be time consuming, and the topic also might have been covered previously in other related issues. + + +## Pull requests + +### Smaller is better +Big changes are significantly less likely to be accepted. Large features often require protocol modifications and necessitate a staged rollout process that is coordinated across millions of users on multiple platforms (Android, iOS, and Desktop). + +Try not to take on too much at once. As a first-time contributor, we recommend starting with small and simple PRs in order to become familiar with the codebase. Most of the work should go into discovering which three lines need to change rather than writing the code. + +### Sign the Contributor License Agreement (CLA) +You will need to [sign our CLA](https://signal.org/cla/) before your pull request can be merged. + +### Follow the Code Style Guidelines +Ensure that your code adheres to the [Code Style Guidelines](https://github.com/signalapp/Signal-Android/wiki/Code-Style-Guidelines) before submitting a pull request. + +### Submit finished and well-tested pull requests +Please do not submit pull requests that are still a work in progress. Pull requests should be thoroughly tested and ready to merge before they are submitted. + +### Merging can sometimes take a while +If your pull request follows all of the advice above but still has not been merged, this usually means that the developers haven't had time to review it yet. We understand that this might feel frustrating, and we apologize. The Signal team is still small, but [we are hiring](https://signal.org/workworkwork/). + + +## How can I contribute? +There are several other ways to get involved: +* Help new users learn about Signal. + * Redirect support questions to support@signal.org and the [Signal Support Center](https://support.signal.org/). + * Redirect non-bug discussions to the [community forum](https://community.signalusers.org). +* Improve documentation in the [wiki](https://github.com/signalapp/Signal-Android/wiki). +* Join the community of volunteer translators on Transifex: + * [Android](https://www.transifex.com/signalapp/signal-android/) + * [iOS](https://www.transifex.com/signalapp/signal-ios/) + * [Desktop](https://www.transifex.com/signalapp/signal-desktop/) +* Find and mark duplicate issues. +* Try to reproduce issues and help with troubleshooting. +* Discover solutions to open issues and post any relevant findings. +* Test other people's pull requests. +* [Donate to Signal.](https://signal.org/donate/) +* Share Signal with your friends and family. + +Signal is made for you. Thank you for your feedback and support. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..94a04532 --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..1408e552 --- /dev/null +++ b/NOTICE @@ -0,0 +1,34 @@ +TextSecure provides encrypted text messages for Android. +Copyright 2011 Whisper Systems + +This software has the follow third party dependencies: + +Bouncy Castle 1.42 +http://www.bouncycastle.org/ +MIT License + +Copyright (c) 2000 - 2011 The Legion Of The Bouncy Castle (http://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +ZXing +http://code.google.com/p/zxing/ +Apache License 2.0 + +Copyright 2009 ZXing authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index e69de29b..07c8ca54 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,66 @@ +# Signal Android + +Signal is a messaging app for simple private communication with friends. + +Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone. + +Currently available on the Play store. + +Get it on Google Play + +## Contributing Bug reports +We use GitHub for bug tracking. Please search the existing issues for your bug and create a new one if the issue is not yet tracked! + +https://github.com/signalapp/Signal-Android/issues + +## Joining the Beta +Want to live life on the bleeding edge and help out with testing? + +You can subscribe to Signal Android Beta releases here: +https://play.google.com/apps/testing/org.thoughtcrime.securesms + +If you're interested in a life of peace and tranquility, stick with the standard releases. + +## Contributing Translations +Interested in helping to translate Signal? Contribute here: + +https://www.transifex.com/projects/p/signal-android/ + +## Contributing Code + +If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md), that might answer some of your questions. + +For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation. + +## Contributing Ideas +Have something you want to say about Open Whisper Systems projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org). + +Help +==== +## Support +For troubleshooting and questions, please visit our support center! + +https://support.signal.org/ + +## Documentation +Looking for documentation? Check out the wiki! + +https://github.com/signalapp/Signal-Android/wiki + +# Legal things +## Cryptography Notice + +This distribution includes cryptographic software. The country in which you currently reside may have restrictions on the import, possession, use, and/or re-export to another country, of encryption software. +BEFORE using any encryption software, please check your country's laws, regulations and policies concerning the import, possession, or use, and re-export of encryption software, to see if this is permitted. +See for more information. + +The U.S. Government Department of Commerce, Bureau of Industry and Security (BIS), has classified this software as Export Commodity Control Number (ECCN) 5D002.C.1, which includes information security software using or performing cryptographic functions with asymmetric algorithms. +The form and manner of this distribution makes it eligible for export under the License Exception ENC Technology Software Unrestricted (TSU) exception (see the BIS Export Administration Regulations, Section 740.13) for both object code and source code. + +## License + +Copyright 2013-2020 Signal + +Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html + +Google Play and the Google Play logo are trademarks of Google Inc. diff --git a/apntool/.gitignore b/apntool/.gitignore new file mode 100644 index 00000000..b174b758 --- /dev/null +++ b/apntool/.gitignore @@ -0,0 +1,2 @@ +*.db +*.db.gz diff --git a/apntool/apnlists/cyanogenmod.xml b/apntool/apnlists/cyanogenmod.xml new file mode 100644 index 00000000..2b7719b7 --- /dev/null +++ b/apntool/apnlists/cyanogenmod.xml @@ -0,0 +1,1884 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apntool/apnlists/hangouts.xml b/apntool/apnlists/hangouts.xml new file mode 100644 index 00000000..2d3ce5b6 --- /dev/null +++ b/apntool/apnlists/hangouts.xml @@ -0,0 +1,2217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apntool/apntool.py b/apntool/apntool.py new file mode 100644 index 00000000..2c6e6cac --- /dev/null +++ b/apntool/apntool.py @@ -0,0 +1,106 @@ +import sys +import re +import argparse +import sqlite3 +import gzip +from progressbar import ProgressBar, Counter, Timer +from lxml import etree + +parser = argparse.ArgumentParser(prog='apntool', description="""Process Android's apn xml files and drop them into an + easily queryable SQLite db. Tested up to version 9 of + their APN file.""") +parser.add_argument('-v', '--version', action='version', version='%(prog)s v1.1') +parser.add_argument('-i', '--input', help='the xml file to parse', default='apns.xml', required=False) +parser.add_argument('-o', '--output', help='the sqlite db output file', default='apns.db', required=False) +parser.add_argument('--quiet', help='do not show progress or verbose instructions', action='store_true', required=False) +parser.add_argument('--no-gzip', help="do not gzip after creation", action='store_true', required=False) +args = parser.parse_args() + + +def normalized(target): + o2_typo = re.compile(r"02\.co\.uk") + port_typo = re.compile(r"(\d+\.\d+\.\d+\.\d+)\.(\d+)") + leading_zeros = re.compile(r"(/|\.|^)0+(\d+)") + subbed = o2_typo.sub(r'o2.co.uk', target) + subbed = port_typo.sub(r'\1:\2', subbed) + subbed = leading_zeros.sub(r'\1\2', subbed) + return subbed + +try: + connection = sqlite3.connect(args.output) + cursor = connection.cursor() + cursor.execute('SELECT SQLITE_VERSION()') + version = cursor.fetchone() + if not args.quiet: + print("SQLite version: %s" % version) + print("Opening %s" % args.input) + + cursor.execute("PRAGMA legacy_file_format=ON") + cursor.execute("PRAGMA journal_mode=DELETE") + cursor.execute("PRAGMA page_size=32768") + cursor.execute("VACUUM") + cursor.execute("DROP TABLE IF EXISTS apns") + cursor.execute("""CREATE TABLE apns(_id INTEGER PRIMARY KEY, mccmnc TEXT, mcc TEXT, mnc TEXT, carrier TEXT, + apn TEXT, mmsc TEXT, port INTEGER, type TEXT, protocol TEXT, bearer TEXT, roaming_protocol TEXT, + carrier_enabled INTEGER, mmsproxy TEXT, mmsport INTEGER, proxy TEXT, mvno_match_data TEXT, + mvno_type TEXT, authtype INTEGER, user TEXT, password TEXT, server TEXT)""") + + apns = etree.parse(args.input) + root = apns.getroot() + pbar = None + if not args.quiet: + pbar = ProgressBar(widgets=['Processed: ', Counter(), ' apns (', Timer(), ')'], maxval=len(list(root))).start() + + count = 0 + for apn in root.iter("apn"): + if apn.get("mmsc") is None: + continue + sqlvars = ["?" for x in apn.attrib.keys()] + ["?"] + mccmnc = "%s%s" % (apn.get("mcc"), apn.get("mnc")) + normalized_mmsc = normalized(apn.get("mmsc")) + if normalized_mmsc != apn.get("mmsc"): + print("normalize MMSC: %s => %s" % (apn.get("mmsc"), normalized_mmsc)) + apn.set("mmsc", normalized_mmsc) + + if not apn.get("mmsproxy") is None: + normalized_mmsproxy = normalized(apn.get("mmsproxy")) + if normalized_mmsproxy != apn.get("mmsproxy"): + print("normalize proxy: %s => %s" % (apn.get("mmsproxy"), normalized_mmsproxy)) + apn.set("mmsproxy", normalized_mmsproxy) + + values = [apn.get(attrib) for attrib in apn.attrib.keys()] + [mccmnc] + keys = apn.attrib.keys() + ["mccmnc"] + + cursor.execute("SELECT 1 FROM apns WHERE mccmnc = ? AND apn = ?", [mccmnc, apn.get("apn")]) + if cursor.fetchone() is None: + statement = "INSERT INTO apns (%s) VALUES (%s)" % (", ".join(keys), ", ".join(sqlvars)) + cursor.execute(statement, values) + + count += 1 + if not args.quiet: + pbar.update(count) + + if not args.quiet: + pbar.finish() + connection.commit() + print("Successfully written to %s" % args.output) + + if not args.no_gzip: + gzipped_file = "%s.gz" % (args.output,) + with open(args.output, 'rb') as orig: + with gzip.open(gzipped_file, 'wb') as gzipped: + gzipped.writelines(orig) + print("Successfully gzipped to %s" % gzipped_file) + + if not args.quiet: + print("\nTo include this in the distribution, copy it to the project's assets/databases/ directory.") + print("If you support API 10 or lower, you must use the gzipped version to avoid corruption.") + +except sqlite3.Error, e: + if connection: + connection.rollback() + print("Error: %s" % e.args[0]) + sys.exit(1) +finally: + if connection: + connection.close() diff --git a/apntool/requirements.txt b/apntool/requirements.txt new file mode 100644 index 00000000..4f7aa283 --- /dev/null +++ b/apntool/requirements.txt @@ -0,0 +1,3 @@ +argparse>=1.2.1 +lxml>=3.3.3 +progressbar-latest>=2.4 diff --git a/app/.tx/config b/app/.tx/config new file mode 100644 index 00000000..695060d5 --- /dev/null +++ b/app/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com +lang_map = da_DK:da-rDK,fil:tl,he:iw,id:in,kn_IN:kn-rIN,pa_PK:pa-rPK,pt_BR:pt-rBR,pt_PT:pt,qu_EC:qu-rEC,sv_SE:sv-rSE,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW + +[signal-android.master] +file_filter = src/main/res/values-/strings.xml +source_file = src/main/res/values/strings.xml +source_lang = en +type = ANDROID + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..160eec73 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,549 @@ +import org.signal.signing.ApkSignerUtil + +import java.security.MessageDigest + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.protobuf' +apply plugin: 'androidx.navigation.safeargs' +apply plugin: 'witness' +apply from: 'translations.gradle' +apply from: 'witness-verifications.gradle' + +repositories { + maven { + url "https://raw.github.com/signalapp/maven/master/photoview/releases/" + content { + includeGroupByRegex "com\\.github\\.chrisbanes.*" + } + } + maven { + url "https://raw.github.com/signalapp/maven/master/shortcutbadger/releases/" + content { + includeGroupByRegex "me\\.leolin.*" + } + } + maven { + url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/" + content { + includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*" + } + } + maven { + url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/" + content { + includeGroupByRegex "org\\.signal.*" + } + } + maven { // textdrawable + url 'https://dl.bintray.com/amulyakhare/maven' + content { + includeGroupByRegex "com\\.amulyakhare.*" + } + } + google() + mavenCentral() + jcenter() + mavenLocal() + + flatDir { + dirs 'libs' + } +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.10.0' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +def canonicalVersionCode = 797 +def canonicalVersionName = "5.4.11" + +def postFixSize = 100 +def abiPostFix = ['universal' : 0, + 'armeabi-v7a' : 1, + 'arm64-v8a' : 2, + 'x86' : 3, + 'x86_64' : 4] + +def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ] + +android { + buildToolsVersion BUILD_TOOL_VERSION + compileSdkVersion COMPILE_SDK + + flavorDimensions 'distribution', 'environment' + useLibrary 'org.apache.http.legacy' + + dexOptions { + javaMaxHeapSize "4g" + } + + signingConfigs { + if (keystores.debug != null) { + debug { + storeFile file("${project.rootDir}/${keystores.debug.storeFile}") + storePassword keystores.debug.storePassword + keyAlias keystores.debug.keyAlias + keyPassword keystores.debug.keyPassword + } + } + } + + defaultConfig { + versionCode canonicalVersionCode * postFixSize + versionName canonicalVersionName + + minSdkVersion MINIMUM_SDK + targetSdkVersion TARGET_SDK + + multiDexEnabled true + + vectorDrawables.useSupportLibrary = true + project.ext.set("archivesBaseName", "Signal"); + + buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" + buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\"" + buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\"" + buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\"" + buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\"" + buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\"" + buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"" + buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"" + buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"" + buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"" + buildConfigField "int", "CONTENT_PROXY_PORT", "443" + buildConfigField "String", "SIGNAL_AGENT", "\"OWA\"" + buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\"" + buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," + + "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " + + "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"; + buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]" + buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"" + buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\"" + buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}' + buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode" + + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + } + + resConfigs autoResConfig() + + splits { + abi { + enable true + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + universalApk true + } + } + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JAVA_VERSION + targetCompatibility JAVA_VERSION + } + + packagingOptions { + exclude 'LICENSE.txt' + exclude 'LICENSE' + exclude 'NOTICE' + exclude 'asm-license.txt' + exclude 'META-INF/LICENSE' + exclude 'META-INF/NOTICE' + exclude 'META-INF/proguard/androidx-annotations.pro' + } + + buildTypes { + debug { + if (keystores['debug'] != null) { + signingConfig signingConfigs.debug + } + isDefault true + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), + 'proguard/proguard-firebase-messaging.pro', + 'proguard/proguard-google-play-services.pro', + 'proguard/proguard-jackson.pro', + 'proguard/proguard-sqlite.pro', + 'proguard/proguard-appcompat-v7.pro', + 'proguard/proguard-square-okhttp.pro', + 'proguard/proguard-square-okio.pro', + 'proguard/proguard-spongycastle.pro', + 'proguard/proguard-rounded-image-view.pro', + 'proguard/proguard-glide.pro', + 'proguard/proguard-shortcutbadger.pro', + 'proguard/proguard-retrofit.pro', + 'proguard/proguard-webrtc.pro', + 'proguard/proguard-klinker.pro', + 'proguard/proguard-retrolambda.pro', + 'proguard/proguard-okhttp.pro', + 'proguard/proguard-ez-vcard.pro', + 'proguard/proguard.cfg', + 'proguard/proguard-event_bus.pro' + testProguardFiles 'proguard/proguard-automation.pro', + 'proguard/proguard.cfg' + } + flipper { + initWith debug + isDefault false + minifyEnabled false + matchingFallbacks = ['debug'] + } + release { + minifyEnabled true + proguardFiles = buildTypes.debug.proguardFiles + } + perf { + initWith debug + isDefault false + debuggable false + matchingFallbacks = ['debug'] + } + } + + productFlavors { + play { + dimension 'distribution' + isDefault true + ext.websiteUpdateUrl = "null" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" + buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + } + + website { + dimension 'distribution' + ext.websiteUpdateUrl = "https://updates.signal.org/android" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" + buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\"" + } + + internal { + dimension 'distribution' + ext.websiteUpdateUrl = "null" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" + buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + } + + prod { + dimension 'environment' + + isDefault true + } + + staging { + dimension 'environment' + + applicationIdSuffix ".staging" + + buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\"" + buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\"" + buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\"" + buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"" + buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\"" + buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"" + buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\"" + buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " + + "\"038c40bbbacdc873caa81ac793bb75afde6dfe436a99ab1f15e3f0cbb7434ced\", " + + "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" + buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]" + buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"" + buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\"" + } + } + + android.applicationVariants.all { variant -> + variant.outputs.each { output -> + output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk") + def abiName = output.getFilter("ABI") ?: 'universal' + def postFix = abiPostFix.get(abiName, 0) + + if (postFix >= postFixSize) throw new AssertionError("postFix is too large") + + output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix + } + } + + lintOptions { + abortOnError true + baseline file("lint-baseline.xml") + disable "LintError" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + configurations { + all*.exclude group: 'commons-codec', module: 'commons-codec' + + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + + lintChecks project(':lintchecks') + + implementation ('androidx.appcompat:appcompat:1.2.0') { + force = true + } + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'com.google.android.material:material:1.2.1' + implementation 'androidx.legacy:legacy-support-v13:1.0.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.preference:preference:1.0.0' + implementation 'androidx.legacy:legacy-preference-v14:1.0.0' + implementation 'androidx.gridlayout:gridlayout:1.0.0' + implementation 'androidx.exifinterface:exifinterface:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.navigation:navigation-fragment:2.1.0' + implementation 'androidx.navigation:navigation-ui:2.1.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0' + implementation "androidx.camera:camera-core:1.0.0-beta11" + implementation "androidx.camera:camera-camera2:1.0.0-beta11" + implementation "androidx.camera:camera-lifecycle:1.0.0-beta11" + implementation "androidx.camera:camera-view:1.0.0-alpha18" + implementation "androidx.concurrent:concurrent-futures:1.0.0" + implementation "androidx.autofill:autofill:1.0.0" + implementation "androidx.biometric:biometric:1.1.0" + + implementation ('com.google.firebase:firebase-messaging:20.2.0') { + exclude group: 'com.google.firebase', module: 'firebase-core' + exclude group: 'com.google.firebase', module: 'firebase-analytics' + exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' + } + + implementation 'com.google.android.gms:play-services-maps:16.1.0' + implementation 'com.google.android.gms:play-services-auth:16.0.1' + + implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1' + implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1' + + implementation 'org.conscrypt:conscrypt-android:2.0.0' + implementation 'org.signal:aesgcmprovider:0.0.3' + + implementation project(':libsignal-service') + implementation project(':paging') + implementation project(':core-util') + implementation project(':video') + + implementation 'org.signal:zkgroup-android:0.7.0' + implementation 'org.whispersystems:signal-client-android:0.1.7' + implementation 'com.google.protobuf:protobuf-javalite:3.10.0' + implementation 'org.signal:argon2:13.1@aar' + + implementation 'org.signal:ringrtc-android:2.9.2' + + implementation "me.leolin:ShortcutBadger:1.1.16" + implementation 'se.emilsjolander:stickylistheaders:2.7.0' + implementation 'com.jpardogo.materialtabstrip:library:1.0.9' + implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' + implementation 'com.github.chrisbanes:PhotoView:2.1.3' + implementation 'com.github.bumptech.glide:glide:4.11.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' + kapt 'com.github.bumptech.glide:compiler:4.11.0' + annotationProcessor 'androidx.annotation:annotation:1.1.0' + implementation 'com.makeramen:roundedimageview:2.1.0' + implementation 'com.pnikosis:materialish-progress:1.5' + implementation 'org.greenrobot:eventbus:3.0.0' + implementation 'pl.tajchert:waitingdots:0.1.0' + implementation 'com.melnykov:floatingactionbutton:1.3.0' + implementation 'com.google.zxing:android-integration:3.1.0' + implementation 'mobi.upod:time-duration-picker:1.1.3' + implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' + implementation 'com.google.zxing:core:3.2.1' + implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { + exclude group: 'com.android.support', module: 'support-annotations' + } + implementation ('cn.carbswang.android:NumberPickerView:1.0.9') { + exclude group: 'com.android.support', module: 'appcompat-v7' + } + implementation ('com.tomergoldst.android:tooltips:1.0.6') { + exclude group: 'com.android.support', module: 'appcompat-v7' + } + implementation ('com.klinkerapps:android-smsmms:4.0.1') { + exclude group: 'com.squareup.okhttp', module: 'okhttp' + exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' + } + implementation 'com.squareup.okhttp3:okhttp:3.8.1' + implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.8.1' + implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1' + + implementation 'com.squareup.retrofit2:retrofit:2.6.1' + implementation 'com.squareup.retrofit2:converter-gson:2.1.0' + + + + implementation 'com.annimon:stream:1.1.8' + implementation ('com.takisoft.fix:colorpicker:0.9.1') { + exclude group: 'com.android.support', module: 'appcompat-v7' + exclude group: 'com.android.support', module: 'recyclerview-v7' + } + + implementation 'com.airbnb.android:lottie:3.6.0' + + implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' + implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' + implementation 'org.signal:android-database-sqlcipher:3.5.9-S3' + implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { + exclude group: 'com.fasterxml.jackson.core' + exclude group: 'org.freemarker' + } + implementation 'dnsjava:dnsjava:2.1.9' + + flipperImplementation 'com.facebook.flipper:flipper:0.32.2' + flipperImplementation 'com.facebook.soloader:soloader:0.8.2' + + testImplementation 'junit:junit:4.12' + testImplementation 'org.assertj:assertj-core:3.11.1' + testImplementation 'org.mockito:mockito-core:2.8.9' + testImplementation 'org.powermock:powermock-api-mockito2:1.7.4' + testImplementation 'org.powermock:powermock-module-junit4:1.7.4' + testImplementation 'org.powermock:powermock-module-junit4-rule:1.7.4' + testImplementation 'org.powermock:powermock-classloading-xstream:1.7.4' + + testImplementation 'androidx.test:core:1.2.0' + testImplementation ('org.robolectric:robolectric:4.4') { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } + testImplementation 'org.robolectric:shadows-multidex:4.4' + testImplementation 'org.hamcrest:hamcrest:2.2' + + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + + implementation(name: 'androidcopysdk-signal-debug_20', ext: 'aar') + implementation(name: 'common-debug', ext: 'aar') + implementation group: 'log4j', name: 'log4j', version: '1.2.17' + implementation 'com.google.code.gson:gson:2.8.5' + + + implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation 'commons-io:commons-io:2.6' + implementation 'org.apache.commons:commons-text:1.9' + + implementation group: 'commons-io', name: 'commons-io', version: '2.6' //For test copy file + +} + +dependencyVerification { + configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath' +} + +def assembleWebsiteDescriptor = { variant, file -> + if (file.exists()) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + file.eachByte 4096, {bytes, size -> + md.update(bytes, 0, size); + } + + String digest = md.digest().collect {String.format "%02x", it}.join(); + String url = variant.productFlavors.get(0).ext.websiteUpdateUrl + String apkName = file.getName() + + String descriptor = "{" + + "\"versionCode\" : ${canonicalVersionCode * postFixSize + abiPostFix['universal']}," + + "\"versionName\" : \"$canonicalVersionName\"," + + "\"sha256sum\" : \"$digest\"," + + "\"url\" : \"$url/$apkName\"" + + "}" + + File descriptorFile = new File(file.getParent(), apkName.replace(".apk", ".json")) + + descriptorFile.write(descriptor) + } +} + +def signProductionRelease = { variant -> + variant.outputs.collect { output -> + String apkName = output.outputFile.name + File inputFile = new File(output.outputFile.path) + File outputFile = new File(output.outputFile.parent, apkName.replace('-unsigned', '')) + + new ApkSignerUtil('sun.security.pkcs11.SunPKCS11', + 'pkcs11.config', + 'PKCS11', + 'file:pkcs11.password').calculateSignature(inputFile.getAbsolutePath(), + outputFile.getAbsolutePath()) + + inputFile.delete() + outputFile + } +} + +task signProductionPlayRelease { + doLast { + signProductionRelease(android.applicationVariants.find { (it.name == 'playProdRelease') }) + } +} + +task signProductionInternalRelease { + doLast { + signProductionRelease(android.applicationVariants.find { (it.name == 'internalProdRelease') }) + } +} + +task signProductionWebsiteRelease { + doLast { + def variant = android.applicationVariants.find { (it.name == 'websiteProdRelease') } + File signedRelease = signProductionRelease(variant).find { it.name.contains('universal') } + assembleWebsiteDescriptor(variant, signedRelease) + } +} + +def getLastCommitTimestamp() { + new ByteArrayOutputStream().withStream { os -> + def result = exec { + executable = 'git' + args = ['log', '-1', '--pretty=format:%ct'] + standardOutput = os + } + + return os.toString() + "000" + } +} + +tasks.withType(Test) { + testLogging { + events "failed" + exceptionFormat "full" + showCauses true + showExceptions true + showStackTraces true + } +} + +def loadKeystoreProperties(filename) { + def keystorePropertiesFile = file("${project.rootDir}/${filename}") + if (keystorePropertiesFile.exists()) { + def keystoreProperties = new Properties() + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + return keystoreProperties; + } else { + return null; + } +} diff --git a/app/internalProd/debug/Signal-Android-internal-prod-arm64-v8a-debug-5.4.11.apk b/app/internalProd/debug/Signal-Android-internal-prod-arm64-v8a-debug-5.4.11.apk new file mode 100644 index 00000000..601a2757 Binary files /dev/null and b/app/internalProd/debug/Signal-Android-internal-prod-arm64-v8a-debug-5.4.11.apk differ diff --git a/app/internalProd/debug/Signal-Android-internal-prod-armeabi-v7a-debug-5.4.11.apk b/app/internalProd/debug/Signal-Android-internal-prod-armeabi-v7a-debug-5.4.11.apk new file mode 100644 index 00000000..e5b74e12 Binary files /dev/null and b/app/internalProd/debug/Signal-Android-internal-prod-armeabi-v7a-debug-5.4.11.apk differ diff --git a/app/internalProd/debug/Signal-Android-internal-prod-universal-debug-5.4.11.apk b/app/internalProd/debug/Signal-Android-internal-prod-universal-debug-5.4.11.apk new file mode 100644 index 00000000..4452cada Binary files /dev/null and b/app/internalProd/debug/Signal-Android-internal-prod-universal-debug-5.4.11.apk differ diff --git a/app/internalProd/debug/Signal-Android-internal-prod-x86-debug-5.4.11.apk b/app/internalProd/debug/Signal-Android-internal-prod-x86-debug-5.4.11.apk new file mode 100644 index 00000000..cc30a43a Binary files /dev/null and b/app/internalProd/debug/Signal-Android-internal-prod-x86-debug-5.4.11.apk differ diff --git a/app/internalProd/debug/Signal-Android-internal-prod-x86_64-debug-5.4.11.apk b/app/internalProd/debug/Signal-Android-internal-prod-x86_64-debug-5.4.11.apk new file mode 100644 index 00000000..2a513292 Binary files /dev/null and b/app/internalProd/debug/Signal-Android-internal-prod-x86_64-debug-5.4.11.apk differ diff --git a/app/internalProd/debug/output-metadata.json b/app/internalProd/debug/output-metadata.json new file mode 100644 index 00000000..0793c6a9 --- /dev/null +++ b/app/internalProd/debug/output-metadata.json @@ -0,0 +1,66 @@ +{ + "version": 2, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "org.thoughtcrime.securesms", + "variantName": "processInternalProdDebugResources", + "elements": [ + { + "type": "UNIVERSAL", + "filters": [], + "versionCode": 79700, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-universal-debug-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "x86" + } + ], + "versionCode": 79703, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-x86-debug-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "arm64-v8a" + } + ], + "versionCode": 79702, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-arm64-v8a-debug-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "armeabi-v7a" + } + ], + "versionCode": 79701, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-armeabi-v7a-debug-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "x86_64" + } + ], + "versionCode": 79704, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-x86_64-debug-5.4.11.apk" + } + ] +} \ No newline at end of file diff --git a/app/internalProd/release/Signal-Android-internal-prod-arm64-v8a-release-5.4.11.apk b/app/internalProd/release/Signal-Android-internal-prod-arm64-v8a-release-5.4.11.apk new file mode 100644 index 00000000..4fd2c29f Binary files /dev/null and b/app/internalProd/release/Signal-Android-internal-prod-arm64-v8a-release-5.4.11.apk differ diff --git a/app/internalProd/release/Signal-Android-internal-prod-armeabi-v7a-release-5.4.11.apk b/app/internalProd/release/Signal-Android-internal-prod-armeabi-v7a-release-5.4.11.apk new file mode 100644 index 00000000..1dc83999 Binary files /dev/null and b/app/internalProd/release/Signal-Android-internal-prod-armeabi-v7a-release-5.4.11.apk differ diff --git a/app/internalProd/release/Signal-Android-internal-prod-universal-release-5.4.11.apk b/app/internalProd/release/Signal-Android-internal-prod-universal-release-5.4.11.apk new file mode 100644 index 00000000..d2f9bea3 Binary files /dev/null and b/app/internalProd/release/Signal-Android-internal-prod-universal-release-5.4.11.apk differ diff --git a/app/internalProd/release/Signal-Android-internal-prod-x86-release-5.4.11.apk b/app/internalProd/release/Signal-Android-internal-prod-x86-release-5.4.11.apk new file mode 100644 index 00000000..c5c89411 Binary files /dev/null and b/app/internalProd/release/Signal-Android-internal-prod-x86-release-5.4.11.apk differ diff --git a/app/internalProd/release/Signal-Android-internal-prod-x86_64-release-5.4.11.apk b/app/internalProd/release/Signal-Android-internal-prod-x86_64-release-5.4.11.apk new file mode 100644 index 00000000..9a3a8c73 Binary files /dev/null and b/app/internalProd/release/Signal-Android-internal-prod-x86_64-release-5.4.11.apk differ diff --git a/app/internalProd/release/output-metadata.json b/app/internalProd/release/output-metadata.json new file mode 100644 index 00000000..56ebf8d9 --- /dev/null +++ b/app/internalProd/release/output-metadata.json @@ -0,0 +1,66 @@ +{ + "version": 2, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "org.thoughtcrime.securesms", + "variantName": "processInternalProdReleaseResources", + "elements": [ + { + "type": "UNIVERSAL", + "filters": [], + "versionCode": 79700, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-universal-release-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "x86" + } + ], + "versionCode": 79703, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-x86-release-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "arm64-v8a" + } + ], + "versionCode": 79702, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-arm64-v8a-release-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "x86_64" + } + ], + "versionCode": 79704, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-x86_64-release-5.4.11.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "armeabi-v7a" + } + ], + "versionCode": 79701, + "versionName": "5.4.11", + "outputFile": "Signal-Android-internal-prod-armeabi-v7a-release-5.4.11.apk" + } + ] +} \ No newline at end of file diff --git a/app/jni/Android.mk b/app/jni/Android.mk new file mode 100644 index 00000000..cac4c9cb --- /dev/null +++ b/app/jni/Android.mk @@ -0,0 +1,11 @@ +JNI_DIR := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := native-utils +LOCAL_C_INCLUDES := $(JNI_DIR)/utils/ +LOCAL_CFLAGS += -Wall + +LOCAL_SRC_FILES := $(JNI_DIR)/utils/org_thoughtcrime_securesms_util_FileUtils.cpp + +include $(BUILD_SHARED_LIBRARY) \ No newline at end of file diff --git a/app/jni/Application.mk b/app/jni/Application.mk new file mode 100644 index 00000000..e2617969 --- /dev/null +++ b/app/jni/Application.mk @@ -0,0 +1,6 @@ +# Built with NDK 19.2.5345600 +APP_ABI := armeabi-v7a x86 arm64-v8a x86_64 +APP_PLATFORM := android-19 +APP_STL := c++_static +APP_CPPFLAGS += -fexceptions +APP_OPTIM := debug diff --git a/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.cpp b/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.cpp new file mode 100644 index 00000000..27ce4309 --- /dev/null +++ b/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.cpp @@ -0,0 +1,45 @@ +#include "org_thoughtcrime_securesms_util_FileUtils.h" + +#include +#include +#include +#include +#include + +jint JNICALL Java_org_thoughtcrime_securesms_util_FileUtils_getFileDescriptorOwner + (JNIEnv *env, jclass clazz, jobject fileDescriptor) +{ + jclass fdClass = env->GetObjectClass(fileDescriptor); + + if (fdClass == NULL) { + return -1; + } + + jfieldID fdFieldId = env->GetFieldID(fdClass, "descriptor", "I"); + + if (fdFieldId == NULL) { + return -1; + } + + int fd = env->GetIntField(fileDescriptor, fdFieldId); + + struct stat stat_struct; + + if (fstat(fd, &stat_struct) != 0) { + return -1; + } + + return stat_struct.st_uid; +} + +JNIEXPORT jint JNICALL Java_org_thoughtcrime_securesms_util_FileUtils_createMemoryFileDescriptor + (JNIEnv *env, jclass clazz, jstring jname) +{ + const char *name = env->GetStringUTFChars(jname, NULL); + + int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC); + + env->ReleaseStringUTFChars(jname, name); + + return fd; +} diff --git a/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.h b/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.h new file mode 100644 index 00000000..12faa685 --- /dev/null +++ b/app/jni/utils/org_thoughtcrime_securesms_util_FileUtils.h @@ -0,0 +1,29 @@ +/* DO NOT EDIT THIS FILE - it is machine generated */ +#include +/* Header for class org_thoughtcrime_securesms_util_FileUtils */ + +#ifndef _Included_org_thoughtcrime_securesms_util_FileUtils +#define _Included_org_thoughtcrime_securesms_util_FileUtils +#ifdef __cplusplus +extern "C" { +#endif +/* + * Class: org_thoughtcrime_securesms_util_FileUtils + * Method: getFileDescriptorOwner + * Signature: (Ljava/io/FileDescriptor;)I + */ +JNIEXPORT jint JNICALL Java_org_thoughtcrime_securesms_util_FileUtils_getFileDescriptorOwner + (JNIEnv *, jclass, jobject); + +/* + * Class: org_thoughtcrime_securesms_util_FileUtils + * Method: createMemoryFileDescriptor + * Signature: (Ljava/lang/String;)I + */ +JNIEXPORT jint JNICALL Java_org_thoughtcrime_securesms_util_FileUtils_createMemoryFileDescriptor + (JNIEnv *, jclass, jstring); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/app/libs/androidcopysdk-signal-debug_20.aar b/app/libs/androidcopysdk-signal-debug_20.aar new file mode 100644 index 00000000..1c1dbf48 Binary files /dev/null and b/app/libs/androidcopysdk-signal-debug_20.aar differ diff --git a/app/libs/common-debug.aar b/app/libs/common-debug.aar new file mode 100644 index 00000000..69aa6ab8 Binary files /dev/null and b/app/libs/common-debug.aar differ diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 00000000..500948f1 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 00000000..805f69b9 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/proguard/proguard-appcompat-v7.pro b/app/proguard/proguard-appcompat-v7.pro new file mode 100644 index 00000000..f0d67393 --- /dev/null +++ b/app/proguard/proguard-appcompat-v7.pro @@ -0,0 +1,13 @@ +# https://code.google.com/p/android/issues/detail?id=78377 +-keepnames class !android.support.v7.internal.view.menu.**, ** { *; } + +-keep public class android.support.v7.widget.** { *; } +-keep public class android.support.v7.internal.widget.** { *; } + +-keep public class * extends android.support.v4.view.ActionProvider { + public (android.content.Context); +} + +-keepattributes *Annotation* +-keep public class * extends android.support.design.widget.CoordinatorLayout.Behavior { *; } +-keep public class * extends android.support.design.widget.ViewOffsetBehavior { *; } diff --git a/app/proguard/proguard-automation.pro b/app/proguard/proguard-automation.pro new file mode 100644 index 00000000..43561f7b --- /dev/null +++ b/app/proguard/proguard-automation.pro @@ -0,0 +1,13 @@ +-keepattributes Exceptions +-dontskipnonpubliclibraryclassmembers + +-dontwarn android.test.** +-dontwarn com.android.support.test.** +-dontwarn sun.reflect.** +-dontwarn sun.misc.** +-dontwarn org.assertj.** +-dontwarn org.hamcrest.** +-dontwarn org.mockito.** +-dontwarn com.squareup.** + +-dontobfuscate \ No newline at end of file diff --git a/app/proguard/proguard-event_bus.pro b/app/proguard/proguard-event_bus.pro new file mode 100644 index 00000000..03f6016e --- /dev/null +++ b/app/proguard/proguard-event_bus.pro @@ -0,0 +1,5 @@ +-keepattributes *Annotation* +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; +} +-keep enum org.greenrobot.eventbus.ThreadMode { *; } \ No newline at end of file diff --git a/app/proguard/proguard-ez-vcard.pro b/app/proguard/proguard-ez-vcard.pro new file mode 100644 index 00000000..39be6827 --- /dev/null +++ b/app/proguard/proguard-ez-vcard.pro @@ -0,0 +1 @@ +-dontwarn ezvcard.io.html.HCardPage diff --git a/app/proguard/proguard-firebase-messaging.pro b/app/proguard/proguard-firebase-messaging.pro new file mode 100644 index 00000000..17af8ca9 --- /dev/null +++ b/app/proguard/proguard-firebase-messaging.pro @@ -0,0 +1 @@ +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector \ No newline at end of file diff --git a/app/proguard/proguard-glide.pro b/app/proguard/proguard-glide.pro new file mode 100644 index 00000000..a5a3efcc --- /dev/null +++ b/app/proguard/proguard-glide.pro @@ -0,0 +1,6 @@ +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.AppGlideModule +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; +} diff --git a/app/proguard/proguard-google-play-services.pro b/app/proguard/proguard-google-play-services.pro new file mode 100644 index 00000000..ae70fc4e --- /dev/null +++ b/app/proguard/proguard-google-play-services.pro @@ -0,0 +1,19 @@ +## Google Play Services 4.3.23 specific rules ## +## https://developer.android.com/google/play-services/setup.html#Proguard ## + +-keep class * extends java.util.ListResourceBundle { + protected Object[][] getContents(); +} + +-keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { + public static final *** NULL; +} + +-keepnames @com.google.android.gms.common.annotation.KeepName class * +-keepclassmembernames class * { + @com.google.android.gms.common.annotation.KeepName *; +} + +-keepnames class * implements android.os.Parcelable { + public static final ** CREATOR; +} \ No newline at end of file diff --git a/app/proguard/proguard-jackson.pro b/app/proguard/proguard-jackson.pro new file mode 100644 index 00000000..82f1593a --- /dev/null +++ b/app/proguard/proguard-jackson.pro @@ -0,0 +1,12 @@ +# Proguard configuration for Jackson 2.x (fasterxml package instead of codehaus package) + +-keepattributes *Annotation*,EnclosingMethod,Signature +-keepnames class com.fasterxml.jackson.** { +*; +} +-keepnames interface com.fasterxml.jackson.** { + *; +} +-dontwarn com.fasterxml.jackson.databind.** +-keep class org.codehaus.** { *; } + diff --git a/app/proguard/proguard-klinker.pro b/app/proguard/proguard-klinker.pro new file mode 100644 index 00000000..b3ac85a4 --- /dev/null +++ b/app/proguard/proguard-klinker.pro @@ -0,0 +1,3 @@ +-dontwarn android.net.ConnectivityManager +-dontwarn android.net.ConnectivityManager$NetworkCallback +-dontwarn org.webrtc.NetworkMonitorAutoDetect$ConnectivityManagerDelegate \ No newline at end of file diff --git a/app/proguard/proguard-okhttp.pro b/app/proguard/proguard-okhttp.pro new file mode 100644 index 00000000..d2b59a43 --- /dev/null +++ b/app/proguard/proguard-okhttp.pro @@ -0,0 +1,3 @@ +-dontwarn okio.** +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.ParametersAreNonnullByDefault diff --git a/app/proguard/proguard-retrofit.pro b/app/proguard/proguard-retrofit.pro new file mode 100644 index 00000000..2d0ca2eb --- /dev/null +++ b/app/proguard/proguard-retrofit.pro @@ -0,0 +1,4 @@ +-dontwarn retrofit.** +-keep class retrofit.** { *; } +-keepattributes Signature +-keepattributes Exceptions \ No newline at end of file diff --git a/app/proguard/proguard-retrolambda.pro b/app/proguard/proguard-retrolambda.pro new file mode 100644 index 00000000..82e89509 --- /dev/null +++ b/app/proguard/proguard-retrolambda.pro @@ -0,0 +1,2 @@ +-dontwarn java.lang.invoke.* +-dontwarn **$$Lambda$* diff --git a/app/proguard/proguard-rounded-image-view.pro b/app/proguard/proguard-rounded-image-view.pro new file mode 100644 index 00000000..988e525d --- /dev/null +++ b/app/proguard/proguard-rounded-image-view.pro @@ -0,0 +1 @@ +-dontwarn com.squareup.picasso.** \ No newline at end of file diff --git a/app/proguard/proguard-shortcutbadger.pro b/app/proguard/proguard-shortcutbadger.pro new file mode 100644 index 00000000..41b07bb6 --- /dev/null +++ b/app/proguard/proguard-shortcutbadger.pro @@ -0,0 +1 @@ +-keep class me.leolin.shortcutbadger.** {*;} diff --git a/app/proguard/proguard-sqlite.pro b/app/proguard/proguard-sqlite.pro new file mode 100644 index 00000000..b7837ef8 --- /dev/null +++ b/app/proguard/proguard-sqlite.pro @@ -0,0 +1,5 @@ +-keep class org.sqlite.** { *; } +-keep class org.sqlite.database.** { *; } + +-keep class net.sqlcipher.** { *; } +-dontwarn net.sqlcipher.** \ No newline at end of file diff --git a/app/proguard/proguard-square-okhttp.pro b/app/proguard/proguard-square-okhttp.pro new file mode 100644 index 00000000..6b25daf6 --- /dev/null +++ b/app/proguard/proguard-square-okhttp.pro @@ -0,0 +1,6 @@ +# OkHttp +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.squareup.okhttp.** { *; } +-keep interface com.squareup.okhttp.** { *; } +-dontwarn com.squareup.okhttp.** \ No newline at end of file diff --git a/app/proguard/proguard-square-okio.pro b/app/proguard/proguard-square-okio.pro new file mode 100644 index 00000000..589e679d --- /dev/null +++ b/app/proguard/proguard-square-okio.pro @@ -0,0 +1,5 @@ +# Okio +-keep class sun.misc.Unsafe { *; } +-dontwarn java.nio.file.* +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn okio.** \ No newline at end of file diff --git a/app/proguard/proguard.cfg b/app/proguard/proguard.cfg new file mode 100644 index 00000000..a7218672 --- /dev/null +++ b/app/proguard/proguard.cfg @@ -0,0 +1,11 @@ +-dontoptimize +-dontobfuscate +-keepattributes SourceFile,LineNumberTable +-keep class org.whispersystems.** { *; } +-keep class org.thoughtcrime.securesms.** { *; } +-keepclassmembers class ** { + public void onEvent*(**); +} + +# Protobuf lite +-keep class * extends com.google.protobuf.GeneratedMessageLite { *; } diff --git a/app/sampledata/contacts.json b/app/sampledata/contacts.json new file mode 100644 index 00000000..c8d25541 --- /dev/null +++ b/app/sampledata/contacts.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "name": "Ottttooooooooo Ocataaaaaaaavius", + "number": "+1 (555) 555-5555", + "label": "Mobile" + }, + { + "name": "Victor Von Doom Phd", + "number": "+1 (555) 123-4567", + "label": "Home" + }, + { + "name": "Flash Thompson", + "number": "+1 (555) 435-1261", + "label": "Work" + }, + { + "name": "Dr. Curtis Connors", + "number": "+1 (555) 992-1567", + "label": "Mobile" + }, + { + "name": "Billy Russo", + "number": "+1 (555) 234-1516", + "label": "Mobile" + } + ] +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/lock/PinHashing_hashPin_Test.java b/app/src/androidTest/java/org/thoughtcrime/securesms/lock/PinHashing_hashPin_Test.java new file mode 100644 index 00000000..5b8f365b --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/lock/PinHashing_hashPin_Test.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.lock; + +import org.junit.Test; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.signalservice.api.kbs.HashedPin; +import org.whispersystems.signalservice.api.kbs.KbsData; +import org.whispersystems.signalservice.api.kbs.MasterKey; + +import java.io.IOException; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class PinHashing_hashPin_Test { + + @Test + public void argon2_hashed_pin_password() throws IOException { + String pin = "password"; + byte[] backupId = Hex.fromStringCondensed("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f")); + + HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId); + KbsData kbsData = hashedPin.createNewKbsData(masterKey); + + assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("ab7e8499d21f80a6600b3b9ee349ac6d72c07e3359fe885a934ba7aa844429f8"), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("3f33ce58eb25b40436592a30eae2a8fabab1899095f4e2fba6e2d0dc43b4a2d9cac5a3931748522393951e0e54dec769"), kbsData.getCipherText()); + assertEquals(masterKey, kbsData.getMasterKey()); + + String localPinHash = PinHashing.localPinHash(pin); + assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin)); + } + + @Test + public void argon2_hashed_pin_another_password() throws IOException { + String pin = "anotherpassword"; + byte[] backupId = Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"); + MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("88a787415a2ecd79da0d1016a82a27c5c695c9a19b88b0aa1d35683280aa9a67")); + + HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId); + KbsData kbsData = hashedPin.createNewKbsData(masterKey); + + assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("301d9dd1e96f20ce51083f67d3298fd37b97525de8324d5e12ed2d407d3d927b"), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("9d9b05402ea39c17ff1c9298c8a0e86784a352aa02a74943bf8bcf07ec0f4b574a5b786ad0182c8d308d9eb06538b8c9"), kbsData.getCipherText()); + assertEquals(masterKey, kbsData.getMasterKey()); + + String localPinHash = PinHashing.localPinHash(pin); + assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin)); + } + + @Test + public void argon2_hashed_pin_password_with_spaces_diacritics_and_non_arabic_numerals() throws IOException { + String pin = " Pass६örd "; + byte[] backupId = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8"); + MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("9571f3fde1e58588ba49bcf82be1b301ca3859a6f59076f79a8f47181ef952bf")); + + HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId); + KbsData kbsData = hashedPin.createNewKbsData(masterKey); + + assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("ab645acdccc1652a48a34b2ac6926340ff35c03034013f68760f20013f028dd8"), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("11c0ba1834db15e47c172f6c987c64bd4cfc69c6047dd67a022afeec0165a10943f204d5b8f37b3cb7bab21c6dfc39c8"), kbsData.getCipherText()); + assertEquals(masterKey, kbsData.getMasterKey()); + + assertEquals("577939bccb2b6638c39222d5a97998a867c5e154e30b82cc120f2dd07a3de987", kbsData.getMasterKey().deriveRegistrationLock()); + + String localPinHash = PinHashing.localPinHash(pin); + assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin)); + } + + @Test + public void argon2_hashed_pin_password_with_just_non_arabic_numerals() throws IOException { + String pin = " ६१८ "; + byte[] backupId = Hex.fromStringCondensed("717dc111a98423a57196512606822fca646c653facd037c10728f14ba0be2ab3"); + MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("0432d735b32f66d0e3a70d4f9cc821a8529521a4937d26b987715d8eff4e4c54")); + + HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId); + KbsData kbsData = hashedPin.createNewKbsData(masterKey); + + + assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("d2fedabd0d4c17a371491c9722578843a26be3b4923e28d452ab2fc5491e794b"), kbsData.getKbsAccessKey()); + assertArrayEquals(Hex.fromStringCondensed("877ef871ef1fc668401c717ef21aa12e8523579fb1ff4474b76f28c2293537c80cc7569996c9e0229bea7f378e3a824e"), kbsData.getCipherText()); + assertEquals(masterKey, kbsData.getMasterKey()); + + assertEquals("23a75cb1df1a87df45cc2ed167c2bdc85ab1220b847c88761b0005cac907fce5", kbsData.getMasterKey().deriveRegistrationLock()); + + String localPinHash = PinHashing.localPinHash(pin); + assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin)); + } +} diff --git a/app/src/flipper/AndroidManifest.xml b/app/src/flipper/AndroidManifest.xml new file mode 100644 index 00000000..e2404f13 --- /dev/null +++ b/app/src/flipper/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/flipper/java/org/thoughtcrime/securesms/FlipperApplicationContext.java b/app/src/flipper/java/org/thoughtcrime/securesms/FlipperApplicationContext.java new file mode 100644 index 00000000..7c5fe2c2 --- /dev/null +++ b/app/src/flipper/java/org/thoughtcrime/securesms/FlipperApplicationContext.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms; + +import com.facebook.flipper.android.AndroidFlipperClient; +import com.facebook.flipper.core.FlipperClient; +import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin; +import com.facebook.flipper.plugins.inspector.DescriptorMapping; +import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; +import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; +import com.facebook.soloader.SoLoader; + +import org.thoughtcrime.securesms.database.FlipperSqlCipherAdapter; + +public class FlipperApplicationContext extends ApplicationContext { + + @Override + public void onCreate() { + super.onCreate(); + + SoLoader.init(this, false); + + FlipperClient client = AndroidFlipperClient.getInstance(this); + client.addPlugin(new InspectorFlipperPlugin(this, DescriptorMapping.withDefaults())); + client.addPlugin(new DatabasesFlipperPlugin(new FlipperSqlCipherAdapter(this))); + client.addPlugin(new SharedPreferencesFlipperPlugin(this)); + client.start(); + } +} diff --git a/app/src/flipper/java/org/thoughtcrime/securesms/database/FlipperSqlCipherAdapter.java b/app/src/flipper/java/org/thoughtcrime/securesms/database/FlipperSqlCipherAdapter.java new file mode 100644 index 00000000..dd9d0dde --- /dev/null +++ b/app/src/flipper/java/org/thoughtcrime/securesms/database/FlipperSqlCipherAdapter.java @@ -0,0 +1,267 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.flipper.plugins.databases.DatabaseDescriptor; +import com.facebook.flipper.plugins.databases.DatabaseDriver; + +import net.sqlcipher.DatabaseUtils; +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteStatement; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A lot of this code is taken from {@link com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver} + * and made to work with SqlCipher. Unfortunately I couldn't use it directly, nor subclass it. + */ +public class FlipperSqlCipherAdapter extends DatabaseDriver { + + private static final String TAG = Log.tag(FlipperSqlCipherAdapter.class); + + public FlipperSqlCipherAdapter(Context context) { + super(context); + } + + @Override + public List getDatabases() { + try { + Field databaseHelperField = DatabaseFactory.class.getDeclaredField("databaseHelper"); + databaseHelperField.setAccessible(true); + + SignalDatabase mainOpenHelper = Objects.requireNonNull((SQLCipherOpenHelper) databaseHelperField.get(DatabaseFactory.getInstance(getContext()))); + SignalDatabase keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext()); + SignalDatabase megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext()); + SignalDatabase jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext()); + + return Arrays.asList(new Descriptor(mainOpenHelper), + new Descriptor(keyValueOpenHelper), + new Descriptor(megaphoneOpenHelper), + new Descriptor(jobManagerOpenHelper)); + } catch (Exception e) { + Log.i(TAG, "Unable to use reflection to access raw database.", e); + } + return Collections.emptyList(); + } + + @Override + public List getTableNames(Descriptor descriptor) { + SQLiteDatabase db = descriptor.getReadable(); + List tableNames = new ArrayList<>(); + + try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", new String[] { "table", "view" })) { + while (cursor != null && cursor.moveToNext()) { + tableNames.add(cursor.getString(0)); + } + } + + return tableNames; + } + + @Override + public DatabaseGetTableDataResponse getTableData(Descriptor descriptor, String table, String order, boolean reverse, int start, int count) { + SQLiteDatabase db = descriptor.getReadable(); + + long total = DatabaseUtils.queryNumEntries(db, table); + String orderBy = order != null ? order + (reverse ? " DESC" : " ASC") : null; + String limitBy = start + ", " + count; + + try (Cursor cursor = db.query(table, null, null, null, null, null, orderBy, limitBy)) { + String[] columnNames = cursor.getColumnNames(); + List> rows = cursorToList(cursor); + + return new DatabaseGetTableDataResponse(Arrays.asList(columnNames), rows, start, rows.size(), total); + } + } + + @Override + public DatabaseGetTableStructureResponse getTableStructure(Descriptor descriptor, String table) { + SQLiteDatabase db = descriptor.getReadable(); + + Map foreignKeyValues = new HashMap<>(); + + try(Cursor cursor = db.rawQuery("PRAGMA foreign_key_list(" + table + ")", null)) { + while (cursor != null && cursor.moveToNext()) { + String from = cursor.getString(cursor.getColumnIndex("from")); + String to = cursor.getString(cursor.getColumnIndex("to")); + String tableName = cursor.getString(cursor.getColumnIndex("table")) + "(" + to + ")"; + + foreignKeyValues.put(from, tableName); + } + } + + + List structureColumns = Arrays.asList("column_name", "data_type", "nullable", "default", "primary_key", "foreign_key"); + List> structureValues = new ArrayList<>(); + + try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) { + while (cursor != null && cursor.moveToNext()) { + String columnName = cursor.getString(cursor.getColumnIndex("name")); + String foreignKey = foreignKeyValues.containsKey(columnName) ? foreignKeyValues.get(columnName) : null; + + structureValues.add(Arrays.asList(columnName, + cursor.getString(cursor.getColumnIndex("type")), + cursor.getInt(cursor.getColumnIndex("notnull")) == 0, + getObjectFromColumnIndex(cursor, cursor.getColumnIndex("dflt_value")), + cursor.getInt(cursor.getColumnIndex("pk")) == 1, + foreignKey)); + } + } + + + List indexesColumns = Arrays.asList("index_name", "unique", "indexed_column_name"); + List> indexesValues = new ArrayList<>(); + + try (Cursor indexesCursor = db.rawQuery("PRAGMA index_list(" + table + ")", null)) { + List indexedColumnNames = new ArrayList<>(); + String indexName = indexesCursor.getString(indexesCursor.getColumnIndex("name")); + + try(Cursor indexInfoCursor = db.rawQuery("PRAGMA index_info(" + indexName + ")", null)) { + while (indexInfoCursor.moveToNext()) { + indexedColumnNames.add(indexInfoCursor.getString(indexInfoCursor.getColumnIndex("name"))); + } + } + + indexesValues.add(Arrays.asList(indexName, + indexesCursor.getInt(indexesCursor.getColumnIndex("unique")) == 1, + TextUtils.join(",", indexedColumnNames))); + + } + + return new DatabaseGetTableStructureResponse(structureColumns, structureValues, indexesColumns, indexesValues); + } + + @Override + public DatabaseGetTableInfoResponse getTableInfo(Descriptor databaseDescriptor, String table) { + SQLiteDatabase db = databaseDescriptor.getReadable(); + + try (Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE name = ?", new String[] { table })) { + cursor.moveToFirst(); + return new DatabaseGetTableInfoResponse(cursor.getString(cursor.getColumnIndex("sql"))); + } + } + + @Override + public DatabaseExecuteSqlResponse executeSQL(Descriptor descriptor, String query) { + SQLiteDatabase db = descriptor.getWritable(); + + String firstWordUpperCase = getFirstWord(query).toUpperCase(); + + switch (firstWordUpperCase) { + case "UPDATE": + case "DELETE": + return executeUpdateDelete(db, query); + case "INSERT": + return executeInsert(db, query); + case "SELECT": + case "PRAGMA": + case "EXPLAIN": + return executeSelect(db, query); + default: + return executeRawQuery(db, query); + } + } + + private static String getFirstWord(String s) { + s = s.trim(); + int firstSpace = s.indexOf(' '); + return firstSpace >= 0 ? s.substring(0, firstSpace) : s; + } + + private static DatabaseExecuteSqlResponse executeUpdateDelete(SQLiteDatabase database, String query) { + SQLiteStatement statement = database.compileStatement(query); + int count = statement.executeUpdateDelete(); + + return DatabaseExecuteSqlResponse.successfulUpdateDelete(count); + } + + private static DatabaseExecuteSqlResponse executeInsert(SQLiteDatabase database, String query) { + SQLiteStatement statement = database.compileStatement(query); + long insertedId = statement.executeInsert(); + + return DatabaseExecuteSqlResponse.successfulInsert(insertedId); + } + + private static DatabaseExecuteSqlResponse executeSelect(SQLiteDatabase database, String query) { + try (Cursor cursor = database.rawQuery(query, null)) { + String[] columnNames = cursor.getColumnNames(); + List> rows = cursorToList(cursor); + + return DatabaseExecuteSqlResponse.successfulSelect(Arrays.asList(columnNames), rows); + } + } + + private static DatabaseExecuteSqlResponse executeRawQuery(SQLiteDatabase database, String query) { + database.execSQL(query); + return DatabaseExecuteSqlResponse.successfulRawQuery(); + } + + private static @NonNull List> cursorToList(Cursor cursor) { + List> rows = new ArrayList<>(); + int numColumns = cursor.getColumnCount(); + + while (cursor.moveToNext()) { + List values = new ArrayList<>(numColumns); + + for (int column = 0; column < numColumns; column++) { + values.add(getObjectFromColumnIndex(cursor, column)); + } + + rows.add(values); + } + return rows; + } + + private static @Nullable Object getObjectFromColumnIndex(Cursor cursor, int column) { + switch (cursor.getType(column)) { + case Cursor.FIELD_TYPE_NULL: + return null; + case Cursor.FIELD_TYPE_INTEGER: + return cursor.getLong(column); + case Cursor.FIELD_TYPE_FLOAT: + return cursor.getDouble(column); + case Cursor.FIELD_TYPE_BLOB: + return cursor.getBlob(column); + case Cursor.FIELD_TYPE_STRING: + default: + return cursor.getString(column); + } + } + + static class Descriptor implements DatabaseDescriptor { + private final SignalDatabase sqlCipherOpenHelper; + + Descriptor(@NonNull SignalDatabase sqlCipherOpenHelper) { + this.sqlCipherOpenHelper = sqlCipherOpenHelper; + } + + @Override + public String name() { + return sqlCipherOpenHelper.getDatabaseName(); + } + + public @NonNull SQLiteDatabase getReadable() { + return sqlCipherOpenHelper.getSqlCipherDatabase(); + } + + public @NonNull SQLiteDatabase getWritable() { + return sqlCipherOpenHelper.getSqlCipherDatabase(); + } + } +} diff --git a/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a898496a --- /dev/null +++ b/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b9ac2ba4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,850 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/databases/apns.db b/app/src/main/assets/databases/apns.db new file mode 100644 index 00000000..7ffe6b6b Binary files /dev/null and b/app/src/main/assets/databases/apns.db differ diff --git a/app/src/main/assets/emoji/Activity.webp b/app/src/main/assets/emoji/Activity.webp new file mode 100644 index 00000000..27f6c5cf Binary files /dev/null and b/app/src/main/assets/emoji/Activity.webp differ diff --git a/app/src/main/assets/emoji/Flags_0.webp b/app/src/main/assets/emoji/Flags_0.webp new file mode 100644 index 00000000..b0f3b34a Binary files /dev/null and b/app/src/main/assets/emoji/Flags_0.webp differ diff --git a/app/src/main/assets/emoji/Flags_1.webp b/app/src/main/assets/emoji/Flags_1.webp new file mode 100644 index 00000000..19b7ac0d Binary files /dev/null and b/app/src/main/assets/emoji/Flags_1.webp differ diff --git a/app/src/main/assets/emoji/Foods.webp b/app/src/main/assets/emoji/Foods.webp new file mode 100644 index 00000000..9fe5d10e Binary files /dev/null and b/app/src/main/assets/emoji/Foods.webp differ diff --git a/app/src/main/assets/emoji/Nature.webp b/app/src/main/assets/emoji/Nature.webp new file mode 100644 index 00000000..ab6e62a5 Binary files /dev/null and b/app/src/main/assets/emoji/Nature.webp differ diff --git a/app/src/main/assets/emoji/Objects.webp b/app/src/main/assets/emoji/Objects.webp new file mode 100644 index 00000000..2bf60c23 Binary files /dev/null and b/app/src/main/assets/emoji/Objects.webp differ diff --git a/app/src/main/assets/emoji/People_0.webp b/app/src/main/assets/emoji/People_0.webp new file mode 100644 index 00000000..dfd3593e Binary files /dev/null and b/app/src/main/assets/emoji/People_0.webp differ diff --git a/app/src/main/assets/emoji/People_1.webp b/app/src/main/assets/emoji/People_1.webp new file mode 100644 index 00000000..81704a75 Binary files /dev/null and b/app/src/main/assets/emoji/People_1.webp differ diff --git a/app/src/main/assets/emoji/People_2.webp b/app/src/main/assets/emoji/People_2.webp new file mode 100644 index 00000000..1220bb40 Binary files /dev/null and b/app/src/main/assets/emoji/People_2.webp differ diff --git a/app/src/main/assets/emoji/People_3.webp b/app/src/main/assets/emoji/People_3.webp new file mode 100644 index 00000000..a770bc62 Binary files /dev/null and b/app/src/main/assets/emoji/People_3.webp differ diff --git a/app/src/main/assets/emoji/People_4.webp b/app/src/main/assets/emoji/People_4.webp new file mode 100644 index 00000000..3ebbe58a Binary files /dev/null and b/app/src/main/assets/emoji/People_4.webp differ diff --git a/app/src/main/assets/emoji/People_5.webp b/app/src/main/assets/emoji/People_5.webp new file mode 100644 index 00000000..e458f43b Binary files /dev/null and b/app/src/main/assets/emoji/People_5.webp differ diff --git a/app/src/main/assets/emoji/People_6.webp b/app/src/main/assets/emoji/People_6.webp new file mode 100644 index 00000000..ee827ffe Binary files /dev/null and b/app/src/main/assets/emoji/People_6.webp differ diff --git a/app/src/main/assets/emoji/People_7.webp b/app/src/main/assets/emoji/People_7.webp new file mode 100644 index 00000000..4e8b4286 Binary files /dev/null and b/app/src/main/assets/emoji/People_7.webp differ diff --git a/app/src/main/assets/emoji/Places.webp b/app/src/main/assets/emoji/Places.webp new file mode 100644 index 00000000..0b8000bd Binary files /dev/null and b/app/src/main/assets/emoji/Places.webp differ diff --git a/app/src/main/assets/emoji/Symbols.webp b/app/src/main/assets/emoji/Symbols.webp new file mode 100644 index 00000000..c2ee7c30 Binary files /dev/null and b/app/src/main/assets/emoji/Symbols.webp differ diff --git a/app/src/main/assets/fonts/Roboto-Light.ttf b/app/src/main/assets/fonts/Roboto-Light.ttf new file mode 100644 index 00000000..13bf13af Binary files /dev/null and b/app/src/main/assets/fonts/Roboto-Light.ttf differ diff --git a/app/src/main/assets/sounds/state-change_confirm-down.ogg b/app/src/main/assets/sounds/state-change_confirm-down.ogg new file mode 100644 index 00000000..b2b5d58f Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-down.ogg differ diff --git a/app/src/main/assets/sounds/state-change_confirm-up.ogg b/app/src/main/assets/sounds/state-change_confirm-up.ogg new file mode 100644 index 00000000..36dd7e99 Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-up.ogg differ diff --git a/app/src/main/java/androidx/camera/view/SignalCameraView.java b/app/src/main/java/androidx/camera/view/SignalCameraView.java new file mode 100644 index 00000000..99641b10 --- /dev/null +++ b/app/src/main/java/androidx/camera/view/SignalCameraView.java @@ -0,0 +1,822 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.camera.view; + +import android.Manifest.permission; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.hardware.display.DisplayManager; +import android.hardware.display.DisplayManager.DisplayListener; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Display; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.Surface; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; +import androidx.camera.core.Camera; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.FocusMeteringAction; +import androidx.camera.core.FocusMeteringResult; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCapture.OnImageCapturedCallback; +import androidx.camera.core.ImageCapture.OnImageSavedCallback; +import androidx.camera.core.ImageProxy; +import androidx.camera.core.Logger; +import androidx.camera.core.MeteringPoint; +import androidx.camera.core.MeteringPointFactory; +import androidx.camera.core.VideoCapture; +import androidx.camera.core.VideoCapture.OnVideoSavedCallback; +import androidx.camera.core.impl.LensFacingConverter; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.impl.utils.futures.FutureCallback; +import androidx.camera.core.impl.utils.futures.Futures; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.util.concurrent.Executor; + +/** + * A {@link View} that displays a preview of the camera with methods {@link + * #takePicture(Executor, OnImageCapturedCallback)}, + * {@link #takePicture(ImageCapture.OutputFileOptions, Executor, OnImageSavedCallback)}, + * {@link #startRecording(File , Executor , OnVideoSavedCallback callback)} + * and {@link #stopRecording()}. + * + *

Because the Camera is a limited resource and consumes a high amount of power, CameraView must + * be opened/closed. CameraView will handle opening/closing automatically through use of a {@link + * LifecycleOwner}. Use {@link #bindToLifecycle(LifecycleOwner)} to start the camera. + */ +@RequiresApi(21) +@SuppressLint("RestrictedApi") +public final class SignalCameraView extends FrameLayout { + static final String TAG = SignalCameraView.class.getSimpleName(); + + static final int INDEFINITE_VIDEO_DURATION = -1; + static final int INDEFINITE_VIDEO_SIZE = -1; + + private static final String EXTRA_SUPER = "super"; + private static final String EXTRA_ZOOM_RATIO = "zoom_ratio"; + private static final String EXTRA_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled"; + private static final String EXTRA_FLASH = "flash"; + private static final String EXTRA_MAX_VIDEO_DURATION = "max_video_duration"; + private static final String EXTRA_MAX_VIDEO_SIZE = "max_video_size"; + private static final String EXTRA_SCALE_TYPE = "scale_type"; + private static final String EXTRA_CAMERA_DIRECTION = "camera_direction"; + private static final String EXTRA_CAPTURE_MODE = "captureMode"; + + private static final int LENS_FACING_NONE = 0; + private static final int LENS_FACING_FRONT = 1; + private static final int LENS_FACING_BACK = 2; + private static final int FLASH_MODE_AUTO = 1; + private static final int FLASH_MODE_ON = 2; + private static final int FLASH_MODE_OFF = 4; + // For tap-to-focus + private long mDownEventTimestamp; + // For pinch-to-zoom + private PinchToZoomGestureDetector mPinchToZoomGestureDetector; + private boolean mIsPinchToZoomEnabled = true; + SignalCameraXModule mCameraModule; + private final DisplayManager.DisplayListener mDisplayListener = + new DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + } + + @Override + public void onDisplayRemoved(int displayId) { + } + + @Override + public void onDisplayChanged(int displayId) { + mCameraModule.invalidateView(); + } + }; + private PreviewView mPreviewView; + // For accessibility event + private MotionEvent mUpEvent; + + public SignalCameraView(@NonNull Context context) { + this(context, null); + } + + public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context, attrs); + } + + @RequiresApi(21) + public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs); + } + + /** + * Binds control of the camera used by this view to the given lifecycle. + * + *

This links opening/closing the camera to the given lifecycle. The camera will not operate + * unless this method is called with a valid {@link LifecycleOwner} that is not in the {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state. Call this method only once camera + * permissions have been obtained. + * + *

Once the provided lifecycle has transitioned to a {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state, CameraView must be bound to a new + * lifecycle through this method in order to operate the camera. + * + * @param lifecycleOwner The lifecycle that will control this view's camera + * @throws IllegalArgumentException if provided lifecycle is in a {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state. + * @throws IllegalStateException if camera permissions are not granted. + */ + @RequiresPermission(permission.CAMERA) + public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner) { + mCameraModule.bindToLifecycle(lifecycleOwner); + } + + private void init(Context context, @Nullable AttributeSet attrs) { + addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */); + mCameraModule = new SignalCameraXModule(this); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView); + setScaleType( + PreviewView.ScaleType.fromId( + a.getInteger(R.styleable.CameraView_scaleType, + getScaleType().getId()))); + setPinchToZoomEnabled( + a.getBoolean( + R.styleable.CameraView_pinchToZoomEnabled, isPinchToZoomEnabled())); + setCaptureMode( + CaptureMode.fromId( + a.getInteger(R.styleable.CameraView_captureMode, + getCaptureMode().getId()))); + + int lensFacing = a.getInt(R.styleable.CameraView_lensFacing, LENS_FACING_BACK); + switch (lensFacing) { + case LENS_FACING_NONE: + setCameraLensFacing(null); + break; + case LENS_FACING_FRONT: + setCameraLensFacing(CameraSelector.LENS_FACING_FRONT); + break; + case LENS_FACING_BACK: + setCameraLensFacing(CameraSelector.LENS_FACING_BACK); + break; + default: + // Unhandled event. + } + + int flashMode = a.getInt(R.styleable.CameraView_flash, 0); + switch (flashMode) { + case FLASH_MODE_AUTO: + setFlash(ImageCapture.FLASH_MODE_AUTO); + break; + case FLASH_MODE_ON: + setFlash(ImageCapture.FLASH_MODE_ON); + break; + case FLASH_MODE_OFF: + setFlash(ImageCapture.FLASH_MODE_OFF); + break; + default: + // Unhandled event. + } + + a.recycle(); + } + + if (getBackground() == null) { + setBackgroundColor(0xFF111111); + } + + mPinchToZoomGestureDetector = new PinchToZoomGestureDetector(context); + } + + @Override + @NonNull + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + + @Override + @NonNull + protected Parcelable onSaveInstanceState() { + // TODO(b/113884082): Decide what belongs here or what should be invalidated on + // configuration + // change + Bundle state = new Bundle(); + state.putParcelable(EXTRA_SUPER, super.onSaveInstanceState()); + state.putInt(EXTRA_SCALE_TYPE, getScaleType().getId()); + state.putFloat(EXTRA_ZOOM_RATIO, getZoomRatio()); + state.putBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED, isPinchToZoomEnabled()); + state.putString(EXTRA_FLASH, FlashModeConverter.nameOf(getFlash())); + state.putLong(EXTRA_MAX_VIDEO_DURATION, getMaxVideoDuration()); + state.putLong(EXTRA_MAX_VIDEO_SIZE, getMaxVideoSize()); + if (getCameraLensFacing() != null) { + state.putString(EXTRA_CAMERA_DIRECTION, + LensFacingConverter.nameOf(getCameraLensFacing())); + } + state.putInt(EXTRA_CAPTURE_MODE, getCaptureMode().getId()); + return state; + } + + @Override + protected void onRestoreInstanceState(@Nullable Parcelable savedState) { + // TODO(b/113884082): Decide what belongs here or what should be invalidated on + // configuration + // change + if (savedState instanceof Bundle) { + Bundle state = (Bundle) savedState; + super.onRestoreInstanceState(state.getParcelable(EXTRA_SUPER)); + setScaleType(PreviewView.ScaleType.fromId(state.getInt(EXTRA_SCALE_TYPE))); + setZoomRatio(state.getFloat(EXTRA_ZOOM_RATIO)); + setPinchToZoomEnabled(state.getBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED)); + setFlash(FlashModeConverter.valueOf(state.getString(EXTRA_FLASH))); + setMaxVideoDuration(state.getLong(EXTRA_MAX_VIDEO_DURATION)); + setMaxVideoSize(state.getLong(EXTRA_MAX_VIDEO_SIZE)); + String lensFacingString = state.getString(EXTRA_CAMERA_DIRECTION); + setCameraLensFacing( + TextUtils.isEmpty(lensFacingString) + ? null + : LensFacingConverter.valueOf(lensFacingString)); + setCaptureMode(CaptureMode.fromId(state.getInt(EXTRA_CAPTURE_MODE))); + } else { + super.onRestoreInstanceState(savedState); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + DisplayManager dpyMgr = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + dpyMgr.registerDisplayListener(mDisplayListener, new Handler(Looper.getMainLooper())); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + DisplayManager dpyMgr = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + dpyMgr.unregisterDisplayListener(mDisplayListener); + } + + /** + * Gets the {@link LiveData} of the underlying {@link PreviewView}'s + * {@link PreviewView.StreamState}. + * + * @return A {@link LiveData} containing the {@link PreviewView.StreamState}. Apps can either + * get current value by {@link LiveData#getValue()} or register a observer by + * {@link LiveData#observe}. + * @see PreviewView#getPreviewStreamState() + */ + @NonNull + public LiveData getPreviewStreamState() { + return mPreviewView.getPreviewStreamState(); + } + + @NonNull + PreviewView getPreviewView() { + return mPreviewView; + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Since bindToLifecycle will depend on the measured dimension, only call it when measured + // dimension is not 0x0 + if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { + mCameraModule.bindToLifecycleAfterViewMeasured(); + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + // In case that the CameraView size is always set as 0x0, we still need to trigger to force + // binding to lifecycle + mCameraModule.bindToLifecycleAfterViewMeasured(); + + mCameraModule.invalidateView(); + super.onLayout(changed, left, top, right, bottom); + } + + /** + * @return One of {@link Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link + * Surface#ROTATION_180}, {@link Surface#ROTATION_270}. + */ + int getDisplaySurfaceRotation() { + Display display = getDisplay(); + + // Null when the View is detached. If we were in the middle of a background operation, + // better to not NPE. When the background operation finishes, it'll realize that the camera + // was closed. + if (display == null) { + return 0; + } + + return display.getRotation(); + } + + /** + * Returns the scale type used to scale the preview. + * + * @return The current {@link PreviewView.ScaleType}. + */ + @NonNull + public PreviewView.ScaleType getScaleType() { + return mPreviewView.getScaleType(); + } + + /** + * Sets the view finder scale type. + * + *

This controls how the view finder should be scaled and positioned within the view. + * + * @param scaleType The desired {@link PreviewView.ScaleType}. + */ + public void setScaleType(@NonNull PreviewView.ScaleType scaleType) { + mPreviewView.setScaleType(scaleType); + } + + /** + * Returns the scale type used to scale the preview. + * + * @return The current {@link CaptureMode}. + */ + @NonNull + public CaptureMode getCaptureMode() { + return mCameraModule.getCaptureMode(); + } + + /** + * Sets the CameraView capture mode + * + *

This controls only image or video capture function is enabled or both are enabled. + * + * @param captureMode The desired {@link CaptureMode}. + */ + public void setCaptureMode(@NonNull CaptureMode captureMode) { + mCameraModule.setCaptureMode(captureMode); + } + + /** + * Returns the maximum duration of videos, or {@link #INDEFINITE_VIDEO_DURATION} if there is no + * timeout. + * + * @hide Not currently implemented. + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public long getMaxVideoDuration() { + return mCameraModule.getMaxVideoDuration(); + } + + /** + * Sets the maximum video duration before + * {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)} is called + * automatically. + * Use {@link #INDEFINITE_VIDEO_DURATION} to disable the timeout. + */ + private void setMaxVideoDuration(long duration) { + mCameraModule.setMaxVideoDuration(duration); + } + + /** + * Returns the maximum size of videos in bytes, or {@link #INDEFINITE_VIDEO_SIZE} if there is no + * timeout. + */ + private long getMaxVideoSize() { + return mCameraModule.getMaxVideoSize(); + } + + /** + * Sets the maximum video size in bytes before + * {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)} + * is called automatically. Use {@link #INDEFINITE_VIDEO_SIZE} to disable the size restriction. + */ + private void setMaxVideoSize(long size) { + mCameraModule.setMaxVideoSize(size); + } + + /** + * Takes a picture, and calls {@link OnImageCapturedCallback#onCaptureSuccess(ImageProxy)} + * once when done. + * + * @param executor The executor in which the callback methods will be run. + * @param callback Callback which will receive success or failure callbacks. + */ + public void takePicture(@NonNull Executor executor, @NonNull OnImageCapturedCallback callback) { + mCameraModule.takePicture(executor, callback); + } + + /** + * Takes a picture and calls + * {@link OnImageSavedCallback#onImageSaved(ImageCapture.OutputFileResults)} when done. + * + *

The value of {@link ImageCapture.Metadata#isReversedHorizontal()} in the + * {@link ImageCapture.OutputFileOptions} will be overwritten based on camera direction. For + * front camera, it will be set to true; for back camera, it will be set to false. + * + * @param outputFileOptions Options to store the newly captured image. + * @param executor The executor in which the callback methods will be run. + * @param callback Callback which will receive success or failure. + */ + public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions, + @NonNull Executor executor, + @NonNull OnImageSavedCallback callback) { + mCameraModule.takePicture(outputFileOptions, executor, callback); + } + + /** + * Takes a video and calls the OnVideoSavedCallback when done. + * + * @param outputFileOptions Options to store the newly captured video. + * @param executor The executor in which the callback methods will be run. + * @param callback Callback which will receive success or failure. + */ + public void startRecording(@NonNull VideoCapture.OutputFileOptions outputFileOptions, + @NonNull Executor executor, + @NonNull OnVideoSavedCallback callback) { + mCameraModule.startRecording(outputFileOptions, executor, callback); + } + + /** Stops an in progress video. */ + public void stopRecording() { + mCameraModule.stopRecording(); + } + + /** @return True if currently recording. */ + public boolean isRecording() { + return mCameraModule.isRecording(); + } + + /** + * Queries whether the current device has a camera with the specified direction. + * + * @return True if the device supports the direction. + * @throws IllegalStateException if the CAMERA permission is not currently granted. + */ + @RequiresPermission(permission.CAMERA) + public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) { + return mCameraModule.hasCameraWithLensFacing(lensFacing); + } + + /** + * Toggles between the primary front facing camera and the primary back facing camera. + * + *

This will have no effect if not already bound to a lifecycle via {@link + * #bindToLifecycle(LifecycleOwner)}. + */ + public void toggleCamera() { + mCameraModule.toggleCamera(); + } + + /** + * Sets the desired camera by specifying desired lensFacing. + * + *

This will choose the primary camera with the specified camera lensFacing. + * + *

If called before {@link #bindToLifecycle(LifecycleOwner)}, this will set the camera to be + * used when first bound to the lifecycle. If the specified lensFacing is not supported by the + * device, as determined by {@link #hasCameraWithLensFacing(int)}, the first supported + * lensFacing will be chosen when {@link #bindToLifecycle(LifecycleOwner)} is called. + * + *

If called with {@code null} AFTER binding to the lifecycle, the behavior would be + * equivalent to unbind the use cases without the lifecycle having to be destroyed. + * + * @param lensFacing The desired camera lensFacing. + */ + public void setCameraLensFacing(@Nullable Integer lensFacing) { + mCameraModule.setCameraLensFacing(lensFacing); + } + + /** Returns the currently selected lensFacing. */ + @Nullable + public Integer getCameraLensFacing() { + return mCameraModule.getLensFacing(); + } + + /** Gets the active flash strategy. */ + @ImageCapture.FlashMode + public int getFlash() { + return mCameraModule.getFlash(); + } + + // Begin Signal Custom Code Block + public boolean hasFlash() { + return mCameraModule.hasFlash(); + } + // End Signal Custom Code Block + + /** Sets the active flash strategy. */ + public void setFlash(@ImageCapture.FlashMode int flashMode) { + mCameraModule.setFlash(flashMode); + } + + private long delta() { + return System.currentTimeMillis() - mDownEventTimestamp; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + // Disable pinch-to-zoom and tap-to-focus while the camera module is paused. + if (mCameraModule.isPaused()) { + return false; + } + // Only forward the event to the pinch-to-zoom gesture detector when pinch-to-zoom is + // enabled. + if (isPinchToZoomEnabled()) { + mPinchToZoomGestureDetector.onTouchEvent(event); + } + if (event.getPointerCount() == 2 && isPinchToZoomEnabled() && isZoomSupported()) { + return true; + } + + // Camera focus + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mDownEventTimestamp = System.currentTimeMillis(); + break; + case MotionEvent.ACTION_UP: + if (delta() < ViewConfiguration.getLongPressTimeout() + && mCameraModule.isBoundToLifecycle()) { + mUpEvent = event; + performClick(); + } + break; + default: + // Unhandled event. + return false; + } + return true; + } + + /** + * Focus the position of the touch event, or focus the center of the preview for + * accessibility events + */ + @Override + public boolean performClick() { + super.performClick(); + + final float x = (mUpEvent != null) ? mUpEvent.getX() : getX() + getWidth() / 2f; + final float y = (mUpEvent != null) ? mUpEvent.getY() : getY() + getHeight() / 2f; + mUpEvent = null; + + Camera camera = mCameraModule.getCamera(); + if (camera != null) { + MeteringPointFactory pointFactory = mPreviewView.getMeteringPointFactory(); + float afPointWidth = 1.0f / 6.0f; // 1/6 total area + float aePointWidth = afPointWidth * 1.5f; + MeteringPoint afPoint = pointFactory.createPoint(x, y, afPointWidth); + MeteringPoint aePoint = pointFactory.createPoint(x, y, aePointWidth); + + ListenableFuture future = + camera.getCameraControl().startFocusAndMetering( + new FocusMeteringAction.Builder(afPoint, + FocusMeteringAction.FLAG_AF).addPoint(aePoint, + FocusMeteringAction.FLAG_AE).build()); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable FocusMeteringResult result) { + } + + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); + + } else { + Logger.d(TAG, "cannot access camera"); + } + + return true; + } + + float rangeLimit(float val, float max, float min) { + return Math.min(Math.max(val, min), max); + } + + /** + * Returns whether the view allows pinch-to-zoom. + * + * @return True if pinch to zoom is enabled. + */ + public boolean isPinchToZoomEnabled() { + return mIsPinchToZoomEnabled; + } + + /** + * Sets whether the view should allow pinch-to-zoom. + * + *

When enabled, the user can pinch the camera to zoom in/out. This only has an effect if the + * bound camera supports zoom. + * + * @param enabled True to enable pinch-to-zoom. + */ + public void setPinchToZoomEnabled(boolean enabled) { + mIsPinchToZoomEnabled = enabled; + } + + /** + * Returns the current zoom ratio. + * + * @return The current zoom ratio. + */ + public float getZoomRatio() { + return mCameraModule.getZoomRatio(); + } + + /** + * Sets the current zoom ratio. + * + *

Valid zoom values range from {@link #getMinZoomRatio()} to {@link #getMaxZoomRatio()}. + * + * @param zoomRatio The requested zoom ratio. + */ + public void setZoomRatio(float zoomRatio) { + mCameraModule.setZoomRatio(zoomRatio); + } + + /** + * Returns the minimum zoom ratio. + * + *

For most cameras this should return a zoom ratio of 1. A zoom ratio of 1 corresponds to a + * non-zoomed image. + * + * @return The minimum zoom ratio. + */ + public float getMinZoomRatio() { + return mCameraModule.getMinZoomRatio(); + } + + /** + * Returns the maximum zoom ratio. + * + *

The zoom ratio corresponds to the ratio between both the widths and heights of a + * non-zoomed image and a maximally zoomed image for the selected camera. + * + * @return The maximum zoom ratio. + */ + public float getMaxZoomRatio() { + return mCameraModule.getMaxZoomRatio(); + } + + /** + * Returns whether the bound camera supports zooming. + * + * @return True if the camera supports zooming. + */ + public boolean isZoomSupported() { + return mCameraModule.isZoomSupported(); + } + + /** + * Turns on/off torch. + * + * @param torch True to turn on torch, false to turn off torch. + */ + public void enableTorch(boolean torch) { + mCameraModule.enableTorch(torch); + } + + /** + * Returns current torch status. + * + * @return true if torch is on , otherwise false + */ + public boolean isTorchOn() { + return mCameraModule.isTorchOn(); + } + + /** + * The capture mode used by CameraView. + * + *

This enum can be used to determine which capture mode will be enabled for {@link + * SignalCameraView}. + */ + public enum CaptureMode { + /** A mode where image capture is enabled. */ + IMAGE(0), + /** A mode where video capture is enabled. */ + VIDEO(1), + /** + * A mode where both image capture and video capture are simultaneously enabled. Note that + * this mode may not be available on every device. + */ + MIXED(2); + + private final int mId; + + int getId() { + return mId; + } + + CaptureMode(int id) { + mId = id; + } + + static CaptureMode fromId(int id) { + for (CaptureMode f : values()) { + if (f.mId == id) { + return f; + } + } + throw new IllegalArgumentException(); + } + } + + static class S extends ScaleGestureDetector.SimpleOnScaleGestureListener { + private ScaleGestureDetector.OnScaleGestureListener mListener; + + void setRealGestureDetector(ScaleGestureDetector.OnScaleGestureListener l) { + mListener = l; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector); + } + } + + private class PinchToZoomGestureDetector extends ScaleGestureDetector + implements ScaleGestureDetector.OnScaleGestureListener { + PinchToZoomGestureDetector(Context context) { + this(context, new S()); + } + + PinchToZoomGestureDetector(Context context, S s) { + super(context, s); + s.setRealGestureDetector(this); + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + float scale = detector.getScaleFactor(); + + // Speeding up the zoom by 2X. + if (scale > 1f) { + scale = 1.0f + (scale - 1.0f) * 2; + } else { + scale = 1.0f - (1.0f - scale) * 2; + } + + float newRatio = getZoomRatio() * scale; + newRatio = rangeLimit(newRatio, getMaxZoomRatio(), getMinZoomRatio()); + setZoomRatio(newRatio); + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/androidx/camera/view/SignalCameraXModule.java b/app/src/main/java/androidx/camera/view/SignalCameraXModule.java new file mode 100644 index 00000000..d6af30e5 --- /dev/null +++ b/app/src/main/java/androidx/camera/view/SignalCameraXModule.java @@ -0,0 +1,696 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.camera.view; + +import android.Manifest.permission; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.util.Rational; +import android.util.Size; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.camera.core.Camera; +import androidx.camera.core.CameraInfoUnavailableException; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCapture.OnImageCapturedCallback; +import androidx.camera.core.ImageCapture.OnImageSavedCallback; +import androidx.camera.core.Logger; +import androidx.camera.core.Preview; +import androidx.camera.core.TorchState; +import androidx.camera.core.UseCase; +import androidx.camera.core.VideoCapture; +import androidx.camera.core.VideoCapture.OnVideoSavedCallback; +import androidx.camera.core.impl.CameraInternal; +import androidx.camera.core.impl.LensFacingConverter; +import androidx.camera.core.impl.utils.CameraOrientationUtil; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.impl.utils.futures.FutureCallback; +import androidx.camera.core.impl.utils.futures.Futures; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.util.Preconditions; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.video.VideoUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF; + +/** CameraX use case operation built on @{link androidx.camera.core}. */ +@RequiresApi(21) +@SuppressLint("RestrictedApi") +final class SignalCameraXModule { + public static final String TAG = "CameraXModule"; + + private static final float UNITY_ZOOM_SCALE = 1f; + private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE; + private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9); + private static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3); + private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16); + private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4); + + private final Preview.Builder mPreviewBuilder; + private final VideoCapture.Builder mVideoCaptureBuilder; + private final ImageCapture.Builder mImageCaptureBuilder; + private final SignalCameraView mCameraView; + final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false); + private SignalCameraView.CaptureMode mCaptureMode = SignalCameraView.CaptureMode.IMAGE; + private long mMaxVideoDuration = SignalCameraView.INDEFINITE_VIDEO_DURATION; + private long mMaxVideoSize = SignalCameraView.INDEFINITE_VIDEO_SIZE; + @ImageCapture.FlashMode + private int mFlash = FLASH_MODE_OFF; + @Nullable + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + Camera mCamera; + @Nullable + private ImageCapture mImageCapture; + @Nullable + private VideoCapture mVideoCapture; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @Nullable + Preview mPreview; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @Nullable + LifecycleOwner mCurrentLifecycle; + private final LifecycleObserver mCurrentLifecycleObserver = + new LifecycleObserver() { + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + public void onDestroy(LifecycleOwner owner) { + if (owner == mCurrentLifecycle) { + clearCurrentLifecycle(); + } + } + }; + @Nullable + private LifecycleOwner mNewLifecycle; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @Nullable + Integer mCameraLensFacing = CameraSelector.LENS_FACING_BACK; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @Nullable + ProcessCameraProvider mCameraProvider; + + SignalCameraXModule(SignalCameraView view) { + mCameraView = view; + + Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()), + new FutureCallback() { + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + public void onSuccess(@Nullable ProcessCameraProvider provider) { + Preconditions.checkNotNull(provider); + mCameraProvider = provider; + if (mCurrentLifecycle != null) { + bindToLifecycle(mCurrentLifecycle); + } + } + + @Override + public void onFailure(Throwable t) { + throw new RuntimeException("CameraX failed to initialize.", t); + } + }, CameraXExecutors.mainThreadExecutor()); + + mPreviewBuilder = new Preview.Builder().setTargetName("Preview"); + + mImageCaptureBuilder = new ImageCapture.Builder().setTargetName("ImageCapture"); + + mVideoCaptureBuilder = new VideoCapture.Builder().setTargetName("VideoCapture") + .setAudioBitRate(VideoUtil.AUDIO_BIT_RATE) + .setVideoFrameRate(VideoUtil.VIDEO_FRAME_RATE) + .setBitRate(VideoUtil.VIDEO_BIT_RATE); + } + + @RequiresPermission(permission.CAMERA) + void bindToLifecycle(LifecycleOwner lifecycleOwner) { + mNewLifecycle = lifecycleOwner; + + if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { + bindToLifecycleAfterViewMeasured(); + } + } + + @RequiresPermission(permission.CAMERA) + void bindToLifecycleAfterViewMeasured() { + if (mNewLifecycle == null) { + return; + } + + clearCurrentLifecycle(); + if (mNewLifecycle.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) { + // Lifecycle is already in a destroyed state. Since it may have been a valid + // lifecycle when bound, but became destroyed while waiting for layout, treat this as + // a no-op now that we have cleared the previous lifecycle. + mNewLifecycle = null; + return; + } + mCurrentLifecycle = mNewLifecycle; + mNewLifecycle = null; + + if (mCameraProvider == null) { + // try again once the camera provider is no longer null + return; + } + + Set available = getAvailableCameraLensFacing(); + + if (available.isEmpty()) { + Logger.w(TAG, "Unable to bindToLifeCycle since no cameras available"); + mCameraLensFacing = null; + } + + // Ensure the current camera exists, or default to another camera + if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) { + Logger.w(TAG, "Camera does not exist with direction " + mCameraLensFacing); + + // Default to the first available camera direction + mCameraLensFacing = available.iterator().next(); + + Logger.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing); + } + + // Do not attempt to create use cases for a null cameraLensFacing. This could occur if + // the user explicitly sets the LensFacing to null, or if we determined there + // were no available cameras, which should be logged in the logic above. + if (mCameraLensFacing == null) { + return; + } + + // Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect + // ratio as 16:9 if it is VIDEO or MIXED mode. Then, it will be WYSIWYG when the view finder + // is in CENTER_INSIDE mode. + + boolean isDisplayPortrait = getDisplayRotationDegrees() == 0 + || getDisplayRotationDegrees() == 180; + + // Begin Signal Custom Code Block + int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels); + // End Signal Custom Code Block + + Rational targetAspectRatio; + if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) { + // Begin Signal Custom Code Block + mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait)); + // End Signal Custom Code Block + targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3; + } else { + // Begin Signal Custom Code Block + mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait)); + // End Signal Custom Code Block + targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9; + } + + // Begin Signal Custom Code Block + mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode()); + // End Signal Custom Code Block + + mImageCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation()); + mImageCapture = mImageCaptureBuilder.build(); + + // Begin Signal Custom Code Block + Size size = VideoUtil.getVideoRecordingSize(); + mVideoCaptureBuilder.setTargetResolution(size); + mVideoCaptureBuilder.setMaxResolution(size); + // End Signal Custom Code Block + + mVideoCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation()); + // Begin Signal Custom Code Block + if (MediaConstraints.isVideoTranscodeAvailable()) { + mVideoCapture = mVideoCaptureBuilder.build(); + } + // End Signal Custom Code Block + + // Adjusts the preview resolution according to the view size and the target aspect ratio. + int height = (int) (getMeasuredWidth() / targetAspectRatio.floatValue()); + mPreviewBuilder.setTargetResolution(new Size(getMeasuredWidth(), height)); + + mPreview = mPreviewBuilder.build(); + mPreview.setSurfaceProvider(mCameraView.getPreviewView().getSurfaceProvider()); + + CameraSelector cameraSelector = + new CameraSelector.Builder().requireLensFacing(mCameraLensFacing).build(); + if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) { + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mImageCapture, + mPreview); + } else if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) { + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mVideoCapture, + mPreview); + } else { + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mImageCapture, + mVideoCapture, mPreview); + } + + setZoomRatio(UNITY_ZOOM_SCALE); + mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver); + // Enable flash setting in ImageCapture after use cases are created and binded. + setFlash(getFlash()); + } + + public void open() { + throw new UnsupportedOperationException( + "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead."); + } + + public void close() { + throw new UnsupportedOperationException( + "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead."); + } + + public void takePicture(Executor executor, OnImageCapturedCallback callback) { + if (mImageCapture == null) { + return; + } + + if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) { + throw new IllegalStateException("Can not take picture under VIDEO capture mode."); + } + + if (callback == null) { + throw new IllegalArgumentException("OnImageCapturedCallback should not be empty"); + } + + mImageCapture.takePicture(executor, callback); + } + + public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions, + @NonNull Executor executor, OnImageSavedCallback callback) { + if (mImageCapture == null) { + return; + } + + if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) { + throw new IllegalStateException("Can not take picture under VIDEO capture mode."); + } + + if (callback == null) { + throw new IllegalArgumentException("OnImageSavedCallback should not be empty"); + } + + outputFileOptions.getMetadata().setReversedHorizontal(mCameraLensFacing != null + && mCameraLensFacing == CameraSelector.LENS_FACING_FRONT); + mImageCapture.takePicture(outputFileOptions, executor, callback); + } + + public void startRecording(VideoCapture.OutputFileOptions outputFileOptions, + Executor executor, final OnVideoSavedCallback callback) { + if (mVideoCapture == null) { + return; + } + + if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) { + throw new IllegalStateException("Can not record video under IMAGE capture mode."); + } + + if (callback == null) { + throw new IllegalArgumentException("OnVideoSavedCallback should not be empty"); + } + + mVideoIsRecording.set(true); + mVideoCapture.startRecording( + outputFileOptions, + executor, + new VideoCapture.OnVideoSavedCallback() { + @Override + public void onVideoSaved( + @NonNull VideoCapture.OutputFileResults outputFileResults) { + mVideoIsRecording.set(false); + callback.onVideoSaved(outputFileResults); + } + + @Override + public void onError( + @VideoCapture.VideoCaptureError int videoCaptureError, + @NonNull String message, + @Nullable Throwable cause) { + mVideoIsRecording.set(false); + Logger.e(TAG, message, cause); + callback.onError(videoCaptureError, message, cause); + } + }); + } + + public void stopRecording() { + if (mVideoCapture == null) { + return; + } + + mVideoCapture.stopRecording(); + } + + public boolean isRecording() { + return mVideoIsRecording.get(); + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + public void setCameraLensFacing(@Nullable Integer lensFacing) { + // Setting same lens facing is a no-op, so check for that first + if (!Objects.equals(mCameraLensFacing, lensFacing)) { + // If we're not bound to a lifecycle, just update the camera that will be opened when we + // attach to a lifecycle. + mCameraLensFacing = lensFacing; + + if (mCurrentLifecycle != null) { + // Re-bind to lifecycle with new camera + bindToLifecycle(mCurrentLifecycle); + } + } + } + + @RequiresPermission(permission.CAMERA) + public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) { + if (mCameraProvider == null) { + return false; + } + try { + return mCameraProvider.hasCamera( + new CameraSelector.Builder().requireLensFacing(lensFacing).build()); + } catch (CameraInfoUnavailableException e) { + return false; + } + } + + @Nullable + public Integer getLensFacing() { + return mCameraLensFacing; + } + + public void toggleCamera() { + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + Set availableCameraLensFacing = getAvailableCameraLensFacing(); + + if (availableCameraLensFacing.isEmpty()) { + return; + } + + if (mCameraLensFacing == null) { + setCameraLensFacing(availableCameraLensFacing.iterator().next()); + return; + } + + if (mCameraLensFacing == CameraSelector.LENS_FACING_BACK + && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_FRONT)) { + setCameraLensFacing(CameraSelector.LENS_FACING_FRONT); + return; + } + + if (mCameraLensFacing == CameraSelector.LENS_FACING_FRONT + && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_BACK)) { + setCameraLensFacing(CameraSelector.LENS_FACING_BACK); + return; + } + } + + public float getZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getZoomRatio(); + } else { + return UNITY_ZOOM_SCALE; + } + } + + public void setZoomRatio(float zoomRatio) { + if (mCamera != null) { + ListenableFuture future = mCamera.getCameraControl().setZoomRatio( + zoomRatio); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + } + + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); + } else { + Logger.e(TAG, "Failed to set zoom ratio"); + } + } + + public float getMinZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getMinZoomRatio(); + } else { + return UNITY_ZOOM_SCALE; + } + } + + public float getMaxZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getMaxZoomRatio(); + } else { + return ZOOM_NOT_SUPPORTED; + } + } + + public boolean isZoomSupported() { + return getMaxZoomRatio() != ZOOM_NOT_SUPPORTED; + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + private void rebindToLifecycle() { + if (mCurrentLifecycle != null) { + bindToLifecycle(mCurrentLifecycle); + } + } + + boolean isBoundToLifecycle() { + return mCamera != null; + } + + int getRelativeCameraOrientation(boolean compensateForMirroring) { + int rotationDegrees = 0; + if (mCamera != null) { + rotationDegrees = + mCamera.getCameraInfo().getSensorRotationDegrees(getDisplaySurfaceRotation()); + if (compensateForMirroring) { + rotationDegrees = (360 - rotationDegrees) % 360; + } + } + + return rotationDegrees; + } + + public void invalidateView() { + updateViewInfo(); + } + + void clearCurrentLifecycle() { + if (mCurrentLifecycle != null && mCameraProvider != null) { + // Remove previous use cases + List toUnbind = new ArrayList<>(); + if (mImageCapture != null && mCameraProvider.isBound(mImageCapture)) { + toUnbind.add(mImageCapture); + } + if (mVideoCapture != null && mCameraProvider.isBound(mVideoCapture)) { + toUnbind.add(mVideoCapture); + } + if (mPreview != null && mCameraProvider.isBound(mPreview)) { + toUnbind.add(mPreview); + } + + if (!toUnbind.isEmpty()) { + mCameraProvider.unbind(toUnbind.toArray((new UseCase[0]))); + } + + // Remove surface provider once unbound. + if (mPreview != null) { + mPreview.setSurfaceProvider(null); + } + } + mCamera = null; + mCurrentLifecycle = null; + } + + // Update view related information used in use cases + private void updateViewInfo() { + if (mImageCapture != null) { + mImageCapture.setCropAspectRatio(new Rational(getWidth(), getHeight())); + mImageCapture.setTargetRotation(getDisplaySurfaceRotation()); + } + + if (mVideoCapture != null) { + mVideoCapture.setTargetRotation(getDisplaySurfaceRotation()); + } + } + + @RequiresPermission(permission.CAMERA) + private Set getAvailableCameraLensFacing() { + // Start with all camera directions + Set available = new LinkedHashSet<>(Arrays.asList(LensFacingConverter.values())); + + // If we're bound to a lifecycle, remove unavailable cameras + if (mCurrentLifecycle != null) { + if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) { + available.remove(CameraSelector.LENS_FACING_BACK); + } + + if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT)) { + available.remove(CameraSelector.LENS_FACING_FRONT); + } + } + + return available; + } + + @ImageCapture.FlashMode + public int getFlash() { + return mFlash; + } + + // Begin Signal Custom Code Block + public boolean hasFlash() { + if (mImageCapture == null) { + return false; + } + + CameraInternal camera = mImageCapture.getCamera(); + + if (camera == null) { + return false; + } + + return camera.getCameraInfoInternal().hasFlashUnit(); + } + // End Signal Custom Code Block + + public void setFlash(@ImageCapture.FlashMode int flash) { + this.mFlash = flash; + + if (mImageCapture == null) { + // Do nothing if there is no imageCapture + return; + } + + mImageCapture.setFlashMode(flash); + } + + public void enableTorch(boolean torch) { + if (mCamera == null) { + return; + } + ListenableFuture future = mCamera.getCameraControl().enableTorch(torch); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + } + + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); + } + + public boolean isTorchOn() { + if (mCamera == null) { + return false; + } + return mCamera.getCameraInfo().getTorchState().getValue() == TorchState.ON; + } + + public Context getContext() { + return mCameraView.getContext(); + } + + public int getWidth() { + return mCameraView.getWidth(); + } + + public int getHeight() { + return mCameraView.getHeight(); + } + + public int getDisplayRotationDegrees() { + return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation()); + } + + protected int getDisplaySurfaceRotation() { + return mCameraView.getDisplaySurfaceRotation(); + } + + private int getMeasuredWidth() { + return mCameraView.getMeasuredWidth(); + } + + private int getMeasuredHeight() { + return mCameraView.getMeasuredHeight(); + } + + @Nullable + public Camera getCamera() { + return mCamera; + } + + @NonNull + public SignalCameraView.CaptureMode getCaptureMode() { + return mCaptureMode; + } + + public void setCaptureMode(@NonNull SignalCameraView.CaptureMode captureMode) { + this.mCaptureMode = captureMode; + rebindToLifecycle(); + } + + public long getMaxVideoDuration() { + return mMaxVideoDuration; + } + + public void setMaxVideoDuration(long duration) { + mMaxVideoDuration = duration; + } + + public long getMaxVideoSize() { + return mMaxVideoSize; + } + + public void setMaxVideoSize(long size) { + mMaxVideoSize = size; + } + + public boolean isPaused() { + return false; + } +} diff --git a/app/src/main/java/org/archiver/ArchiveConstants.kt b/app/src/main/java/org/archiver/ArchiveConstants.kt new file mode 100644 index 00000000..997d0916 --- /dev/null +++ b/app/src/main/java/org/archiver/ArchiveConstants.kt @@ -0,0 +1,44 @@ +package org.archiver + +class ArchiveConstants { + + companion object{ + const val SIGNAL_ARCHIVE_VERSION = "V1" + + + const val signalTestUserName = "signal" + const val signalTestPassword = "Aa!123456" + + const val signalCurrentPassword = ""/*"Aa123456"*/ + const val signalCurrentUser = "qasam" + + const val signalTestMobileNumber = "+972520123456" + const val isTestMode = false + // const val signalTestMobileNumber = "+447520619489" + //const val signalTestMobileNumber = "+972520099696" //EnterP + + const val integration = "https://integration.telemessage.co.il" + const val integrationKeeper = "https://api-gateway-integration.devops.telemessage.co.il" + + const val charlieProduction = "https://rest.telemessage.com" + const val prodKeeper = "https://archive.telemessage.com" + + const val ARCHIVE_TYPE_APP_MESSAGE = "App Message" + const val ARCHIVE_TYPE_SMS = "SMS" + + const val ARCHIVE_SUBJECT_CHAT_GROUP = "chat group" + + const val ARCHIVE_SUBJECT_FROM_TEXT = "from" + const val ARCHIVE_SUBJECT_TO_TEXT = "to" + + const val ARCHIVE_FILE_FOLDER_NAME = "aa_archiver" + + const val SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX = SIGNAL_ARCHIVE_VERSION + "_" + "Signal" + "_" + + } + + enum class ProtocolType(val type: String) { + ARCHIVE_PARAM_PROTOCOL_SEND("0"), + ARCHIVE_PARAM_PROTOCOL_INBOX("1") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt b/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt new file mode 100644 index 00000000..6a1885fa --- /dev/null +++ b/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt @@ -0,0 +1,13 @@ +package org.archiver + +class ArchivePreferenceConstants { + + companion object{ + + const val PREF_KEY_DEVICE_PHONE_NUMBER = "devicePhoneNumber" + + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/archiver/ArchiveSender.kt b/app/src/main/java/org/archiver/ArchiveSender.kt new file mode 100644 index 00000000..4d607c4a --- /dev/null +++ b/app/src/main/java/org/archiver/ArchiveSender.kt @@ -0,0 +1,123 @@ +package org.archiver + +import android.content.Context +import com.tm.androidcopysdk.DataGrabber +import org.archiver.ArchiveUtil.Companion.createMessageNameList +import org.archiver.ArchiveUtil.Companion.createSubjectForArchiving +import org.archiver.ArchiveUtil.Companion.createToRecipientList +import org.archiver.ArchiveUtil.Companion.fromContactName +import org.archiver.ArchiveUtil.Companion.getChatMode +import org.archiver.ArchiveUtil.Companion.getChatName +import org.archiver.ArchiveUtil.Companion.getFromPartForSubject +import org.archiver.ArchiveUtil.Companion.getGroupInboxRecipientNumber +import org.archiver.ArchiveUtil.Companion.groupId +import org.thoughtcrime.securesms.mms.IncomingMediaMessage +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.IncomingTextMessage +import org.thoughtcrime.securesms.sms.OutgoingTextMessage +import java.io.File + +class ArchiveSender { + + companion object{ + + private fun sendArchiveMessage(context: Context, aProtocolType: ArchiveConstants.ProtocolType, toRecipientsList: Array, from: String, messageBody: String? = "", messageId: String, dateInTimeStamp: Long, subject: String, chatMode: DataGrabber.CHAT_MODE, chatName: String, chatId: String?, fromNameString: String, toRecipientsListNames: Array, archiveFile: File? = null){ + + if(archiveFile == null) { + DataGrabber.getInstance(context).setMessage(aProtocolType.type, toRecipientsList, from, messageBody, messageId, dateInTimeStamp.toString(), subject, ArchiveUtil.getPhoneNumberInTestMode(context), chatMode, chatName, chatId, fromNameString, ArchiveUtil.getPhoneNumberInTestMode(context), toRecipientsListNames, toRecipientsList) + }else { + DataGrabber.getInstance(context).setMmsMessage(aProtocolType.type, toRecipientsList, from, messageBody, messageId, dateInTimeStamp.toString(), subject, ArchiveUtil.getPhoneNumberInTestMode(context), chatMode, chatName, chatId, fromNameString, ArchiveUtil.getPhoneNumberInTestMode(context), toRecipientsListNames, toRecipientsList, archiveFile) + } + } + + + fun updateArchiveSDKToSendMMSMessage(context: Context, fileName: String, needCompress: Boolean){ + DataGrabber.getInstance(context).updateFileMms(fileName, needCompress) + } + + fun archiveMessageInbox(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: IncomingTextMessage, messageId: Long) { + val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX + val isGroup = message.groupId != null + var inboxRecipient = "" + if (archiveRecipient.isGroup) { + inboxRecipient = getGroupInboxRecipientNumber(archiveRecipient, message) + } + val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient) + val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from) + val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false) + val chatMode = getChatMode(isGroup) + val chatName = getChatName(context, archiveRecipient) + val chatId = groupId(archiveRecipient) + val fromContactName = fromContactName(context, archiveRecipient, isInbox) + val toName = createMessageNameList(context, archiveRecipient, isInbox, from) + sendArchiveMessage(context, type, toRecipientsList, from, message.messageBody, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName) + } + + fun archiveMessageOutbox(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: OutgoingTextMessage, messageId: Long) { + val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX + val isGroup = archiveRecipient.isGroup + val inboxRecipient = "" + /* if(isInbox && isGroup) { + inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null); + }*/ + val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient) + val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from) + val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false) + val chatMode = getChatMode(isGroup) + val chatName = getChatName(context, archiveRecipient) + val chatId = groupId(archiveRecipient) + val fromContactName = fromContactName(context, archiveRecipient, isInbox) + val toName = createMessageNameList(context, archiveRecipient, isInbox, from) + sendArchiveMessage(context, type, toRecipientsList, from, message.messageBody, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName) + } + + + //This method also sent sms if attachments list size is 0 + fun archiveMessageOutboxMMS(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: OutgoingMediaMessage, messageId: Long, archiveFile: File? = null) { + val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX + val isGroup = archiveRecipient.isGroup + val inboxRecipient = "" + /* if(isInbox && isGroup) { + inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null); + }*/ + val toRecipientList = if(!isGroup) { + listOf(archiveRecipient) + } else{ + message.recipient.participants.filter { it.e164.isPresent } + } + + val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient) + val toRecipientsList = createToRecipientList(context, isInbox,/*isGroup, archiveRecipient*/archiveRecipient, from) + val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false) + val chatMode = getChatMode(isGroup) + val chatName = getChatName(context, archiveRecipient) + val chatId = groupId(archiveRecipient) + val fromContactName = fromContactName(context, archiveRecipient, isInbox) + val toName = createMessageNameList(context, archiveRecipient, isInbox, from) + sendArchiveMessage(context, type, toRecipientsList, from, message.body, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName, archiveFile) + } + + //This method also sent sms if attachments list size is 0 + fun archiveMessageInboxMMS(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, recipientList: MutableList, message: IncomingMediaMessage, messageId: Long, archiveFile: File? = null) { + val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX + val isGroup = archiveRecipient.isGroup + val inboxRecipient = "" + /* if(isInbox && isGroup) { + inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null); + }*/ + val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient) + val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from) + val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false) + val chatMode = getChatMode(isGroup) + val chatName = getChatName(context, archiveRecipient) + val chatId = groupId(archiveRecipient) + val fromContactName = fromContactName(context, archiveRecipient, isInbox) + val toName = createMessageNameList(context, archiveRecipient, isInbox, from) + sendArchiveMessage(context, type, toRecipientsList, from, message.body, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName, archiveFile) + } + + + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/archiver/ArchiveUtil.kt b/app/src/main/java/org/archiver/ArchiveUtil.kt new file mode 100644 index 00000000..89f466d7 --- /dev/null +++ b/app/src/main/java/org/archiver/ArchiveUtil.kt @@ -0,0 +1,193 @@ +package org.archiver + +import android.content.Context +import com.klinker.android.send_message.Utils +import com.tm.androidcopysdk.DataGrabber +import com.tm.androidcopysdk.utils.PrefManager +import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_CHAT_GROUP +import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_FROM_TEXT +import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_TO_TEXT +import org.archiver.ArchiveConstants.Companion.SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX +import org.archiver.ArchiveConstants.Companion.isTestMode +import org.archiver.ArchiveConstants.Companion.signalTestMobileNumber +import org.signal.glide.Log +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.IncomingTextMessage +import org.thoughtcrime.securesms.util.Util +import java.util.* + +class ArchiveUtil { + + companion object{ + + fun createToRecipientList(context: Context, isInboxArchiveMessage: Boolean, aRecipient: Recipient, from: String): Array { + var recipientListFromRecipient: List = if (aRecipient.isGroup) { + aRecipient.participants.filter { it.e164.isPresent }.map { it.e164.get()} + } else { + if(isInboxArchiveMessage){ + listOf(getPhoneNumberInTestMode(context)) + }else { + if(aRecipient.e164.isPresent) { + listOf(aRecipient.e164.get().toString()) + }else{ + listOf("") + } + } + } + + recipientListFromRecipient = if (!isInboxArchiveMessage) { + recipientListFromRecipient.filter { it != getPhoneNumberInTestMode(context) } + }else{ + recipientListFromRecipient.filter { it != from } + } + return recipientListFromRecipient.toTypedArray(); + } + + + + + fun createSubjectForArchiving(context: Context, isInboxArchiveMessage: Boolean, isGroup: Boolean, recipient: Recipient, inboxRecipient : String = "",forceSms : Boolean) : String{ + + val archiveType: String = getArchiveType(isInboxArchiveMessage, isGroup, forceSms) + val to = getToPartForSubject(context, isInboxArchiveMessage, recipient, isGroup) + val from = getFromPartForSubject(context, isInboxArchiveMessage, recipient, inboxRecipient) + return "$archiveType $ARCHIVE_SUBJECT_FROM_TEXT $from $ARCHIVE_SUBJECT_TO_TEXT $to" + + } + + private fun getToPartForSubject(context: Context, isInboxArchiveMessage: Boolean, recipient: Recipient, isGroup: Boolean): String { + return when { + isGroup -> { + "$ARCHIVE_SUBJECT_CHAT_GROUP ${recipient.getName(context)}" + } + isInboxArchiveMessage -> { + getPhoneNumberInTestMode(context) + } + else -> { + if(recipient.e164.isPresent) { + recipient.e164.get() + }else{ + "" + } + } + } + } + + + fun getFromPartForSubject(context: Context, isInboxArchiveMessage: Boolean, recipient: Recipient, inboxRecipient : String = ""): String { + return when { + isInboxArchiveMessage -> { + if(recipient.isGroup){ + inboxRecipient + }else { + if(recipient.e164.isPresent) { + recipient.e164.get() + }else{ + "" + } + } + }else -> { + getPhoneNumberInTestMode(context) + } + } + } + + fun getArchiveType(isInboxArchiveMessage: Boolean, isGroupMessage: Boolean, forceSms: Boolean): String { + + return if(isInboxArchiveMessage || isGroupMessage){ + ArchiveConstants.ARCHIVE_TYPE_APP_MESSAGE + }else{ + if(forceSms){ + ArchiveConstants.ARCHIVE_TYPE_SMS + }else{ + ArchiveConstants.ARCHIVE_TYPE_APP_MESSAGE + } + } + } + + fun getPhoneNumberInTestMode(context: Context) : String{ + return if(isTestMode){ + signalTestMobileNumber + }else{ + PrefManager.getStringPref(context, ArchivePreferenceConstants.PREF_KEY_DEVICE_PHONE_NUMBER, ""); + } + } + + fun getChatMode(isGroup: Boolean) : DataGrabber.CHAT_MODE { + return when { + isGroup -> { + DataGrabber.CHAT_MODE.group + }else -> { + DataGrabber.CHAT_MODE.chat + } + } + } + + fun getChatName(context: Context, recipient: Recipient): String { + return if(recipient.isGroup){ + recipient.getName(context).toString() + }else{ + "" + } + } + + fun getGroupInboxRecipientNumber(archiveRecipient: Recipient, message: IncomingTextMessage): String { + val recipientList = archiveRecipient.participants.filter { + message.sender.toLong() == it.id.toLong() + } + return recipientList[0].e164.get() + } + + fun groupId(recipient: Recipient): String? { + return if(recipient.isGroup){ + // recipient.groupId.get().toString() + UUID.randomUUID().toString() + }else{ + null + } + } + + fun fromContactName(context: Context,recipient: Recipient, isInboxArchiveMessage: Boolean ): String { + return if(isInboxArchiveMessage){ + recipient.getDisplayName(context) + }else{ + Recipient.self().profileName.toString() + } + + } + + fun createMessageNameList(context: Context, recipient: Recipient, isInboxArchiveMessage: Boolean, from: String): Array { + + val rl = if (!isInboxArchiveMessage) { + recipient.participants.filter { + it.e164.isPresent && it.e164.get() != getPhoneNumberInTestMode(context) + } + }else{ + recipient.participants.filter { + it.e164.isPresent && it.e164.get() != from + } + } + + val recipientListFromRecipient: List = if (recipient.isGroup) { + + rl.map { + it.getDisplayName(context) + } + + } else { + if(isInboxArchiveMessage){ + listOf(Recipient.self().profileName.toString()) + }else { + listOf(recipient.getDisplayName(context)) + } + } + + return recipientListFromRecipient.toTypedArray() + } + + fun generateAttachmentName(attachmentId: Long, messageId: Long) : String{ + return SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX + attachmentId + "_" + messageId + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/archiver/FileUtilTestMoti.java b/app/src/main/java/org/archiver/FileUtilTestMoti.java new file mode 100644 index 00000000..240877d4 --- /dev/null +++ b/app/src/main/java/org/archiver/FileUtilTestMoti.java @@ -0,0 +1,286 @@ +package org.archiver; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; + +public class FileUtilTestMoti { + + + public static String getPath(Context context, Uri uri){ + String[] projection = {MediaStore.MediaColumns.DATA}; + String path = ""; + ContentResolver cr = context.getApplicationContext().getContentResolver(); + Cursor metaCursor = cr.query(uri, projection, null, null, null); + if (metaCursor != null) { + try { + if (metaCursor.moveToFirst()) { + path = metaCursor.getString(0); + } + } finally { + metaCursor.close(); + } + } + return path; + } + + /* +This method can parse out the real local file path from a file URI. +*/ + public static String getUriRealPath(Context ctx, Uri uri) + { + String ret = ""; + + if( isAboveKitKat() ) + { + // Android sdk version number bigger than 19. + ret = getUriRealPathAboveKitkat(ctx, uri); + }else + { + // Android sdk version number smaller than 19. + ret = getImageRealPath(ctx.getContentResolver(), uri, null); + } + + return ret; + } + + /* + This method will parse out the real local file path from the file content URI. + The method is only applied to android sdk version number that is bigger than 19. + */ + public static String getUriRealPathAboveKitkat(Context ctx, Uri uri) + { + String ret = ""; + + if(ctx != null && uri != null) { + + if(isContentUri(uri)) + { + if(isGooglePhotoDoc(uri.getAuthority())) + { + ret = uri.getLastPathSegment(); + }else { + ret = getImageRealPath(ctx.getContentResolver(), uri, null); + } + }else if(isFileUri(uri)) { + ret = uri.getPath(); + }else if(isDocumentUri(ctx, uri)){ + + // Get uri related document id. + String documentId = DocumentsContract.getDocumentId(uri); + + // Get uri authority. + String uriAuthority = uri.getAuthority(); + + if(isMediaDoc(uriAuthority)) + { + String idArr[] = documentId.split(":"); + if(idArr.length == 2) + { + // First item is document type. + String docType = idArr[0]; + + // Second item is document real id. + String realDocId = idArr[1]; + + // Get content uri by document type. + Uri mediaContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + if("image".equals(docType)) + { + mediaContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + }else if("video".equals(docType)) + { + mediaContentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + }else if("audio".equals(docType)) + { + mediaContentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + // Get where clause with real document id. + String whereClause = MediaStore.Images.Media._ID + " = " + realDocId; + + ret = getImageRealPath(ctx.getContentResolver(), mediaContentUri, whereClause); + } + + }else if(isDownloadDoc(uriAuthority)) + { + // Build download uri. + Uri downloadUri = Uri.parse("content://downloads/public_downloads"); + + // Append download document id at uri end. + Uri downloadUriAppendId = ContentUris.withAppendedId(downloadUri, Long.valueOf(documentId)); + + ret = getImageRealPath(ctx.getContentResolver(), downloadUriAppendId, null); + + }else if(isExternalStoreDoc(uriAuthority)) + { + String idArr[] = documentId.split(":"); + if(idArr.length == 2) + { + String type = idArr[0]; + String realDocId = idArr[1]; + + if("primary".equalsIgnoreCase(type)) + { + ret = Environment.getExternalStorageDirectory() + "/" + realDocId; + } + } + } + } + } + + return ret; + } + + /* Check whether current android os version is bigger than kitkat or not. */ + public static boolean isAboveKitKat() + { + boolean ret = false; + ret = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + return ret; + } + + /* Check whether this uri represent a document or not. */ + public static boolean isDocumentUri(Context ctx, Uri uri) + { + boolean ret = false; + if(ctx != null && uri != null) { + ret = DocumentsContract.isDocumentUri(ctx, uri); + } + return ret; + } + + /* Check whether this uri is a content uri or not. + * content uri like content://media/external/images/media/1302716 + * */ + public static boolean isContentUri(Uri uri) + { + boolean ret = false; + if(uri != null) { + String uriSchema = uri.getScheme(); + if("content".equalsIgnoreCase(uriSchema)) + { + ret = true; + } + } + return ret; + } + + /* Check whether this uri is a file uri or not. + * file uri like file:///storage/41B7-12F1/DCIM/Camera/IMG_20180211_095139.jpg + * */ + public static boolean isFileUri(Uri uri) + { + boolean ret = false; + if(uri != null) { + String uriSchema = uri.getScheme(); + if("file".equalsIgnoreCase(uriSchema)) + { + ret = true; + } + } + return ret; + } + + + /* Check whether this document is provided by ExternalStorageProvider. Return true means the file is saved in external storage. */ + public static boolean isExternalStoreDoc(String uriAuthority) + { + boolean ret = false; + + if("com.android.externalstorage.documents".equals(uriAuthority)) + { + ret = true; + } + + return ret; + } + + /* Check whether this document is provided by DownloadsProvider. return true means this file is a downloaed file. */ + public static boolean isDownloadDoc(String uriAuthority) + { + boolean ret = false; + + if("com.android.providers.downloads.documents".equals(uriAuthority)) + { + ret = true; + } + + return ret; + } + + /* + Check if MediaProvider provide this document, if true means this image is created in android media app. + */ + public static boolean isMediaDoc(String uriAuthority) + { + boolean ret = false; + + if("com.android.providers.media.documents".equals(uriAuthority)) + { + ret = true; + } + + return ret; + } + + /* + Check whether google photos provide this document, if true means this image is created in google photos app. + */ + public static boolean isGooglePhotoDoc(String uriAuthority) + { + boolean ret = false; + + if("com.google.android.apps.photos.content".equals(uriAuthority)) + { + ret = true; + } + + return ret; + } + + /* Return uri represented document file real local path.*/ + public static String getImageRealPath(ContentResolver contentResolver, Uri uri, String whereClause) + { + String ret = ""; + + // Query the uri with condition. + Cursor cursor = contentResolver.query(uri, null, whereClause, null, null); + + if(cursor!=null) + { + boolean moveToFirst = cursor.moveToFirst(); + if(moveToFirst) + { + + // Get columns name by uri type. + String columnName = MediaStore.Images.Media.DATA; + + if( uri==MediaStore.Images.Media.EXTERNAL_CONTENT_URI ) + { + columnName = MediaStore.Images.Media.DATA; + }else if( uri==MediaStore.Audio.Media.EXTERNAL_CONTENT_URI ) + { + columnName = MediaStore.Audio.Media.DATA; + }else if( uri==MediaStore.Video.Media.EXTERNAL_CONTENT_URI ) + { + columnName = MediaStore.Video.Media.DATA; + } + + // Get column index. + int imageColumnIndex = cursor.getColumnIndex(columnName); + + // Get column value which is the uri related file local path. + ret = cursor.getString(imageColumnIndex); + } + } + + return ret; + } +} diff --git a/app/src/main/java/org/signal/glide/Log.java b/app/src/main/java/org/signal/glide/Log.java new file mode 100644 index 00000000..b2c6037b --- /dev/null +++ b/app/src/main/java/org/signal/glide/Log.java @@ -0,0 +1,58 @@ +package org.signal.glide; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class Log { + + private Log() {} + + public static void v(@NonNull String tag, @NonNull String message) { + SignalGlideCodecs.getLogProvider().v(tag, message); + } + + public static void d(@NonNull String tag, @NonNull String message) { + SignalGlideCodecs.getLogProvider().d(tag, message); + } + + public static void i(@NonNull String tag, @NonNull String message) { + SignalGlideCodecs.getLogProvider().i(tag, message); + } + + public static void w(@NonNull String tag, @NonNull String message) { + SignalGlideCodecs.getLogProvider().w(tag, message); + } + + public static void e(@NonNull String tag, @NonNull String message) { + SignalGlideCodecs.getLogProvider().e(tag, message, null); + } + + public static void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable) { + SignalGlideCodecs.getLogProvider().e(tag, message, throwable); + } + + public interface Provider { + void v(@NonNull String tag, @NonNull String message); + void d(@NonNull String tag, @NonNull String message); + void i(@NonNull String tag, @NonNull String message); + void w(@NonNull String tag, @NonNull String message); + void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable); + + Provider EMPTY = new Provider() { + @Override + public void v(@NonNull String tag, @NonNull String message) { } + + @Override + public void d(@NonNull String tag, @NonNull String message) { } + + @Override + public void i(@NonNull String tag, @NonNull String message) { } + + @Override + public void w(@NonNull String tag, @NonNull String message) { } + + @Override + public void e(@NonNull String tag, @NonNull String message, @NonNull Throwable throwable) { } + }; + } +} diff --git a/app/src/main/java/org/signal/glide/SignalGlideCodecs.java b/app/src/main/java/org/signal/glide/SignalGlideCodecs.java new file mode 100644 index 00000000..014148a8 --- /dev/null +++ b/app/src/main/java/org/signal/glide/SignalGlideCodecs.java @@ -0,0 +1,18 @@ +package org.signal.glide; + +import androidx.annotation.NonNull; + +public final class SignalGlideCodecs { + + private static Log.Provider logProvider = Log.Provider.EMPTY; + + private SignalGlideCodecs() {} + + public static void setLogProvider(@NonNull Log.Provider provider) { + logProvider = provider; + } + + public static @NonNull Log.Provider getLogProvider() { + return logProvider; + } +} diff --git a/app/src/main/java/org/signal/glide/apng/APNGDrawable.java b/app/src/main/java/org/signal/glide/apng/APNGDrawable.java new file mode 100644 index 00000000..021598b3 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/APNGDrawable.java @@ -0,0 +1,52 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng; + +import android.content.Context; + +import org.signal.glide.apng.decode.APNGDecoder; +import org.signal.glide.common.FrameAnimationDrawable; +import org.signal.glide.common.decode.FrameSeqDecoder; +import org.signal.glide.common.loader.AssetStreamLoader; +import org.signal.glide.common.loader.FileLoader; +import org.signal.glide.common.loader.Loader; +import org.signal.glide.common.loader.ResourceStreamLoader; + +/** + * @Description: APNGDrawable + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +public class APNGDrawable extends FrameAnimationDrawable { + public APNGDrawable(Loader provider) { + super(provider); + } + + public APNGDrawable(APNGDecoder decoder) { + super(decoder); + } + + @Override + protected APNGDecoder createFrameSeqDecoder(Loader streamLoader, FrameSeqDecoder.RenderListener listener) { + return new APNGDecoder(streamLoader, listener); + } + + + public static APNGDrawable fromAsset(Context context, String assetPath) { + AssetStreamLoader assetStreamLoader = new AssetStreamLoader(context, assetPath); + return new APNGDrawable(assetStreamLoader); + } + + public static APNGDrawable fromFile(String filePath) { + FileLoader fileLoader = new FileLoader(filePath); + return new APNGDrawable(fileLoader); + } + + public static APNGDrawable fromResource(Context context, int resId) { + ResourceStreamLoader resourceStreamLoader = new ResourceStreamLoader(context, resId); + return new APNGDrawable(resourceStreamLoader); + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java b/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java new file mode 100644 index 00000000..37f60d90 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java @@ -0,0 +1,27 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import org.signal.glide.apng.io.APNGReader; + +import java.io.IOException; + +/** + * @Description: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27acTL.27:_The_Animation_Control_Chunk + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class ACTLChunk extends Chunk { + static final int ID = fourCCToInt("acTL"); + int num_frames; + int num_plays; + + @Override + void innerParse(APNGReader apngReader) throws IOException { + num_frames = apngReader.readInt(); + num_plays = apngReader.readInt(); + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java b/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java new file mode 100644 index 00000000..a8e50ab7 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java @@ -0,0 +1,211 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Rect; + +import org.signal.core.util.logging.Log; +import org.signal.glide.apng.io.APNGReader; +import org.signal.glide.apng.io.APNGWriter; +import org.signal.glide.common.decode.Frame; +import org.signal.glide.common.decode.FrameSeqDecoder; +import org.signal.glide.common.io.Reader; +import org.signal.glide.common.loader.Loader; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * @Description: APNG4Android + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class APNGDecoder extends FrameSeqDecoder { + + private static final String TAG = APNGDecoder.class.getSimpleName(); + + private APNGWriter apngWriter; + private int mLoopCount; + private final Paint paint = new Paint(); + + + private class SnapShot { + byte dispose_op; + Rect dstRect = new Rect(); + ByteBuffer byteBuffer; + } + + private SnapShot snapShot = new SnapShot(); + + /** + * @param loader webp的reader + * @param renderListener 渲染的回调 + */ + public APNGDecoder(Loader loader, FrameSeqDecoder.RenderListener renderListener) { + super(loader, renderListener); + paint.setAntiAlias(true); + } + + @Override + protected APNGWriter getWriter() { + if (apngWriter == null) { + apngWriter = new APNGWriter(); + } + return apngWriter; + } + + @Override + protected APNGReader getReader(Reader reader) { + return new APNGReader(reader); + } + + @Override + protected int getLoopCount() { + return mLoopCount; + } + + @Override + protected void release() { + snapShot.byteBuffer = null; + apngWriter = null; + } + + + @Override + protected Rect read(APNGReader reader) throws IOException { + List chunks = APNGParser.parse(reader); + List otherChunks = new ArrayList<>(); + + boolean actl = false; + APNGFrame lastFrame = null; + byte[] ihdrData = new byte[0]; + int canvasWidth = 0, canvasHeight = 0; + for (Chunk chunk : chunks) { + if (chunk instanceof ACTLChunk) { + mLoopCount = ((ACTLChunk) chunk).num_plays; + actl = true; + } else if (chunk instanceof FCTLChunk) { + APNGFrame frame = new APNGFrame(reader, (FCTLChunk) chunk); + frame.prefixChunks = otherChunks; + frame.ihdrData = ihdrData; + frames.add(frame); + lastFrame = frame; + } else if (chunk instanceof FDATChunk) { + if (lastFrame != null) { + lastFrame.imageChunks.add(chunk); + } + } else if (chunk instanceof IDATChunk) { + if (!actl) { + //如果为非APNG图片,则只解码PNG + Frame frame = new StillFrame(reader); + frame.frameWidth = canvasWidth; + frame.frameHeight = canvasHeight; + frames.add(frame); + mLoopCount = 1; + break; + } + if (lastFrame != null) { + lastFrame.imageChunks.add(chunk); + } + + } else if (chunk instanceof IHDRChunk) { + canvasWidth = ((IHDRChunk) chunk).width; + canvasHeight = ((IHDRChunk) chunk).height; + ihdrData = ((IHDRChunk) chunk).data; + } else if (!(chunk instanceof IENDChunk)) { + otherChunks.add(chunk); + } + } + frameBuffer = ByteBuffer.allocate((canvasWidth * canvasHeight / (sampleSize * sampleSize) + 1) * 4); + snapShot.byteBuffer = ByteBuffer.allocate((canvasWidth * canvasHeight / (sampleSize * sampleSize) + 1) * 4); + return new Rect(0, 0, canvasWidth, canvasHeight); + } + + @Override + protected void renderFrame(Frame frame) { + if (frame == null || fullRect == null) { + return; + } + try { + Bitmap bitmap = obtainBitmap(fullRect.width() / sampleSize, fullRect.height() / sampleSize); + Canvas canvas = cachedCanvas.get(bitmap); + if (canvas == null) { + canvas = new Canvas(bitmap); + cachedCanvas.put(bitmap, canvas); + } + if (frame instanceof APNGFrame) { + // 从缓存中恢复当前帧 + frameBuffer.rewind(); + bitmap.copyPixelsFromBuffer(frameBuffer); + // 开始绘制前,处理快照中的设定 + if (this.frameIndex == 0) { + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + } else { + canvas.save(); + canvas.clipRect(snapShot.dstRect); + switch (snapShot.dispose_op) { + // 从快照中恢复上一帧之前的显示内容 + case FCTLChunk.APNG_DISPOSE_OP_PREVIOUS: + snapShot.byteBuffer.rewind(); + bitmap.copyPixelsFromBuffer(snapShot.byteBuffer); + break; + // 清空上一帧所画区域 + case FCTLChunk.APNG_DISPOSE_OP_BACKGROUND: + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + break; + // 什么都不做 + case FCTLChunk.APNG_DISPOSE_OP_NON: + default: + break; + } + canvas.restore(); + } + + // 然后根据dispose设定传递到快照信息中 + if (((APNGFrame) frame).dispose_op == FCTLChunk.APNG_DISPOSE_OP_PREVIOUS) { + if (snapShot.dispose_op != FCTLChunk.APNG_DISPOSE_OP_PREVIOUS) { + snapShot.byteBuffer.rewind(); + bitmap.copyPixelsToBuffer(snapShot.byteBuffer); + } + } + + snapShot.dispose_op = ((APNGFrame) frame).dispose_op; + canvas.save(); + if (((APNGFrame) frame).blend_op == FCTLChunk.APNG_BLEND_OP_SOURCE) { + canvas.clipRect( + frame.frameX / sampleSize, + frame.frameY / sampleSize, + (frame.frameX + frame.frameWidth) / sampleSize, + (frame.frameY + frame.frameHeight) / sampleSize); + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + } + + + snapShot.dstRect.set(frame.frameX / sampleSize, + frame.frameY / sampleSize, + (frame.frameX + frame.frameWidth) / sampleSize, + (frame.frameY + frame.frameHeight) / sampleSize); + canvas.restore(); + } + //开始真正绘制当前帧的内容 + Bitmap inBitmap = obtainBitmap(frame.frameWidth, frame.frameHeight); + recycleBitmap(frame.draw(canvas, paint, sampleSize, inBitmap, getWriter())); + recycleBitmap(inBitmap); + frameBuffer.rewind(); + bitmap.copyPixelsToBuffer(frameBuffer); + recycleBitmap(bitmap); + } catch (Throwable t) { + Log.e(TAG, "Failed to render!", t); + } + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java b/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java new file mode 100644 index 00000000..fd1ca270 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java @@ -0,0 +1,147 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; + +import org.signal.glide.apng.io.APNGReader; +import org.signal.glide.apng.io.APNGWriter; +import org.signal.glide.common.decode.Frame; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.CRC32; + +/** + * @Description: APNG4Android + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class APNGFrame extends Frame { + public final byte blend_op; + public final byte dispose_op; + byte[] ihdrData; + List imageChunks = new ArrayList<>(); + List prefixChunks = new ArrayList<>(); + private static final byte[] sPNGSignatures = {(byte) 137, 80, 78, 71, 13, 10, 26, 10}; + private static final byte[] sPNGEndChunk = {0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, (byte) 0xAE, 0x42, 0x60, (byte) 0x82}; + + private static ThreadLocal sCRC32 = new ThreadLocal<>(); + + private CRC32 getCRC32() { + CRC32 crc32 = sCRC32.get(); + if (crc32 == null) { + crc32 = new CRC32(); + sCRC32.set(crc32); + } + return crc32; + } + + public APNGFrame(APNGReader reader, FCTLChunk fctlChunk) { + super(reader); + blend_op = fctlChunk.blend_op; + dispose_op = fctlChunk.dispose_op; + frameDuration = fctlChunk.delay_num * 1000 / (fctlChunk.delay_den == 0 ? 100 : fctlChunk.delay_den); + frameWidth = fctlChunk.width; + frameHeight = fctlChunk.height; + frameX = fctlChunk.x_offset; + frameY = fctlChunk.y_offset; + } + + private int encode(APNGWriter apngWriter) throws IOException { + int fileSize = 8 + 13 + 12; + + //prefixChunks + for (Chunk chunk : prefixChunks) { + fileSize += chunk.length + 12; + } + + //imageChunks + for (Chunk chunk : imageChunks) { + if (chunk instanceof IDATChunk) { + fileSize += chunk.length + 12; + } else if (chunk instanceof FDATChunk) { + fileSize += chunk.length + 8; + } + } + fileSize += sPNGEndChunk.length; + apngWriter.reset(fileSize); + apngWriter.putBytes(sPNGSignatures); + //IHDR Chunk + apngWriter.writeInt(13); + int start = apngWriter.position(); + apngWriter.writeFourCC(IHDRChunk.ID); + apngWriter.writeInt(frameWidth); + apngWriter.writeInt(frameHeight); + apngWriter.putBytes(ihdrData); + CRC32 crc32 = getCRC32(); + crc32.reset(); + crc32.update(apngWriter.toByteArray(), start, 17); + apngWriter.writeInt((int) crc32.getValue()); + + //prefixChunks + for (Chunk chunk : prefixChunks) { + if (chunk instanceof IENDChunk) { + continue; + } + reader.reset(); + reader.skip(chunk.offset); + reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length + 12); + apngWriter.skip(chunk.length + 12); + } + //imageChunks + for (Chunk chunk : imageChunks) { + if (chunk instanceof IDATChunk) { + reader.reset(); + reader.skip(chunk.offset); + reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length + 12); + apngWriter.skip(chunk.length + 12); + } else if (chunk instanceof FDATChunk) { + apngWriter.writeInt(chunk.length - 4); + start = apngWriter.position(); + apngWriter.writeFourCC(IDATChunk.ID); + + reader.reset(); + // skip to fdat data position + reader.skip(chunk.offset + 4 + 4 + 4); + reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length - 4); + + apngWriter.skip(chunk.length - 4); + crc32.reset(); + crc32.update(apngWriter.toByteArray(), start, chunk.length); + apngWriter.writeInt((int) crc32.getValue()); + } + } + //endChunk + apngWriter.putBytes(sPNGEndChunk); + return fileSize; + } + + + @Override + public Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, APNGWriter writer) { + try { + int length = encode(writer); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = false; + options.inSampleSize = sampleSize; + options.inMutable = true; + options.inBitmap = reusedBitmap; + byte[] bytes = writer.toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, length, options); + assert bitmap != null; + canvas.drawBitmap(bitmap, (float) frameX / sampleSize, (float) frameY / sampleSize, paint); + return bitmap; + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java b/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java new file mode 100644 index 00000000..04d52757 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java @@ -0,0 +1,143 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import android.content.Context; + +import org.signal.glide.apng.io.APNGReader; +import org.signal.glide.common.io.Reader; +import org.signal.glide.common.io.StreamReader; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * @link {https://www.w3.org/TR/PNG/#5PNG-file-signature} + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class APNGParser { + static class FormatException extends IOException { + FormatException() { + super("APNG Format error"); + } + } + + public static boolean isAPNG(String filePath) { + InputStream inputStream = null; + try { + inputStream = new FileInputStream(filePath); + return isAPNG(new StreamReader(inputStream)); + } catch (Exception e) { + return false; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + public static boolean isAPNG(Context context, String assetPath) { + InputStream inputStream = null; + try { + inputStream = context.getAssets().open(assetPath); + return isAPNG(new StreamReader(inputStream)); + } catch (Exception e) { + return false; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + public static boolean isAPNG(Context context, int resId) { + InputStream inputStream = null; + try { + inputStream = context.getResources().openRawResource(resId); + return isAPNG(new StreamReader(inputStream)); + } catch (Exception e) { + return false; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + public static boolean isAPNG(Reader in) { + APNGReader reader = (in instanceof APNGReader) ? (APNGReader) in : new APNGReader(in); + try { + if (!reader.matchFourCC("\u0089PNG") || !reader.matchFourCC("\r\n\u001a\n")) { + throw new FormatException(); + } + while (reader.available() > 0) { + Chunk chunk = parseChunk(reader); + if (chunk instanceof ACTLChunk) { + return true; + } + } + } catch (IOException e) { + return false; + } + return false; + } + + public static List parse(APNGReader reader) throws IOException { + if (!reader.matchFourCC("\u0089PNG") || !reader.matchFourCC("\r\n\u001a\n")) { + throw new FormatException(); + } + + List chunks = new ArrayList<>(); + while (reader.available() > 0) { + chunks.add(parseChunk(reader)); + } + return chunks; + } + + private static Chunk parseChunk(APNGReader reader) throws IOException { + int offset = reader.position(); + int size = reader.readInt(); + int fourCC = reader.readFourCC(); + Chunk chunk; + if (fourCC == ACTLChunk.ID) { + chunk = new ACTLChunk(); + } else if (fourCC == FCTLChunk.ID) { + chunk = new FCTLChunk(); + } else if (fourCC == FDATChunk.ID) { + chunk = new FDATChunk(); + } else if (fourCC == IDATChunk.ID) { + chunk = new IDATChunk(); + } else if (fourCC == IENDChunk.ID) { + chunk = new IENDChunk(); + } else if (fourCC == IHDRChunk.ID) { + chunk = new IHDRChunk(); + } else { + chunk = new Chunk(); + } + chunk.offset = offset; + chunk.fourcc = fourCC; + chunk.length = size; + chunk.parse(reader); + chunk.crc = reader.readInt(); + return chunk; + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/Chunk.java b/app/src/main/java/org/signal/glide/apng/decode/Chunk.java new file mode 100644 index 00000000..192cc0fd --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/Chunk.java @@ -0,0 +1,53 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import android.text.TextUtils; + +import org.signal.glide.apng.io.APNGReader; + +import java.io.IOException; + +/** + * @Description: Length (长度) 4字节 指定数据块中数据域的长度,其长度不超过(231-1)字节 + * Chunk Type Code (数据块类型码) 4字节 数据块类型码由ASCII字母(A-Z和a-z)组成 + * Chunk Data (数据块数据) 可变长度 存储按照Chunk Type Code指定的数据 + * CRC (循环冗余检测) 4字节 存储用来检测是否有错误的循环冗余码 + * @Link https://www.w3.org/TR/PNG + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class Chunk { + int length; + int fourcc; + int crc; + int offset; + + static int fourCCToInt(String fourCC) { + if (TextUtils.isEmpty(fourCC) || fourCC.length() != 4) { + return 0xbadeffff; + } + return (fourCC.charAt(0) & 0xff) + | (fourCC.charAt(1) & 0xff) << 8 + | (fourCC.charAt(2) & 0xff) << 16 + | (fourCC.charAt(3) & 0xff) << 24 + ; + } + + void parse(APNGReader reader) throws IOException { + int available = reader.available(); + innerParse(reader); + int offset = available - reader.available(); + if (offset > length) { + throw new IOException("Out of chunk area"); + } else if (offset < length) { + reader.skip(length - offset); + } + } + + void innerParse(APNGReader reader) throws IOException { + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java b/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java new file mode 100644 index 00000000..9e74d806 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java @@ -0,0 +1,121 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import org.signal.glide.apng.io.APNGReader; + +import java.io.IOException; + +/** + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + * @see {link=https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27fcTL.27:_The_Frame_Control_Chunk} + */ +class FCTLChunk extends Chunk { + static final int ID = fourCCToInt("fcTL"); + int sequence_number; + /** + * x_offset >= 0 + * y_offset >= 0 + * width > 0 + * height > 0 + * x_offset + width <= 'IHDR' width + * y_offset + height <= 'IHDR' height + */ + /** + * Width of the following frame. + */ + int width; + /** + * Height of the following frame. + */ + int height; + /** + * X position at which to render the following frame. + */ + int x_offset; + /** + * Y position at which to render the following frame. + */ + int y_offset; + + /** + * The delay_num and delay_den parameters together specify a fraction indicating the time to + * display the current frame, in seconds. If the denominator is 0, it is to be treated as if it + * were 100 (that is, delay_num then specifies 1/100ths of a second). + * If the the value of the numerator is 0 the decoder should render the next frame as quickly as + * possible, though viewers may impose a reasonable lower bound. + *

+ * Frame timings should be independent of the time required for decoding and display of each frame, + * so that animations will run at the same rate regardless of the performance of the decoder implementation. + */ + + /** + * Frame delay fraction numerator. + */ + short delay_num; + + /** + * Frame delay fraction denominator. + */ + short delay_den; + + /** + * Type of frame area disposal to be done after rendering this frame. + * dispose_op specifies how the output buffer should be changed at the end of the delay (before rendering the next frame). + * If the first 'fcTL' chunk uses a dispose_op of APNG_DISPOSE_OP_PREVIOUS it should be treated as APNG_DISPOSE_OP_BACKGROUND. + */ + byte dispose_op; + + /** + * Type of frame area rendering for this frame. + */ + byte blend_op; + + /** + * No disposal is done on this frame before rendering the next; the contents of the output buffer are left as is. + */ + static final int APNG_DISPOSE_OP_NON = 0; + + /** + * The frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. + */ + static final int APNG_DISPOSE_OP_BACKGROUND = 1; + + /** + * The frame's region of the output buffer is to be reverted to the previous contents before rendering the next frame. + */ + static final int APNG_DISPOSE_OP_PREVIOUS = 2; + + /** + * blend_op specifies whether the frame is to be alpha blended into the current output buffer content, + * or whether it should completely replace its region in the output buffer. + */ + /** + * All color components of the frame, including alpha, overwrite the current contents of the frame's output buffer region. + */ + static final int APNG_BLEND_OP_SOURCE = 0; + + /** + * The frame should be composited onto the output buffer based on its alpha, + * using a simple OVER operation as described in the Alpha Channel Processing section of the Extensions + * to the PNG Specification, Version 1.2.0. Note that the second variation of the sample code is applicable. + */ + static final int APNG_BLEND_OP_OVER = 1; + + @Override + void innerParse(APNGReader reader) throws IOException { + sequence_number = reader.readInt(); + width = reader.readInt(); + height = reader.readInt(); + x_offset = reader.readInt(); + y_offset = reader.readInt(); + delay_num = reader.readShort(); + delay_den = reader.readShort(); + dispose_op = reader.peek(); + blend_op = reader.peek(); + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java b/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java new file mode 100644 index 00000000..1618c59d --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import org.signal.glide.apng.io.APNGReader; + +import java.io.IOException; + +/** + * @Description: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27fdAT.27:_The_Frame_Data_Chunk + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class FDATChunk extends Chunk { + static final int ID = fourCCToInt("fdAT"); + int sequence_number; + + @Override + void innerParse(APNGReader reader) throws IOException { + sequence_number = reader.readInt(); + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java new file mode 100644 index 00000000..bd7a60fe --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java @@ -0,0 +1,15 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +/** + * @Description: 作用描述 + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class IDATChunk extends Chunk { + static final int ID = fourCCToInt("IDAT"); +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java new file mode 100644 index 00000000..f0cbd800 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java @@ -0,0 +1,15 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +/** + * @Description: 作用描述 + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class IENDChunk extends Chunk { + static final int ID = Chunk.fourCCToInt("IEND"); +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java new file mode 100644 index 00000000..eebd9d27 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java @@ -0,0 +1,45 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import org.signal.glide.apng.io.APNGReader; + +import java.io.IOException; + +/** + * The IHDR chunk shall be the first chunk in the PNG datastream. It contains: + *

+ * Width 4 bytes + * Height 4 bytes + * Bit depth 1 byte + * Colour type 1 byte + * Compression method 1 byte + * Filter method 1 byte + * Interlace method 1 byte + * + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +class IHDRChunk extends Chunk { + static final int ID = fourCCToInt("IHDR"); + /** + * 图像宽度,以像素为单位 + */ + int width; + /** + * 图像高度,以像素为单位 + */ + int height; + + byte[] data = new byte[5]; + + @Override + void innerParse(APNGReader reader) throws IOException { + width = reader.readInt(); + height = reader.readInt(); + reader.read(data, 0, data.length); + } +} diff --git a/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java b/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java new file mode 100644 index 00000000..65715f1e --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java @@ -0,0 +1,49 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.decode; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; + +import org.signal.glide.apng.io.APNGReader; +import org.signal.glide.apng.io.APNGWriter; +import org.signal.glide.common.decode.Frame; + +import java.io.IOException; + +/** + * @Description: APNG4Android + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class StillFrame extends Frame { + + public StillFrame(APNGReader reader) { + super(reader); + } + + @Override + public Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, APNGWriter writer) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = false; + options.inSampleSize = sampleSize; + options.inMutable = true; + options.inBitmap = reusedBitmap; + Bitmap bitmap = null; + try { + reader.reset(); + bitmap = BitmapFactory.decodeStream(reader.toInputStream(), null, options); + assert bitmap != null; + paint.setXfermode(null); + canvas.drawBitmap(bitmap, 0, 0, paint); + } catch (IOException e) { + e.printStackTrace(); + } + return bitmap; + } +} diff --git a/app/src/main/java/org/signal/glide/apng/io/APNGReader.java b/app/src/main/java/org/signal/glide/apng/io/APNGReader.java new file mode 100644 index 00000000..293ed246 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/io/APNGReader.java @@ -0,0 +1,74 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.io; + +import android.text.TextUtils; + +import org.signal.glide.common.io.FilterReader; +import org.signal.glide.common.io.Reader; + +import java.io.IOException; + +/** + * @Description: APNGReader + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class APNGReader extends FilterReader { + private static ThreadLocal __intBytes = new ThreadLocal<>(); + + + protected static byte[] ensureBytes() { + byte[] bytes = __intBytes.get(); + if (bytes == null) { + bytes = new byte[4]; + __intBytes.set(bytes); + } + return bytes; + } + + public APNGReader(Reader in) { + super(in); + } + + public int readInt() throws IOException { + byte[] buf = ensureBytes(); + read(buf, 0, 4); + return buf[3] & 0xFF | + (buf[2] & 0xFF) << 8 | + (buf[1] & 0xFF) << 16 | + (buf[0] & 0xFF) << 24; + } + + public short readShort() throws IOException { + byte[] buf = ensureBytes(); + read(buf, 0, 2); + return (short) (buf[1] & 0xFF | + (buf[0] & 0xFF) << 8); + } + + /** + * @return read FourCC and match chars + */ + public boolean matchFourCC(String chars) throws IOException { + if (TextUtils.isEmpty(chars) || chars.length() != 4) { + return false; + } + int fourCC = readFourCC(); + for (int i = 0; i < 4; i++) { + if (((fourCC >> (i * 8)) & 0xff) != chars.charAt(i)) { + return false; + } + } + return true; + } + + public int readFourCC() throws IOException { + byte[] buf = ensureBytes(); + read(buf, 0, 4); + return buf[0] & 0xff | (buf[1] & 0xff) << 8 | (buf[2] & 0xff) << 16 | (buf[3] & 0xff) << 24; + } +} diff --git a/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java b/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java new file mode 100644 index 00000000..25a7c276 --- /dev/null +++ b/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java @@ -0,0 +1,41 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.apng.io; + +import org.signal.glide.common.io.ByteBufferWriter; + +import java.nio.ByteOrder; + +/** + * @Description: APNGWriter + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public class APNGWriter extends ByteBufferWriter { + public APNGWriter() { + super(); + } + + public void writeFourCC(int val) { + putByte((byte) (val & 0xff)); + putByte((byte) ((val >> 8) & 0xff)); + putByte((byte) ((val >> 16) & 0xff)); + putByte((byte) ((val >> 24) & 0xff)); + } + + public void writeInt(int val) { + putByte((byte) ((val >> 24) & 0xff)); + putByte((byte) ((val >> 16) & 0xff)); + putByte((byte) ((val >> 8) & 0xff)); + putByte((byte) (val & 0xff)); + } + + @Override + public void reset(int size) { + super.reset(size); + this.byteBuffer.order(ByteOrder.BIG_ENDIAN); + } +} diff --git a/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java b/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java new file mode 100644 index 00000000..7f2353fa --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java @@ -0,0 +1,253 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.DrawFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PaintFlagsDrawFilter; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.NonNull; +import androidx.vectordrawable.graphics.drawable.Animatable2Compat; + +import org.signal.core.util.logging.Log; +import org.signal.glide.common.decode.FrameSeqDecoder; +import org.signal.glide.common.loader.Loader; + +import java.nio.ByteBuffer; +import java.util.HashSet; +import java.util.Set; + +/** + * @Description: Frame animation drawable + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +public abstract class FrameAnimationDrawable extends Drawable implements Animatable2Compat, FrameSeqDecoder.RenderListener { + private static final String TAG = FrameAnimationDrawable.class.getSimpleName(); + private final Paint paint = new Paint(); + private final Decoder frameSeqDecoder; + private DrawFilter drawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + private Matrix matrix = new Matrix(); + private Set animationCallbacks = new HashSet<>(); + private Bitmap bitmap; + private static final int MSG_ANIMATION_START = 1; + private static final int MSG_ANIMATION_END = 2; + private Handler uiHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_ANIMATION_START: + for (AnimationCallback animationCallback : animationCallbacks) { + animationCallback.onAnimationStart(FrameAnimationDrawable.this); + } + break; + case MSG_ANIMATION_END: + for (AnimationCallback animationCallback : animationCallbacks) { + animationCallback.onAnimationEnd(FrameAnimationDrawable.this); + } + break; + } + } + }; + private Runnable invalidateRunnable = new Runnable() { + @Override + public void run() { + invalidateSelf(); + } + }; + private boolean autoPlay = true; + + public FrameAnimationDrawable(Decoder frameSeqDecoder) { + paint.setAntiAlias(true); + this.frameSeqDecoder = frameSeqDecoder; + } + + public FrameAnimationDrawable(Loader provider) { + paint.setAntiAlias(true); + this.frameSeqDecoder = createFrameSeqDecoder(provider, this); + } + + public void setAutoPlay(boolean autoPlay) { + this.autoPlay = autoPlay; + } + + protected abstract Decoder createFrameSeqDecoder(Loader streamLoader, FrameSeqDecoder.RenderListener listener); + + /** + * @param loopLimit <=0为无限播放,>0为实际播放次数 + */ + public void setLoopLimit(int loopLimit) { + frameSeqDecoder.setLoopLimit(loopLimit); + } + + public void reset() { + frameSeqDecoder.reset(); + } + + public void pause() { + frameSeqDecoder.pause(); + } + + public void resume() { + frameSeqDecoder.resume(); + } + + public boolean isPaused() { + return frameSeqDecoder.isPaused(); + } + + @Override + public void start() { + if (autoPlay) { + frameSeqDecoder.start(); + } else { + this.frameSeqDecoder.addRenderListener(this); + if (!this.frameSeqDecoder.isRunning()) { + this.frameSeqDecoder.start(); + } + } + } + + @Override + public void stop() { + if (autoPlay) { + frameSeqDecoder.stop(); + } else { + this.frameSeqDecoder.removeRenderListener(this); + this.frameSeqDecoder.stopIfNeeded(); + } + } + + @Override + public boolean isRunning() { + return frameSeqDecoder.isRunning(); + } + + @Override + public void draw(Canvas canvas) { + if (bitmap == null || bitmap.isRecycled()) { + return; + } + canvas.setDrawFilter(drawFilter); + canvas.drawBitmap(bitmap, matrix, paint); + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + boolean sampleSizeChanged = frameSeqDecoder.setDesiredSize(getBounds().width(), getBounds().height()); + matrix.setScale( + 1.0f * getBounds().width() * frameSeqDecoder.getSampleSize() / frameSeqDecoder.getBounds().width(), + 1.0f * getBounds().height() * frameSeqDecoder.getSampleSize() / frameSeqDecoder.getBounds().height()); + + if (sampleSizeChanged) + this.bitmap = Bitmap.createBitmap( + frameSeqDecoder.getBounds().width() / frameSeqDecoder.getSampleSize(), + frameSeqDecoder.getBounds().height() / frameSeqDecoder.getSampleSize(), + Bitmap.Config.ARGB_8888); + } + + @Override + public void setAlpha(int alpha) { + paint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + paint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public void onStart() { + Message.obtain(uiHandler, MSG_ANIMATION_START).sendToTarget(); + } + + @Override + public void onRender(ByteBuffer byteBuffer) { + if (!isRunning()) { + return; + } + if (this.bitmap == null || this.bitmap.isRecycled()) { + this.bitmap = Bitmap.createBitmap( + frameSeqDecoder.getBounds().width() / frameSeqDecoder.getSampleSize(), + frameSeqDecoder.getBounds().height() / frameSeqDecoder.getSampleSize(), + Bitmap.Config.ARGB_8888); + } + byteBuffer.rewind(); + if (byteBuffer.remaining() < this.bitmap.getByteCount()) { + Log.e(TAG, "onRender:Buffer not large enough for pixels"); + return; + } + this.bitmap.copyPixelsFromBuffer(byteBuffer); + uiHandler.post(invalidateRunnable); + } + + @Override + public void onEnd() { + Message.obtain(uiHandler, MSG_ANIMATION_END).sendToTarget(); + } + + @Override + public boolean setVisible(boolean visible, boolean restart) { + if (this.autoPlay) { + if (visible) { + if (!isRunning()) { + start(); + } + } else if (isRunning()) { + stop(); + } + } + return super.setVisible(visible, restart); + } + + @Override + public int getIntrinsicWidth() { + try { + return frameSeqDecoder.getBounds().width(); + } catch (Exception exception) { + return 0; + } + } + + @Override + public int getIntrinsicHeight() { + try { + return frameSeqDecoder.getBounds().height(); + } catch (Exception exception) { + return 0; + } + } + + @Override + public void registerAnimationCallback(@NonNull AnimationCallback animationCallback) { + this.animationCallbacks.add(animationCallback); + } + + @Override + public boolean unregisterAnimationCallback(@NonNull AnimationCallback animationCallback) { + return this.animationCallbacks.remove(animationCallback); + } + + @Override + public void clearAnimationCallbacks() { + this.animationCallbacks.clear(); + } +} diff --git a/app/src/main/java/org/signal/glide/common/decode/Frame.java b/app/src/main/java/org/signal/glide/common/decode/Frame.java new file mode 100644 index 00000000..e7fd5e96 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/decode/Frame.java @@ -0,0 +1,33 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.decode; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; + +import org.signal.glide.common.io.Reader; +import org.signal.glide.common.io.Writer; + +/** + * @Description: One frame in an animation + * @Author: pengfei.zhou + * @CreateDate: 2019-05-13 + */ +public abstract class Frame { + protected final R reader; + public int frameWidth; + public int frameHeight; + public int frameX; + public int frameY; + public int frameDuration; + + public Frame(R reader) { + this.reader = reader; + } + + public abstract Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, W writer); +} diff --git a/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java b/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java new file mode 100644 index 00000000..87ea2e3a --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java @@ -0,0 +1,539 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.decode; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.signal.glide.common.executor.FrameDecoderExecutor; +import org.signal.glide.common.io.Reader; +import org.signal.glide.common.io.Writer; +import org.signal.glide.common.loader.Loader; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +/** + * @Description: Abstract Frame Animation Decoder + * @Author: pengfei.zhou + * @CreateDate: 2019/3/27 + */ +public abstract class FrameSeqDecoder { + private static final String TAG = FrameSeqDecoder.class.getSimpleName(); + private final int taskId; + + private final Loader mLoader; + private final Handler workerHandler; + protected List frames = new ArrayList<>(); + protected int frameIndex = -1; + private int playCount; + private Integer loopLimit = null; + private Set renderListeners = new HashSet<>(); + private AtomicBoolean paused = new AtomicBoolean(true); + private static final Rect RECT_EMPTY = new Rect(); + private Runnable renderTask = new Runnable() { + @Override + public void run() { + if (paused.get()) { + return; + } + if (canStep()) { + long start = System.currentTimeMillis(); + long delay = step(); + long cost = System.currentTimeMillis() - start; + workerHandler.postDelayed(this, Math.max(0, delay - cost)); + for (RenderListener renderListener : renderListeners) { + renderListener.onRender(frameBuffer); + } + } else { + stop(); + } + } + }; + protected int sampleSize = 1; + + private Set cacheBitmaps = new HashSet<>(); + protected Map cachedCanvas = new WeakHashMap<>(); + protected ByteBuffer frameBuffer; + protected volatile Rect fullRect; + private W mWriter = getWriter(); + private R mReader = null; + + /** + * If played all the needed + */ + private boolean finished = false; + + private enum State { + IDLE, + RUNNING, + INITIALIZING, + FINISHING, + } + + private volatile State mState = State.IDLE; + + public Loader getLoader() { + return mLoader; + } + + protected abstract W getWriter(); + + protected abstract R getReader(Reader reader); + + protected Bitmap obtainBitmap(int width, int height) { + Bitmap ret = null; + Iterator iterator = cacheBitmaps.iterator(); + while (iterator.hasNext()) { + int reuseSize = width * height * 4; + ret = iterator.next(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + if (ret != null && ret.getAllocationByteCount() >= reuseSize) { + iterator.remove(); + if (ret.getWidth() != width || ret.getHeight() != height) { + ret.reconfigure(width, height, Bitmap.Config.ARGB_8888); + } + ret.eraseColor(0); + return ret; + } + } else { + if (ret != null && ret.getByteCount() >= reuseSize) { + if (ret.getWidth() == width && ret.getHeight() == height) { + iterator.remove(); + ret.eraseColor(0); + } + return ret; + } + } + } + + try { + Bitmap.Config config = Bitmap.Config.ARGB_8888; + ret = Bitmap.createBitmap(width, height, config); + } catch (OutOfMemoryError e) { + e.printStackTrace(); + } + return ret; + } + + protected void recycleBitmap(Bitmap bitmap) { + if (bitmap != null && !cacheBitmaps.contains(bitmap)) { + cacheBitmaps.add(bitmap); + } + } + + /** + * 解码器的渲染回调 + */ + public interface RenderListener { + /** + * 播放开始 + */ + void onStart(); + + /** + * 帧播放 + */ + void onRender(ByteBuffer byteBuffer); + + /** + * 播放结束 + */ + void onEnd(); + } + + + /** + * @param loader webp的reader + * @param renderListener 渲染的回调 + */ + public FrameSeqDecoder(Loader loader, @Nullable RenderListener renderListener) { + this.mLoader = loader; + if (renderListener != null) { + this.renderListeners.add(renderListener); + } + this.taskId = FrameDecoderExecutor.getInstance().generateTaskId(); + this.workerHandler = new Handler(FrameDecoderExecutor.getInstance().getLooper(taskId)); + } + + + public void addRenderListener(final RenderListener renderListener) { + this.workerHandler.post(new Runnable() { + @Override + public void run() { + renderListeners.add(renderListener); + } + }); + } + + public void removeRenderListener(final RenderListener renderListener) { + this.workerHandler.post(new Runnable() { + @Override + public void run() { + renderListeners.remove(renderListener); + } + }); + } + + public void stopIfNeeded() { + this.workerHandler.post(new Runnable() { + @Override + public void run() { + if (renderListeners.size() == 0) { + stop(); + } + } + }); + } + + public Rect getBounds() { + if (fullRect == null) { + if (mState == State.FINISHING) { + Log.e(TAG, "In finishing,do not interrupt"); + } + final Thread thread = Thread.currentThread(); + workerHandler.post(new Runnable() { + @Override + public void run() { + try { + if (fullRect == null) { + if (mReader == null) { + mReader = getReader(mLoader.obtain()); + } else { + mReader.reset(); + } + initCanvasBounds(read(mReader)); + } + } catch (Exception e) { + e.printStackTrace(); + fullRect = RECT_EMPTY; + } finally { + LockSupport.unpark(thread); + } + } + }); + LockSupport.park(thread); + } + return fullRect; + } + + private void initCanvasBounds(Rect rect) { + fullRect = rect; + frameBuffer = ByteBuffer.allocate((rect.width() * rect.height() / (sampleSize * sampleSize) + 1) * 4); + if (mWriter == null) { + mWriter = getWriter(); + } + } + + + private int getFrameCount() { + return this.frames.size(); + } + + /** + * @return Loop Count defined in file + */ + protected abstract int getLoopCount(); + + public void start() { + if (fullRect == RECT_EMPTY) { + return; + } + if (mState == State.RUNNING || mState == State.INITIALIZING) { + Log.i(TAG, debugInfo() + " Already started"); + return; + } + if (mState == State.FINISHING) { + Log.e(TAG, debugInfo() + " Processing,wait for finish at " + mState); + } + mState = State.INITIALIZING; + if (Looper.myLooper() == workerHandler.getLooper()) { + innerStart(); + } else { + workerHandler.post(new Runnable() { + @Override + public void run() { + innerStart(); + } + }); + } + } + + @WorkerThread + private void innerStart() { + paused.compareAndSet(true, false); + + final long start = System.currentTimeMillis(); + try { + if (frames.size() == 0) { + try { + if (mReader == null) { + mReader = getReader(mLoader.obtain()); + } else { + mReader.reset(); + } + initCanvasBounds(read(mReader)); + } catch (Throwable e) { + e.printStackTrace(); + } + } + } finally { + Log.i(TAG, debugInfo() + " Set state to RUNNING,cost " + (System.currentTimeMillis() - start)); + mState = State.RUNNING; + } + if (getNumPlays() == 0 || !finished) { + this.frameIndex = -1; + renderTask.run(); + for (RenderListener renderListener : renderListeners) { + renderListener.onStart(); + } + } else { + Log.i(TAG, debugInfo() + " No need to started"); + } + } + + @WorkerThread + private void innerStop() { + workerHandler.removeCallbacks(renderTask); + frames.clear(); + for (Bitmap bitmap : cacheBitmaps) { + if (bitmap != null && !bitmap.isRecycled()) { + bitmap.recycle(); + } + } + cacheBitmaps.clear(); + if (frameBuffer != null) { + frameBuffer = null; + } + cachedCanvas.clear(); + try { + if (mReader != null) { + mReader.close(); + mReader = null; + } + if (mWriter != null) { + mWriter.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + release(); + mState = State.IDLE; + for (RenderListener renderListener : renderListeners) { + renderListener.onEnd(); + } + } + + public void stop() { + if (fullRect == RECT_EMPTY) { + return; + } + if (mState == State.FINISHING || mState == State.IDLE) { + Log.i(TAG, debugInfo() + "No need to stop"); + return; + } + if (mState == State.INITIALIZING) { + Log.e(TAG, debugInfo() + "Processing,wait for finish at " + mState); + } + mState = State.FINISHING; + if (Looper.myLooper() == workerHandler.getLooper()) { + innerStop(); + } else { + workerHandler.post(new Runnable() { + @Override + public void run() { + innerStop(); + } + }); + } + } + + private String debugInfo() { + return ""; + } + + protected abstract void release(); + + public boolean isRunning() { + return mState == State.RUNNING || mState == State.INITIALIZING; + } + + public boolean isPaused() { + return paused.get(); + } + + public void setLoopLimit(int limit) { + this.loopLimit = limit; + } + + public void reset() { + this.playCount = 0; + this.frameIndex = -1; + this.finished = false; + } + + public void pause() { + workerHandler.removeCallbacks(renderTask); + paused.compareAndSet(false, true); + } + + public void resume() { + paused.compareAndSet(true, false); + workerHandler.removeCallbacks(renderTask); + workerHandler.post(renderTask); + } + + + public int getSampleSize() { + return sampleSize; + } + + public boolean setDesiredSize(int width, int height) { + boolean sampleSizeChanged = false; + int sample = getDesiredSample(width, height); + if (sample != this.sampleSize) { + this.sampleSize = sample; + sampleSizeChanged = true; + final boolean tempRunning = isRunning(); + workerHandler.removeCallbacks(renderTask); + workerHandler.post(new Runnable() { + @Override + public void run() { + innerStop(); + try { + initCanvasBounds(read(getReader(mLoader.obtain()))); + if (tempRunning) { + innerStart(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + }); + } + return sampleSizeChanged; + } + + protected int getDesiredSample(int desiredWidth, int desiredHeight) { + if (desiredWidth == 0 || desiredHeight == 0) { + return 1; + } + int radio = Math.min(getBounds().width() / desiredWidth, getBounds().height() / desiredHeight); + int sample = 1; + while ((sample * 2) <= radio) { + sample *= 2; + } + return sample; + } + + protected abstract Rect read(R reader) throws IOException; + + private int getNumPlays() { + return this.loopLimit != null ? this.loopLimit : this.getLoopCount(); + } + + private boolean canStep() { + if (!isRunning()) { + return false; + } + if (frames.size() == 0) { + return false; + } + if (getNumPlays() <= 0) { + return true; + } + if (this.playCount < getNumPlays() - 1) { + return true; + } else if (this.playCount == getNumPlays() - 1 && this.frameIndex < this.getFrameCount() - 1) { + return true; + } + finished = true; + return false; + } + + @WorkerThread + private long step() { + this.frameIndex++; + if (this.frameIndex >= this.getFrameCount()) { + this.frameIndex = 0; + this.playCount++; + } + Frame frame = getFrame(this.frameIndex); + if (frame == null) { + return 0; + } + renderFrame(frame); + return frame.frameDuration; + } + + protected abstract void renderFrame(Frame frame); + + private Frame getFrame(int index) { + if (index < 0 || index >= frames.size()) { + return null; + } + return frames.get(index); + } + + /** + * Get Indexed frame + * + * @param index <0 means reverse from last index + */ + public Bitmap getFrameBitmap(int index) throws IOException { + if (mState != State.IDLE) { + Log.e(TAG, debugInfo() + ",stop first"); + return null; + } + mState = State.RUNNING; + paused.compareAndSet(true, false); + if (frames.size() == 0) { + if (mReader == null) { + mReader = getReader(mLoader.obtain()); + } else { + mReader.reset(); + } + initCanvasBounds(read(mReader)); + } + if (index < 0) { + index += this.frames.size(); + } + if (index < 0) { + index = 0; + } + frameIndex = -1; + while (frameIndex < index) { + if (canStep()) { + step(); + } else { + break; + } + } + frameBuffer.rewind(); + Bitmap bitmap = Bitmap.createBitmap(getBounds().width() / getSampleSize(), getBounds().height() / getSampleSize(), Bitmap.Config.ARGB_8888); + bitmap.copyPixelsFromBuffer(frameBuffer); + innerStop(); + return bitmap; + } +} diff --git a/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java b/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java new file mode 100644 index 00000000..6a7aae08 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java @@ -0,0 +1,70 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.executor; + +import android.os.HandlerThread; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @Description: com.github.penfeizhou.animation.executor + * @Author: pengfei.zhou + * @CreateDate: 2019-11-21 + */ +public class FrameDecoderExecutor { + private static int sPoolNumber = 4; + private ArrayList mHandlerThreadGroup = new ArrayList<>(); + private AtomicInteger counter = new AtomicInteger(0); + + private FrameDecoderExecutor() { + } + + static class Inner { + static final FrameDecoderExecutor sInstance = new FrameDecoderExecutor(); + } + + public void setPoolSize(int size) { + sPoolNumber = size; + } + + public static FrameDecoderExecutor getInstance() { + return Inner.sInstance; + } + + public Looper getLooper(int taskId) { + int idx = taskId % sPoolNumber; + if (idx >= mHandlerThreadGroup.size()) { + HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx); + handlerThread.start(); + + mHandlerThreadGroup.add(handlerThread); + Looper looper = handlerThread.getLooper(); + if (looper != null) { + return looper; + } else { + return Looper.getMainLooper(); + } + } else { + if (mHandlerThreadGroup.get(idx) != null) { + Looper looper = mHandlerThreadGroup.get(idx).getLooper(); + if (looper != null) { + return looper; + } else { + return Looper.getMainLooper(); + } + } else { + return Looper.getMainLooper(); + } + } + } + + public int generateTaskId() { + return counter.getAndIncrement(); + } +} + diff --git a/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java b/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java new file mode 100644 index 00000000..7ed9cfa1 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java @@ -0,0 +1,67 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * @Description: APNG4Android + * @Author: pengfei.zhou + * @CreateDate: 2019-05-14 + */ +public class ByteBufferReader implements Reader { + + private final ByteBuffer byteBuffer; + + public ByteBufferReader(ByteBuffer byteBuffer) { + this.byteBuffer = byteBuffer; + byteBuffer.position(0); + } + + @Override + public long skip(long total) throws IOException { + byteBuffer.position((int) (byteBuffer.position() + total)); + return total; + } + + @Override + public byte peek() throws IOException { + return byteBuffer.get(); + } + + @Override + public void reset() throws IOException { + byteBuffer.position(0); + } + + @Override + public int position() { + return byteBuffer.position(); + } + + @Override + public int read(byte[] buffer, int start, int byteCount) throws IOException { + byteBuffer.get(buffer, start, byteCount); + return byteCount; + } + + @Override + public int available() throws IOException { + return byteBuffer.limit() - byteBuffer.position(); + } + + @Override + public void close() throws IOException { + } + + @Override + public InputStream toInputStream() throws IOException { + return new ByteArrayInputStream(byteBuffer.array()); + } +} diff --git a/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java b/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java new file mode 100644 index 00000000..f60688fb --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * @Description: ByteBufferWriter + * @Author: pengfei.zhou + * @CreateDate: 2019-05-12 + */ +public class ByteBufferWriter implements Writer { + + protected ByteBuffer byteBuffer; + + public ByteBufferWriter() { + reset(10 * 1024); + } + + @Override + public void putByte(byte b) { + byteBuffer.put(b); + } + + @Override + public void putBytes(byte[] b) { + byteBuffer.put(b); + } + + @Override + public int position() { + return byteBuffer.position(); + } + + @Override + public void skip(int length) { + byteBuffer.position(length + position()); + } + + @Override + public byte[] toByteArray() { + return byteBuffer.array(); + } + + @Override + public void close() { + } + + @Override + public void reset(int size) { + if (byteBuffer == null || size > byteBuffer.capacity()) { + byteBuffer = ByteBuffer.allocate(size); + this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + } + byteBuffer.clear(); + } +} diff --git a/app/src/main/java/org/signal/glide/common/io/FileReader.java b/app/src/main/java/org/signal/glide/common/io/FileReader.java new file mode 100644 index 00000000..1f21184d --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/FileReader.java @@ -0,0 +1,30 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +/** + * @Description: FileReader + * @Author: pengfei.zhou + * @CreateDate: 2019-05-23 + */ +public class FileReader extends FilterReader { + private final File mFile; + + public FileReader(File file) throws IOException { + super(new StreamReader(new FileInputStream(file))); + mFile = file; + } + + @Override + public void reset() throws IOException { + reader.close(); + reader = new StreamReader(new FileInputStream(mFile)); + } +} diff --git a/app/src/main/java/org/signal/glide/common/io/FilterReader.java b/app/src/main/java/org/signal/glide/common/io/FilterReader.java new file mode 100644 index 00000000..08abbc91 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/FilterReader.java @@ -0,0 +1,63 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @Description: FilterReader + * @Author: pengfei.zhou + * @CreateDate: 2019-05-23 + */ +public class FilterReader implements Reader { + protected Reader reader; + + public FilterReader(Reader in) { + this.reader = in; + } + + @Override + public long skip(long total) throws IOException { + return reader.skip(total); + } + + @Override + public byte peek() throws IOException { + return reader.peek(); + } + + @Override + public void reset() throws IOException { + reader.reset(); + } + + @Override + public int position() { + return reader.position(); + } + + @Override + public int read(byte[] buffer, int start, int byteCount) throws IOException { + return reader.read(buffer, start, byteCount); + } + + @Override + public int available() throws IOException { + return reader.available(); + } + + @Override + public void close() throws IOException { + reader.close(); + } + + @Override + public InputStream toInputStream() throws IOException { + reset(); + return reader.toInputStream(); + } +} diff --git a/app/src/main/java/org/signal/glide/common/io/Reader.java b/app/src/main/java/org/signal/glide/common/io/Reader.java new file mode 100644 index 00000000..6be530b2 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/Reader.java @@ -0,0 +1,35 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @link {https://developers.google.com/speed/webp/docs/riff_container#terminology_basics} + * @Author: pengfei.zhou + * @CreateDate: 2019-05-11 + */ +public interface Reader { + long skip(long total) throws IOException; + + byte peek() throws IOException; + + void reset() throws IOException; + + int position(); + + int read(byte[] buffer, int start, int byteCount) throws IOException; + + int available() throws IOException; + + /** + * close io + */ + void close() throws IOException; + + InputStream toInputStream() throws IOException; +} diff --git a/app/src/main/java/org/signal/glide/common/io/StreamReader.java b/app/src/main/java/org/signal/glide/common/io/StreamReader.java new file mode 100644 index 00000000..7eb08f51 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/StreamReader.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * @Author: pengfei.zhou + * @CreateDate: 2019-05-11 + */ +public class StreamReader extends FilterInputStream implements Reader { + private int position; + + public StreamReader(InputStream in) { + super(in); + try { + in.reset(); + } catch (IOException e) { + // e.printStackTrace(); + } + } + + @Override + public byte peek() throws IOException { + byte ret = (byte) read(); + position++; + return ret; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int ret = super.read(b, off, len); + position += Math.max(0, ret); + return ret; + } + + @Override + public synchronized void reset() throws IOException { + super.reset(); + position = 0; + } + + @Override + public long skip(long n) throws IOException { + long ret = super.skip(n); + position += ret; + return ret; + } + + @Override + public int position() { + return position; + } + + @Override + public InputStream toInputStream() throws IOException { + return this; + } +} diff --git a/app/src/main/java/org/signal/glide/common/io/Writer.java b/app/src/main/java/org/signal/glide/common/io/Writer.java new file mode 100644 index 00000000..84600a08 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/io/Writer.java @@ -0,0 +1,29 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.io; + +import java.io.IOException; + +/** + * @Description: APNG4Android + * @Author: pengfei.zhou + * @CreateDate: 2019-05-12 + */ +public interface Writer { + void reset(int size); + + void putByte(byte b); + + void putBytes(byte[] b); + + int position(); + + void skip(int length); + + byte[] toByteArray(); + + void close() throws IOException; +} diff --git a/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java new file mode 100644 index 00000000..d62ac720 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java @@ -0,0 +1,32 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import android.content.Context; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @Description: 从Asset中读取流 + * @Author: pengfei.zhou + * @CreateDate: 2019/3/28 + */ +public class AssetStreamLoader extends StreamLoader { + + private final Context mContext; + private final String mAssetName; + + public AssetStreamLoader(Context context, String assetName) { + mContext = context.getApplicationContext(); + mAssetName = assetName; + } + + @Override + protected InputStream getInputStream() throws IOException { + return mContext.getAssets().open(mAssetName); + } +} diff --git a/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java b/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java new file mode 100644 index 00000000..05bf8d36 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java @@ -0,0 +1,26 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import org.signal.glide.common.io.ByteBufferReader; +import org.signal.glide.common.io.Reader; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * @Description: ByteBufferLoader + * @Author: pengfei.zhou + * @CreateDate: 2019-05-15 + */ +public abstract class ByteBufferLoader implements Loader { + public abstract ByteBuffer getByteBuffer(); + + @Override + public Reader obtain() throws IOException { + return new ByteBufferReader(getByteBuffer()); + } +} diff --git a/app/src/main/java/org/signal/glide/common/loader/FileLoader.java b/app/src/main/java/org/signal/glide/common/loader/FileLoader.java new file mode 100644 index 00000000..e861aa7e --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/FileLoader.java @@ -0,0 +1,32 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import org.signal.glide.common.io.FileReader; +import org.signal.glide.common.io.Reader; + +import java.io.File; +import java.io.IOException; + +/** + * @Description: 从文件加载流 + * @Author: pengfei.zhou + * @CreateDate: 2019/3/28 + */ +public class FileLoader implements Loader { + + private final File mFile; + private Reader mReader; + + public FileLoader(String path) { + mFile = new File(path); + } + + @Override + public synchronized Reader obtain() throws IOException { + return new FileReader(mFile); + } +} diff --git a/app/src/main/java/org/signal/glide/common/loader/Loader.java b/app/src/main/java/org/signal/glide/common/loader/Loader.java new file mode 100644 index 00000000..9a38babb --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/Loader.java @@ -0,0 +1,19 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import org.signal.glide.common.io.Reader; + +import java.io.IOException; + +/** + * @Description: Loader + * @Author: pengfei.zhou + * @CreateDate: 2019-05-14 + */ +public interface Loader { + Reader obtain() throws IOException; +} diff --git a/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java new file mode 100644 index 00000000..5d6db5a8 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java @@ -0,0 +1,32 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import android.content.Context; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @Description: 从资源加载流 + * @Author: pengfei.zhou + * @CreateDate: 2019/3/28 + */ +public class ResourceStreamLoader extends StreamLoader { + private final Context mContext; + private final int mResId; + + + public ResourceStreamLoader(Context context, int resId) { + mContext = context.getApplicationContext(); + mResId = resId; + } + + @Override + protected InputStream getInputStream() throws IOException { + return mContext.getResources().openRawResource(mResId); + } +} diff --git a/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java new file mode 100644 index 00000000..0103ca80 --- /dev/null +++ b/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 Zhou Pengfei + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.signal.glide.common.loader; + +import org.signal.glide.common.io.Reader; +import org.signal.glide.common.io.StreamReader; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @Author: pengfei.zhou + * @CreateDate: 2019/3/28 + */ +public abstract class StreamLoader implements Loader { + protected abstract InputStream getInputStream() throws IOException; + + + public final synchronized Reader obtain() throws IOException { + return new StreamReader(getInputStream()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java new file mode 100644 index 00000000..83d9c8f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms; + +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.signalservice.api.account.AccountAttributes; + +public final class AppCapabilities { + + private AppCapabilities() { + } + + private static final boolean UUID_CAPABLE = false; + private static final boolean GV2_CAPABLE = true; + private static final boolean GV1_MIGRATION = true; + + /** + * @param storageCapable Whether or not the user can use storage service. This is another way of + * asking if the user has set a Signal PIN or not. + */ + public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) { + return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java new file mode 100644 index 00000000..42fb8d37 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.insights.InsightsOptOut; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +/** + * Rule of thumb: if there's something you want to do on the first app launch that involves + * persisting state to the database, you'll almost certainly *also* want to do it post backup + * restore, since a backup restore will wipe the current state of the database. + */ +public final class AppInitialization { + + private static final String TAG = Log.tag(AppInitialization.class); + + private AppInitialization() {} + + public static void onFirstEverAppLaunch(@NonNull Context context) { + Log.i(TAG, "onFirstEverAppLaunch()"); + + InsightsOptOut.userRequestedOptOut(context); + TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION); + TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION); + TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode()); + TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true); + TextSecurePreferences.setPasswordDisabled(context, true); + TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode()); + TextSecurePreferences.setReadReceiptsEnabled(context, true); + TextSecurePreferences.setTypingIndicatorsEnabled(context, true); + TextSecurePreferences.setHasSeenWelcomeScreen(context, false); + ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); + SignalStore.onFirstEverAppLaunch(); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey())); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey())); + } + + public static void onPostBackupRestore(@NonNull Context context) { + Log.i(TAG, "onPostBackupRestore()"); + + ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); + SignalStore.onFirstEverAppLaunch(); + SignalStore.onboarding().clearAll(); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey())); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey())); + } + + /** + * Temporary migration method that does the safest bits of {@link #onFirstEverAppLaunch(Context)} + */ + public static void onRepairFirstEverAppLaunch(@NonNull Context context) { + Log.w(TAG, "onRepairFirstEverAppLaunch()"); + + InsightsOptOut.userRequestedOptOut(context); + TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION); + TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION); + TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode()); + TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true); + TextSecurePreferences.setPasswordDisabled(context, true); + TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode()); + ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); + SignalStore.onFirstEverAppLaunch(); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false)); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey())); + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java new file mode 100644 index 00000000..3a487062 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -0,0 +1,503 @@ +/* + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; +import androidx.multidex.MultiDexApplication; + +import com.google.android.gms.security.ProviderInstaller; +import com.tm.androidcopysdk.AndroidCopySDK; +import com.tm.androidcopysdk.AndroidCopySettings; +import com.tm.androidcopysdk.CommonUtils; +import com.tm.androidcopysdk.events.EventAbsObj; +import com.tm.androidcopysdk.events.PeriodicEventChecker; +import com.tm.androidcopysdk.utils.PrefManager; + + +import org.archiver.ArchiveUtil; +import org.conscrypt.Conscrypt; +import org.signal.aesgcmprovider.AesGcmProvider; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.AndroidLogger; +import org.signal.core.util.logging.Log; +import org.signal.core.util.logging.PersistentLogger; +import org.signal.core.util.tracing.Tracer; +import org.signal.glide.SignalGlideCodecs; +import org.signal.ringrtc.CallManager; +import org.archiver.ArchiveConstants; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider; +import org.thoughtcrime.securesms.gcm.FcmJobService; +import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; +import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; +import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; +import org.thoughtcrime.securesms.logging.LogSecretProvider; +import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; +import org.thoughtcrime.securesms.ringrtc.RingRtcLogger; +import org.thoughtcrime.securesms.service.DirectoryRefreshListener; +import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.service.LocalBackupListener; +import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; +import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; +import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.VersionTracker; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; +import org.webrtc.voiceengine.WebRtcAudioManager; +import org.webrtc.voiceengine.WebRtcAudioUtils; +import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider; + +import java.security.Security; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.archiver.ArchiveConstants.isTestMode; + +/** + * Will be called once when the TextSecure process is created. + * + * We're using this as an insertion point to patch up the Android PRNG disaster, + * to initialize the job manager, and to check for GCM registration freshness. + * + * @author Moxie Marlinspike + */ +public class ApplicationContext extends MultiDexApplication implements DefaultLifecycleObserver { + + private static final String TAG = ApplicationContext.class.getSimpleName(); + + private ExpiringMessageManager expiringMessageManager; + private ViewOnceMessageManager viewOnceMessageManager; + private PersistentLogger persistentLogger; + + private volatile boolean isAppVisible; + + public static ApplicationContext getInstance(Context context) { + return (ApplicationContext)context.getApplicationContext(); + } + + @Override + public void onCreate() { + Tracer.getInstance().start("Application#onCreate()"); + AppStartup.getInstance().onApplicationCreate(); + + long startTime = System.currentTimeMillis(); + + if (FeatureFlags.internalUser()) { + Tracer.getInstance().setMaxBufferSize(35_000); + } + + super.onCreate(); + + AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider) + .addBlocking("logging", () -> { + initializeLogging(); + Log.i(TAG, "onCreate()"); + }) + .addBlocking("crash-handling", this::initializeCrashHandling) + .addBlocking("eat-db", () -> DatabaseFactory.getInstance(this)) + .addBlocking("app-dependencies", this::initializeAppDependencies) + .addBlocking("first-launch", this::initializeFirstEverAppLaunch) + .addBlocking("app-migrations", this::initializeApplicationMigrations) + .addBlocking("ring-rtc", this::initializeRingRtc) + .addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete(this)) + .addBlocking("lifecycle-observer", () -> ProcessLifecycleOwner.get().getLifecycle().addObserver(this)) + .addBlocking("message-retriever", this::initializeMessageRetrieval) + .addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this)) + .addBlocking("vector-compat", () -> { + if (Build.VERSION.SDK_INT < 21) { + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + } + }) + .addBlocking("proxy-init", () -> { + if (SignalStore.proxy().isProxyEnabled()) { + Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()"); + Conscrypt.setUseEngineSocketByDefault(true); + } + }) + .addNonBlocking(this::initializeRevealableMessageManager) + .addNonBlocking(this::initializeGcmCheck) + .addNonBlocking(this::initializeSignedPreKeyCheck) + .addNonBlocking(this::initializePeriodicTasks) + .addNonBlocking(this::initializeCircumvention) + .addNonBlocking(this::initializePendingMessages) + .addNonBlocking(this::initializeCleanup) + .addNonBlocking(this::initializeGlideCodecs) + .addNonBlocking(FeatureFlags::init) + .addNonBlocking(RefreshPreKeysJob::scheduleIfNecessary) + .addNonBlocking(StorageSyncHelper::scheduleRoutineSync) + .addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop()) + .addPostRender(this::initializeExpiringMessageManager) + .addPostRender(this::initializeBlobProvider) + .addPostRender(() -> NotificationChannels.create(this)) + .execute(); + + ProcessLifecycleOwner.get().getLifecycle().addObserver(this); + + Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); + Tracer.getInstance().end("Application#onCreate()"); + + com.tm.logger.Log.createInstance(getApplicationContext()); + + initArchiveUrlsAndStartArchive(); + + } + + private void initArchiveUrlsAndStartArchive() { + + CommonUtils.setUrl(getApplicationContext(), ArchiveConstants.charlieProduction, ArchiveConstants.prodKeeper); + // CommonUtils.setUrl(getApplicationContext(), ArchiveConstants.integration, ArchiveConstants.integrationKeeper); + CommonUtils.setSqlInfo(getApplicationContext(), ArchiveConstants.isTestMode ? ArchiveConstants.signalTestPassword : ArchiveConstants.signalCurrentPassword); + + boolean installationEventSent = PrefManager.getBooleanPref(getApplicationContext(),R.string.installation_event_sent,false); + + if(/*isTestMode*/true || !installationEventSent) { + initializeTMAndroidArchive(); + } + + CommonUtils.startBackupService(getApplicationContext()); + } + + private void initializeTMAndroidArchive() { + + AndroidCopySettings mSettings = new AndroidCopySettings(); + + PrefManager.setStringPref(getApplicationContext(),"wifi3g","WIFI3G"); + + mSettings.setData(AndroidCopySettings.DataSaving.WIFI3G); + + // AndroidCopySDK.getInstance(getApplicationContext()).savePhoneNumber(ArchiveUtil.Companion.getPhoneNumberInTestMode(this)); + + AndroidCopySDK.getInstance(getApplicationContext()).signupSucess(/*ArchiveConstants.signalTestUserName, ArchiveConstants.signalTestPassword*/"",""); + + + boolean installationEventSent = PrefManager.getBooleanPref(getApplicationContext(), R.string.installation_event_sent, false); + // InstallEvent should be sent only once + if(!installationEventSent) { +/* + CommonUtils.addUpdateVersionEvent(getApplicationContext(), EventAbsObj.EventType.InstallEvent); +*/ + PrefManager.setBooleanPref(getApplicationContext(),R.string.installation_event_sent,true); +/* + PeriodicEventChecker.startService(getApplicationContext(), -1); +*/ + } + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + long startTime = System.currentTimeMillis(); + isAppVisible = true; + Log.i(TAG, "App is now visible."); + + ApplicationDependencies.getFrameRateTracker().begin(); + ApplicationDependencies.getMegaphoneRepository().onAppForegrounded(); + + SignalExecutors.BOUNDED.execute(() -> { + FeatureFlags.refreshIfNecessary(); + ApplicationDependencies.getRecipientCache().warmUp(); + RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this); + GroupV1MigrationJob.enqueueRoutineMigrationsIfNecessary(this); + executePendingContactSync(); + KeyCachingService.onAppForegrounded(this); + ApplicationDependencies.getShakeToReport().enable(); + checkBuildExpiration(); + }); + + Log.d(TAG, "onStart() took " + (System.currentTimeMillis() - startTime) + " ms"); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + isAppVisible = false; + Log.i(TAG, "App is no longer visible."); + KeyCachingService.onAppBackgrounded(this); + ApplicationDependencies.getMessageNotifier().clearVisibleThread(); + ApplicationDependencies.getFrameRateTracker().end(); + ApplicationDependencies.getShakeToReport().disable(); + } + + public ExpiringMessageManager getExpiringMessageManager() { + if (expiringMessageManager == null) { + initializeExpiringMessageManager(); + } + return expiringMessageManager; + } + + public ViewOnceMessageManager getViewOnceMessageManager() { + return viewOnceMessageManager; + } + + public boolean isAppVisible() { + return isAppVisible; + } + + public PersistentLogger getPersistentLogger() { + return persistentLogger; + } + + public void checkBuildExpiration() { + if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Build expired!"); + SignalStore.misc().markClientDeprecated(); + } + } + + private void initializeSecurityProvider() { + try { + Class.forName("org.signal.aesgcmprovider.AesGcmCipher"); + } catch (ClassNotFoundException e) { + Log.e(TAG, "Failed to find AesGcmCipher class"); + throw new ProviderInitializationException(); + } + + int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1); + Log.i(TAG, "Installed AesGcmProvider: " + aesPosition); + + if (aesPosition < 0) { + Log.e(TAG, "Failed to install AesGcmProvider()"); + throw new ProviderInitializationException(); + } + + int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2); + Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition); + + if (conscryptPosition < 0) { + Log.w(TAG, "Did not install Conscrypt provider. May already be present."); + } + } + + private void initializeLogging() { + persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME); + org.signal.core.util.logging.Log.initialize(new AndroidLogger(), persistentLogger); + + SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger()); + } + + private void initializeCrashHandling() { + final Thread.UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler)); + } + + private void initializeApplicationMigrations() { + ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager()); + } + + public void initializeMessageRetrieval() { + ApplicationDependencies.getIncomingMessageObserver(); + } + + private void initializeAppDependencies() { + ApplicationDependencies.init(this, new ApplicationDependencyProvider(this)); + } + + private void initializeFirstEverAppLaunch() { + if (TextSecurePreferences.getFirstInstallVersion(this) == -1) { + if (!SQLCipherOpenHelper.databaseFileExists(this) || VersionTracker.getDaysSinceFirstInstalled(this) < 365) { + Log.i(TAG, "First ever app launch!"); + AppInitialization.onFirstEverAppLaunch(this); + } + + Log.i(TAG, "Setting first install version to " + BuildConfig.CANONICAL_VERSION_CODE); + TextSecurePreferences.setFirstInstallVersion(this, BuildConfig.CANONICAL_VERSION_CODE); + } else if (!TextSecurePreferences.isPasswordDisabled(this) && VersionTracker.getDaysSinceFirstInstalled(this) < 90) { + Log.i(TAG, "Detected a new install that doesn't have passphrases disabled -- assuming bad initialization."); + AppInitialization.onRepairFirstEverAppLaunch(this); + } else if (!TextSecurePreferences.isPasswordDisabled(this) && VersionTracker.getDaysSinceFirstInstalled(this) < 912) { + Log.i(TAG, "Detected a not-recent install that doesn't have passphrases disabled -- disabling now."); + TextSecurePreferences.setPasswordDisabled(this, true); + } + } + + private void initializeGcmCheck() { + if (TextSecurePreferences.isPushRegistered(this)) { + long nextSetTime = TextSecurePreferences.getFcmTokenLastSetTime(this) + TimeUnit.HOURS.toMillis(6); + + if (TextSecurePreferences.getFcmToken(this) == null || nextSetTime <= System.currentTimeMillis()) { + ApplicationDependencies.getJobManager().add(new FcmRefreshJob()); + } + } + } + + private void initializeSignedPreKeyCheck() { + if (!TextSecurePreferences.isSignedPreKeyRegistered(this)) { + ApplicationDependencies.getJobManager().add(new CreateSignedPreKeyJob(this)); + } + } + + private void initializeExpiringMessageManager() { + this.expiringMessageManager = new ExpiringMessageManager(this); + } + + private void initializeRevealableMessageManager() { + this.viewOnceMessageManager = new ViewOnceMessageManager(this); + } + + private void initializePeriodicTasks() { + RotateSignedPreKeyListener.schedule(this); + DirectoryRefreshListener.schedule(this); + LocalBackupListener.schedule(this); + RotateSenderCertificateListener.schedule(this); + + if (BuildConfig.PLAY_STORE_DISABLED) { + UpdateApkRefreshListener.schedule(this); + } + } + + private void initializeRingRtc() { + try { + Set HARDWARE_AEC_BLACKLIST = new HashSet() {{ + add("Pixel"); + add("Pixel XL"); + add("Moto G5"); + add("Moto G (5S) Plus"); + add("Moto G4"); + add("TA-1053"); + add("Mi A1"); + add("Mi A2"); + add("E5823"); // Sony z5 compact + add("Redmi Note 5"); + add("FP2"); // Fairphone FP2 + add("MI 5"); + }}; + + Set OPEN_SL_ES_WHITELIST = new HashSet() {{ + add("Pixel"); + add("Pixel XL"); + }}; + + if (HARDWARE_AEC_BLACKLIST.contains(Build.MODEL)) { + WebRtcAudioUtils.setWebRtcBasedAcousticEchoCanceler(true); + } + + if (!OPEN_SL_ES_WHITELIST.contains(Build.MODEL)) { + WebRtcAudioManager.setBlacklistDeviceForOpenSLESUsage(true); + } + + CallManager.initialize(this, new RingRtcLogger()); + } catch (UnsatisfiedLinkError e) { + throw new AssertionError("Unable to load ringrtc library", e); + } + } + + @WorkerThread + private void initializeCircumvention() { + if (new SignalServiceNetworkAccess(ApplicationContext.this).isCensored(ApplicationContext.this)) { + try { + ProviderInstaller.installIfNeeded(ApplicationContext.this); + } catch (Throwable t) { + Log.w(TAG, t); + } + } + } + + private void executePendingContactSync() { + if (TextSecurePreferences.needsFullContactSync(this)) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true)); + } + } + + private void initializePendingMessages() { + if (TextSecurePreferences.getNeedsMessagePull(this)) { + Log.i(TAG, "Scheduling a message fetch."); + if (Build.VERSION.SDK_INT >= 26) { + FcmJobService.schedule(this); + } else { + ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob()); + } + TextSecurePreferences.setNeedsMessagePull(this, false); + } + } + + @WorkerThread + private void initializeBlobProvider() { + BlobProvider.getInstance().initialize(this); + } + + @WorkerThread + private void initializeCleanup() { + int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments(); + Log.i(TAG, "Deleted " + deleted + " abandoned attachments."); + } + + private void initializeGlideCodecs() { + SignalGlideCodecs.setLogProvider(new org.signal.glide.Log.Provider() { + @Override + public void v(@NonNull String tag, @NonNull String message) { + Log.v(tag, message); + } + + @Override + public void d(@NonNull String tag, @NonNull String message) { + Log.d(tag, message); + } + + @Override + public void i(@NonNull String tag, @NonNull String message) { + Log.i(tag, message); + } + + @Override + public void w(@NonNull String tag, @NonNull String message) { + Log.w(tag, message); + } + + @Override + public void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable) { + Log.e(tag, message, throwable); + } + }); + } + + @Override + protected void attachBaseContext(Context base) { + DynamicLanguageContextWrapper.updateContext(base); + super.attachBaseContext(base); + } + + private static class ProviderInitializationException extends RuntimeException { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java new file mode 100644 index 00000000..bd3f19d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.PorterDuff; +import android.os.Build; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.help.HelpFragment; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment; +import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment; +import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment; +import org.thoughtcrime.securesms.preferences.BackupsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.ChatsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.CorrectedPreferenceFragment; +import org.thoughtcrime.securesms.preferences.DataAndStoragePreferenceFragment; +import org.thoughtcrime.securesms.preferences.EditProxyFragment; +import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.SmsMmsPreferenceFragment; +import org.thoughtcrime.securesms.preferences.widgets.ProfilePreference; +import org.thoughtcrime.securesms.preferences.widgets.UsernamePreference; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * The Activity for application preference display and management. + * + * @author Moxie Marlinspike + * + */ + +public class ApplicationPreferencesActivity extends PassphraseRequiredActivity + implements SharedPreferences.OnSharedPreferenceChangeListener +{ + public static final String LAUNCH_TO_BACKUPS_FRAGMENT = "launch.to.backups.fragment"; + public static final String LAUNCH_TO_HELP_FRAGMENT = "launch.to.help.fragment"; + public static final String LAUNCH_TO_PROXY_FRAGMENT = "launch.to.proxy.fragment"; + public static final String LAUNCH_TO_NOTIFICATIONS_FRAGMENT = "launch.to.notifications.fragment"; + + @SuppressWarnings("unused") + private static final String TAG = ApplicationPreferencesActivity.class.getSimpleName(); + + private static final String PREFERENCE_CATEGORY_PROFILE = "preference_category_profile"; + private static final String PREFERENCE_CATEGORY_USERNAME = "preference_category_username"; + private static final String PREFERENCE_CATEGORY_SMS_MMS = "preference_category_sms_mms"; + private static final String PREFERENCE_CATEGORY_NOTIFICATIONS = "preference_category_notifications"; + private static final String PREFERENCE_CATEGORY_APP_PROTECTION = "preference_category_app_protection"; + private static final String PREFERENCE_CATEGORY_APPEARANCE = "preference_category_appearance"; + private static final String PREFERENCE_CATEGORY_CHATS = "preference_category_chats"; + private static final String PREFERENCE_CATEGORY_STORAGE = "preference_category_storage"; + private static final String PREFERENCE_CATEGORY_DEVICES = "preference_category_devices"; + private static final String PREFERENCE_CATEGORY_HELP = "preference_category_help"; + private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced"; + private static final String PREFERENCE_CATEGORY_DONATE = "preference_category_donate"; + + private static final String WAS_CONFIGURATION_UPDATED = "was_configuration_updated"; + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private boolean wasConfigurationUpdated = false; + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + //noinspection ConstantConditions + this.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + if (getIntent() != null && getIntent().getCategories() != null && getIntent().getCategories().contains("android.intent.category.NOTIFICATION_PREFERENCES")) { + initFragment(android.R.id.content, new NotificationsPreferenceFragment()); + } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_BACKUPS_FRAGMENT, false)) { + initFragment(android.R.id.content, new BackupsPreferenceFragment()); + } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_HELP_FRAGMENT, false)) { + initFragment(android.R.id.content, new HelpFragment()); + } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_PROXY_FRAGMENT, false)) { + initFragment(android.R.id.content, EditProxyFragment.newInstance()); + } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_NOTIFICATIONS_FRAGMENT, false)) { + initFragment(android.R.id.content, new NotificationsPreferenceFragment()); + } else if (icicle == null) { + initFragment(android.R.id.content, new ApplicationPreferenceFragment()); + } else { + wasConfigurationUpdated = icicle.getBoolean(WAS_CONFIGURATION_UPDATED); + } + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + outState.putBoolean(WAS_CONFIGURATION_UPDATED, wasConfigurationUpdated); + super.onSaveInstanceState(outState); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) + { + super.onActivityResult(requestCode, resultCode, data); + Fragment fragment = getSupportFragmentManager().findFragmentById(android.R.id.content); + fragment.onActivityResult(requestCode, resultCode, data); + } + + @Override + public boolean onSupportNavigateUp() { + FragmentManager fragmentManager = getSupportFragmentManager(); + if (fragmentManager.getBackStackEntryCount() > 0) { + fragmentManager.popBackStack(); + } else { + if (wasConfigurationUpdated) { + setResult(MainActivity.RESULT_CONFIG_CHANGED); + } else { + setResult(RESULT_OK); + } + finish(); + } + return true; + } + + @Override + public void onBackPressed() { + onSupportNavigateUp(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(TextSecurePreferences.THEME_PREF)) { + DynamicTheme.setDefaultDayNightMode(this); + recreate(); + } else if (key.equals(TextSecurePreferences.LANGUAGE_PREF)) { + CachedInflater.from(this).clear(); + wasConfigurationUpdated = true; + recreate(); + + Intent intent = new Intent(this, KeyCachingService.class); + intent.setAction(KeyCachingService.LOCALE_CHANGE_EVENT); + startService(intent); + } + } + + public void pushFragment(@NonNull Fragment fragment) { + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end) + .replace(android.R.id.content, fragment) + .addToBackStack(null) + .commit(); + } + + public static class ApplicationPreferenceFragment extends CorrectedPreferenceFragment { + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + this.findPreference(PREFERENCE_CATEGORY_PROFILE) + .setOnPreferenceClickListener(new ProfileClickListener()); + this.findPreference(PREFERENCE_CATEGORY_USERNAME) + .setOnPreferenceClickListener(new UsernameClickListener()); + this.findPreference(PREFERENCE_CATEGORY_SMS_MMS) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_SMS_MMS)); + this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_NOTIFICATIONS)); + this.findPreference(PREFERENCE_CATEGORY_APP_PROTECTION) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_APP_PROTECTION)); + this.findPreference(PREFERENCE_CATEGORY_APPEARANCE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_APPEARANCE)); + this.findPreference(PREFERENCE_CATEGORY_CHATS) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_CHATS)); + this.findPreference(PREFERENCE_CATEGORY_STORAGE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_STORAGE)); + this.findPreference(PREFERENCE_CATEGORY_DEVICES) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DEVICES)); + this.findPreference(PREFERENCE_CATEGORY_HELP) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_HELP)); + this.findPreference(PREFERENCE_CATEGORY_ADVANCED) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_ADVANCED)); + this.findPreference(PREFERENCE_CATEGORY_DONATE) + .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DONATE)); + + tintIcons(); + } + + private void tintIcons() { + if (Build.VERSION.SDK_INT >= 21) return; + + Preference preference = this.findPreference(PREFERENCE_CATEGORY_SMS_MMS); + preference.getIcon().setColorFilter(ContextCompat.getColor(requireContext(), R.color.signal_icon_tint_primary), PorterDuff.Mode.SRC_IN); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences); + + if (FeatureFlags.usernames()) { + UsernamePreference pref = (UsernamePreference) findPreference(PREFERENCE_CATEGORY_USERNAME); + pref.setVisible(shouldDisplayUsernameReminder()); + pref.setOnLongClickListener(v -> { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.ApplicationPreferencesActivity_hide_reminder) + .setPositiveButton(R.string.ApplicationPreferencesActivity_hide, (dialog, which) -> { + dialog.dismiss(); + SignalStore.misc().hideUsernameReminder(); + findPreference(PREFERENCE_CATEGORY_USERNAME).setVisible(false); + }) + .setNegativeButton(android.R.string.cancel, ((dialog, which) -> dialog.dismiss())) + .setCancelable(true) + .show(); + return true; + }); + } + } + + @Override + public void onResume() { + super.onResume(); + //noinspection ConstantConditions + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.text_secure_normal__menu_settings); + setCategorySummaries(); + setCategoryVisibility(); + } + + private void setCategorySummaries() { + ((ProfilePreference)this.findPreference(PREFERENCE_CATEGORY_PROFILE)).refresh(); + + if (FeatureFlags.usernames()) { + this.findPreference(PREFERENCE_CATEGORY_USERNAME) + .setVisible(shouldDisplayUsernameReminder()); + } + + this.findPreference(PREFERENCE_CATEGORY_SMS_MMS) + .setSummary(SmsMmsPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS) + .setSummary(NotificationsPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_APP_PROTECTION) + .setSummary(AppProtectionPreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_APPEARANCE) + .setSummary(AppearancePreferenceFragment.getSummary(getActivity())); + this.findPreference(PREFERENCE_CATEGORY_CHATS) + .setSummary(ChatsPreferenceFragment.getSummary(getActivity())); + } + + private void setCategoryVisibility() { + Preference devicePreference = this.findPreference(PREFERENCE_CATEGORY_DEVICES); + if (devicePreference != null && !TextSecurePreferences.isPushRegistered(getActivity())) { + getPreferenceScreen().removePreference(devicePreference); + } + } + + private static boolean shouldDisplayUsernameReminder() { + return FeatureFlags.usernames() && !Recipient.self().getUsername().isPresent() && SignalStore.misc().shouldShowUsernameReminder(); + } + + private class CategoryClickListener implements Preference.OnPreferenceClickListener { + private String category; + + CategoryClickListener(String category) { + this.category = category; + } + + @Override + public boolean onPreferenceClick(Preference preference) { + Fragment fragment = null; + + switch (category) { + case PREFERENCE_CATEGORY_SMS_MMS: + fragment = new SmsMmsPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_NOTIFICATIONS: + fragment = new NotificationsPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_APP_PROTECTION: + fragment = new AppProtectionPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_APPEARANCE: + fragment = new AppearancePreferenceFragment(); + break; + case PREFERENCE_CATEGORY_CHATS: + fragment = new ChatsPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_STORAGE: + fragment = new DataAndStoragePreferenceFragment(); + break; + case PREFERENCE_CATEGORY_DEVICES: + Intent intent = new Intent(getActivity(), DeviceActivity.class); + startActivity(intent); + break; + case PREFERENCE_CATEGORY_ADVANCED: + fragment = new AdvancedPreferenceFragment(); + break; + case PREFERENCE_CATEGORY_HELP: + fragment = new HelpFragment(); + break; + case PREFERENCE_CATEGORY_DONATE: + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url)); + break; + default: + throw new AssertionError(); + } + + if (fragment != null) { + Bundle args = new Bundle(); + fragment.setArguments(args); + + ((ApplicationPreferencesActivity) requireActivity()).pushFragment(fragment); + } + + return true; + } + } + + private class ProfileClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + requireActivity().startActivity(ManageProfileActivity.getIntent(requireActivity())); + return true; + } + } + + private class UsernameClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + requireActivity().startActivity(ManageProfileActivity.getIntentForUsernameEdit(preference.getContext())); + return true; + } + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java new file mode 100644 index 00000000..dc72ab03 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.transition.TransitionInflater; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityOptionsCompat; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; + +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.request.transition.Transition; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FullscreenHelper; + +/** + * Activity for displaying avatars full screen. + */ +public final class AvatarPreviewActivity extends PassphraseRequiredActivity { + + private static final String TAG = Log.tag(AvatarPreviewActivity.class); + + private static final String RECIPIENT_ID_EXTRA = "recipient_id"; + + public static @NonNull Intent intentFromRecipientId(@NonNull Context context, + @NonNull RecipientId recipientId) + { + Intent intent = new Intent(context, AvatarPreviewActivity.class); + intent.putExtra(RECIPIENT_ID_EXTRA, recipientId.serialize()); + return intent; + } + + public static Bundle createTransitionBundle(@NonNull Activity activity, @NonNull View from) { + return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle(); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + + setTheme(R.style.TextSecure_MediaPreview); + setContentView(R.layout.contact_photo_preview_activity); + + if (Build.VERSION.SDK_INT >= 21) { + postponeEnterTransition(); + TransitionInflater inflater = TransitionInflater.from(this); + getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set)); + getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set)); + } + + Toolbar toolbar = findViewById(R.id.toolbar); + ImageView avatar = findViewById(R.id.avatar); + + setSupportActionBar(toolbar); + + requireSupportActionBar().setDisplayHomeAsUpEnabled(true); + + Context context = getApplicationContext(); + RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA)); + + Recipient.live(recipientId).observe(this, recipient -> { + ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar()) + : recipient.getContactPhoto(); + FallbackContactPhoto fallbackPhoto = recipient.isSelf() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large) + : recipient.getFallbackContactPhoto(); + + Resources resources = this.getResources(); + + GlideApp.with(this) + .asBitmap() + .load(contactPhoto) + .fallback(fallbackPhoto.asCallCard(this)) + .error(fallbackPhoto.asCallCard(this)) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .addListener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + Log.w(TAG, "Unable to load avatar, or avatar removed, closing"); + finish(); + return false; + } + + @Override + public boolean onResourceReady(Bitmap resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + return false; + } + }) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + avatar.setImageDrawable(RoundedBitmapDrawableFactory.create(resources, resource)); + if (Build.VERSION.SDK_INT >= 21) { + startPostponedEnterTransition(); + } + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + + toolbar.setTitle(recipient.getDisplayName(context)); + }); + + FullscreenHelper fullscreenHelper = new FullscreenHelper(this); + + findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility()); + + fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer)); + + fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout)); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java new file mode 100644 index 00000000..de9dc253 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.View; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.app.ActivityCompat; +import androidx.core.app.ActivityOptionsCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.ConfigurationUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; + +import java.util.Objects; + +/** + * Base class for all activities. The vast majority of activities shouldn't extend this directly. + * Instead, they should extend {@link PassphraseRequiredActivity} so they're protected by + * screen lock. + */ +public abstract class BaseActivity extends AppCompatActivity { + private static final String TAG = Log.tag(BaseActivity.class); + + @Override + protected void onCreate(Bundle savedInstanceState) { + AppStartup.getInstance().onCriticalRenderEventStart(); + logEvent("onCreate()"); + super.onCreate(savedInstanceState); + AppStartup.getInstance().onCriticalRenderEventEnd(); + } + + @Override + protected void onResume() { + super.onResume(); + initializeScreenshotSecurity(); + } + + @Override + protected void onStart() { + logEvent("onStart()"); + ApplicationDependencies.getShakeToReport().registerActivity(this); + super.onStart(); + } + + @Override + protected void onStop() { + logEvent("onStop()"); + super.onStop(); + } + + @Override + protected void onDestroy() { + logEvent("onDestroy()"); + super.onDestroy(); + } + + private void initializeScreenshotSecurity() { + if (TextSecurePreferences.isScreenSecurityEnabled(this)) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + } + + protected void startActivitySceneTransition(Intent intent, View sharedView, String transitionName) { + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this, sharedView, transitionName) + .toBundle(); + ActivityCompat.startActivity(this, intent, bundle); + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + super.attachBaseContext(newBase); + + Configuration configuration = new Configuration(newBase.getResources().getConfiguration()); + int appCompatNightMode = getDelegate().getLocalNightMode() != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED ? getDelegate().getLocalNightMode() + : AppCompatDelegate.getDefaultNightMode(); + + configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode); + + applyOverrideConfiguration(configuration); + } + + @Override + public void applyOverrideConfiguration(@NonNull Configuration overrideConfiguration) { + DynamicLanguageContextWrapper.prepareOverrideConfiguration(this, overrideConfiguration); + super.applyOverrideConfiguration(overrideConfiguration); + } + + private void logEvent(@NonNull String event) { + Log.d(TAG, "[" + Log.tag(getClass()) + "] " + event); + } + + public final @NonNull ActionBar requireSupportActionBar() { + return Objects.requireNonNull(getSupportActionBar()); + } + + private static int mapNightModeToConfigurationUiMode(@NonNull Context context, @AppCompatDelegate.NightMode int appCompatNightMode) { + if (appCompatNightMode == AppCompatDelegate.MODE_NIGHT_YES) { + return Configuration.UI_MODE_NIGHT_YES; + } else if (appCompatNightMode == AppCompatDelegate.MODE_NIGHT_NO) { + return Configuration.UI_MODE_NIGHT_NO; + } + return ConfigurationUtil.getNightModeConfiguration(context.getApplicationContext()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java new file mode 100644 index 00000000..17b2e835 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms; + +import android.net.Uri; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; + +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public interface BindableConversationItem extends Unbindable { + void bind(@NonNull LifecycleOwner lifecycleOwner, + @NonNull ConversationMessage messageRecord, + @NonNull Optional previousMessageRecord, + @NonNull Optional nextMessageRecord, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set batchSelected, + @NonNull Recipient recipients, + @Nullable String searchQuery, + boolean pulseMention, + boolean hasWallpaper, + boolean isMessageRequestAccepted); + + ConversationMessage getConversationMessage(); + + void setEventListener(@Nullable EventListener listener); + + interface EventListener { + void onQuoteClicked(MmsMessageRecord messageRecord); + void onLinkPreviewClicked(@NonNull LinkPreview linkPreview); + void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms); + void onStickerClicked(@NonNull StickerLocator stickerLocator); + void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord); + void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView); + void onAddToContactsClicked(@NonNull Contact contact); + void onMessageSharedContactClicked(@NonNull List choices); + void onInviteSharedContactClicked(@NonNull List choices); + void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms); + void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); + void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); + void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); + void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); + void onVoiceNotePause(@NonNull Uri uri); + void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position); + void onVoiceNoteSeekTo(@NonNull Uri uri, double position); + void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange); + void onDecryptionFailedLearnMoreClicked(); + void onSafetyNumberLearnMoreClicked(@NonNull Recipient recipient); + void onJoinGroupCallClicked(); + void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId); + + /** @return true if handled, false if you want to let the normal url handling continue */ + boolean onUrlClicked(@NonNull String url); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java new file mode 100644 index 00000000..784d45cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.Locale; +import java.util.Set; + +public interface BindableConversationListItem extends Unbindable { + + void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, boolean batchMode); + + void setBatchMode(boolean batchMode); + void updateTypingIndicator(@NonNull Set typingThreads); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java new file mode 100644 index 00000000..6d70b17a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.res.Resources; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.Lifecycle; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +/** + * This should be used whenever we want to prompt the user to block/unblock a recipient. + */ +public final class BlockUnblockDialog { + + private BlockUnblockDialog() { } + + public static void showBlockFor(@NonNull Context context, + @NonNull Lifecycle lifecycle, + @NonNull Recipient recipient, + @NonNull Runnable onBlock) + { + SimpleTask.run(lifecycle, + () -> buildBlockFor(context, recipient, onBlock, null), + AlertDialog.Builder::show); + } + + public static void showBlockAndDeleteFor(@NonNull Context context, + @NonNull Lifecycle lifecycle, + @NonNull Recipient recipient, + @NonNull Runnable onBlock, + @NonNull Runnable onBlockAndDelete) + { + SimpleTask.run(lifecycle, + () -> buildBlockFor(context, recipient, onBlock, onBlockAndDelete), + AlertDialog.Builder::show); + } + + public static void showUnblockFor(@NonNull Context context, + @NonNull Lifecycle lifecycle, + @NonNull Recipient recipient, + @NonNull Runnable onUnblock) + { + SimpleTask.run(lifecycle, + () -> buildUnblockFor(context, recipient, onUnblock), + AlertDialog.Builder::show); + } + + @WorkerThread + private static AlertDialog.Builder buildBlockFor(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull Runnable onBlock, + @Nullable Runnable onBlockAndDelete) + { + recipient = recipient.resolve(); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + Resources resources = context.getResources(); + + if (recipient.isGroup()) { + if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates); + builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } else { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_group_members_wont_be_able_to_add_you); + builder.setPositiveButton(R.string.RecipientPreferenceActivity_block, ((dialog, which) -> onBlock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } + } else { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages); + + if (onBlockAndDelete != null) { + builder.setNeutralButton(android.R.string.cancel, null); + builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_delete, (d, w) -> onBlockAndDelete.run()); + builder.setNegativeButton(R.string.BlockUnblockDialog_block, (d, w) -> onBlock.run()); + } else { + builder.setPositiveButton(R.string.BlockUnblockDialog_block, ((dialog, which) -> onBlock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } + } + + return builder; + } + + @WorkerThread + private static AlertDialog.Builder buildUnblockFor(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull Runnable onUnblock) + { + recipient = recipient.resolve(); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + Resources resources = context.getResources(); + + if (recipient.isGroup()) { + if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you); + builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } else { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you); + builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } + } else { + builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context))); + builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other); + builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run())); + builder.setNegativeButton(android.R.string.cancel, null); + } + + return builder; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java new file mode 100644 index 00000000..c668435c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms; + + +import android.app.Activity; +import android.content.Intent; +import android.view.ContextThemeWrapper; + +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public final class ClearAvatarPromptActivity extends Activity { + + private static final String ARG_TITLE = "arg_title"; + + public static Intent createForUserProfilePhoto() { + Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class); + intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo); + return intent; + } + + public static Intent createForGroupProfilePhoto() { + Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class); + intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_group_photo); + return intent; + } + + @Override + public void onResume() { + super.onResume(); + + int message = getIntent().getIntExtra(ARG_TITLE, 0); + + new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme)) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> finish()) + .setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> { + Intent result = new Intent(); + result.putExtra("delete", true); + setResult(Activity.RESULT_OK, result); + finish(); + }) + .setOnCancelListener(dialog -> finish()) + .show(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java new file mode 100644 index 00000000..f35e2200 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java @@ -0,0 +1,176 @@ +package org.thoughtcrime.securesms; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.VerifySpan; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.io.IOException; + +public class ConfirmIdentityDialog extends AlertDialog { + + @SuppressWarnings("unused") + private static final String TAG = ConfirmIdentityDialog.class.getSimpleName(); + + private OnClickListener callback; + + public ConfirmIdentityDialog(Context context, + MessageRecord messageRecord, + IdentityKeyMismatch mismatch) + { + super(context); + + Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context)); + String name = recipient.getDisplayName(context); + String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name); + SpannableString spannableString = new SpannableString(introduction + " " + + context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact)); + + spannableString.setSpan(new VerifySpan(context, mismatch), + introduction.length()+1, spannableString.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + setTitle(name); + setMessage(spannableString); + + setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId())); + setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener()); + } + + @Override + public void show() { + super.show(); + ((TextView)this.findViewById(android.R.id.message)) + .setMovementMethod(LinkMovementMethod.getInstance()); + } + + public void setCallback(OnClickListener callback) { + this.callback = callback; + } + + private class AcceptListener implements OnClickListener { + + private final MessageRecord messageRecord; + private final IdentityKeyMismatch mismatch; + private final RecipientId recipientId; + + private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) { + this.messageRecord = messageRecord; + this.mismatch = mismatch; + this.recipientId = recipientId; + } + + @SuppressLint("StaticFieldLeak") + @Override + public void onClick(DialogInterface dialog, int which) { + new AsyncTask() + { + @Override + protected Void doInBackground(Void... params) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1); + TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext()); + + identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true); + } + + processMessageRecord(messageRecord); + + return null; + } + + private void processMessageRecord(MessageRecord messageRecord) { + if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord); + else processIncomingMessageRecord(messageRecord); + } + + private void processOutgoingMessageRecord(MessageRecord messageRecord) { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext()); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext()); + + if (messageRecord.isMms()) { + mmsDatabase.removeMismatchedIdentity(messageRecord.getId(), + mismatch.getRecipientId(getContext()), + mismatch.getIdentityKey()); + + if (messageRecord.getRecipient().isPushGroup()) { + MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId()); + } else { + MessageSender.resend(getContext(), messageRecord); + } + } else { + smsDatabase.removeMismatchedIdentity(messageRecord.getId(), + mismatch.getRecipientId(getContext()), + mismatch.getIdentityKey()); + + MessageSender.resend(getContext(), messageRecord); + } + } + + private void processIncomingMessageRecord(MessageRecord messageRecord) { + try { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext()); + + smsDatabase.removeMismatchedIdentity(messageRecord.getId(), + mismatch.getRecipientId(getContext()), + mismatch.getIdentityKey()); + + boolean legacy = !messageRecord.isContentBundleKeyExchange(); + + SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE, + Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())), + messageRecord.getRecipientDeviceId(), + messageRecord.getDateSent(), + legacy ? Base64.decode(messageRecord.getBody()) : null, + !legacy ? Base64.decode(messageRecord.getBody()) : null, + 0, + 0, + null); + + ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId())); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + if (callback != null) callback.onClick(null, 0); + } + } + + private class CancelListener implements OnClickListener { + @Override + public void onClick(DialogInterface dialog, int which) { + if (callback != null) callback.onClick(null, 0); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java new file mode 100644 index 00000000..b7062343 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.ContactFilterToolbar; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.lang.ref.WeakReference; + +/** + * Base activity container for selecting a list of contacts. + * + * @author Moxie Marlinspike + * + */ +public abstract class ContactSelectionActivity extends PassphraseRequiredActivity + implements SwipeRefreshLayout.OnRefreshListener, + ContactSelectionListFragment.OnContactSelectedListener, + ContactSelectionListFragment.ScrollCallback +{ + private static final String TAG = ContactSelectionActivity.class.getSimpleName(); + + public static final String EXTRA_LAYOUT_RES_ID = "layout_res_id"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + protected ContactSelectionListFragment contactsFragment; + + private ContactFilterToolbar toolbar; + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { + int displayMode = TextSecurePreferences.isSmsEnabled(this) ? DisplayMode.FLAG_ALL + : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF; + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); + } + + setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity)); + + initializeToolbar(); + initializeResources(); + initializeSearch(); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + protected ContactFilterToolbar getToolbar() { + return toolbar; + } + + private void initializeToolbar() { + this.toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + getSupportActionBar().setDisplayShowTitleEnabled(false); + getSupportActionBar().setIcon(null); + getSupportActionBar().setLogo(null); + } + + private void initializeResources() { + contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + contactsFragment.setOnRefreshListener(this); + } + + private void initializeSearch() { + toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter)); + } + + @Override + public void onRefresh() { + new RefreshDirectoryTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, getApplicationContext()); + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + return true; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) {} + + @Override + public void onBeginScroll() { + hideKeyboard(); + } + + private void hideKeyboard() { + ServiceUtil.getInputMethodManager(this) + .hideSoftInputFromWindow(toolbar.getWindowToken(), 0); + toolbar.clearFocus(); + } + + private static class RefreshDirectoryTask extends AsyncTask { + + private final WeakReference activity; + + private RefreshDirectoryTask(ContactSelectionActivity activity) { + this.activity = new WeakReference<>(activity); + } + + @Override + protected Void doInBackground(Context... params) { + try { + DirectoryHelper.refreshDirectory(params[0], true); + } catch (IOException e) { + Log.w(TAG, e); + } + + return null; + } + + @Override + protected void onPostExecute(Void result) { + ContactSelectionActivity activity = this.activity.get(); + + if (activity != null && !activity.isFinishing()) { + activity.toolbar.clear(); + activity.contactsFragment.resetQueryFilter(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java new file mode 100644 index 00000000..8e394aef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -0,0 +1,699 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + + +import android.Manifest; +import android.animation.LayoutTransition; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.HorizontalScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.fragment.app.FragmentActivity; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.transition.AutoTransition; +import androidx.transition.TransitionManager; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.android.material.chip.ChipGroup; +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.RecyclerViewFastScroller; +import org.thoughtcrime.securesms.components.emoji.WarningTextView; +import org.thoughtcrime.securesms.contacts.ContactChip; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; +import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.UsernameUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter; +import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Fragment for selecting a one or more contacts from a list. + * + * @author Moxie Marlinspike + * + */ +public final class ContactSelectionListFragment extends LoggingFragment + implements LoaderManager.LoaderCallbacks +{ + @SuppressWarnings("unused") + private static final String TAG = Log.tag(ContactSelectionListFragment.class); + + private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1; + private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150; + + public static final int NO_LIMIT = Integer.MAX_VALUE; + + public static final String DISPLAY_MODE = "display_mode"; + public static final String REFRESHABLE = "refreshable"; + public static final String RECENTS = "recents"; + public static final String SELECTION_LIMITS = "selection_limits"; + public static final String CURRENT_SELECTION = "current_selection"; + public static final String HIDE_COUNT = "hide_count"; + public static final String CAN_SELECT_SELF = "can_select_self"; + public static final String DISPLAY_CHIPS = "display_chips"; + + private ConstraintLayout constraintLayout; + private TextView emptyText; + private OnContactSelectedListener onContactSelectedListener; + private SwipeRefreshLayout swipeRefresh; + private View showContactsLayout; + private Button showContactsButton; + private TextView showContactsDescription; + private ProgressWheel showContactsProgress; + private String cursorFilter; + private RecyclerView recyclerView; + private RecyclerViewFastScroller fastScroller; + private ContactSelectionListAdapter cursorRecyclerViewAdapter; + private ChipGroup chipGroup; + private HorizontalScrollView chipGroupScrollContainer; + private WarningTextView groupLimit; + private OnSelectionLimitReachedListener onSelectionLimitReachedListener; + + + @Nullable private FixedViewsAdapter headerAdapter; + @Nullable private FixedViewsAdapter footerAdapter; + @Nullable private ListCallback listCallback; + @Nullable private ScrollCallback scrollCallback; + private GlideRequests glideRequests; + private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS; + private Set currentSelection; + private boolean isMulti; + private boolean hideCount; + private boolean canSelectSelf; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof ListCallback) { + listCallback = (ListCallback) context; + } + + if (context instanceof ScrollCallback) { + scrollCallback = (ScrollCallback) context; + } + + if (context instanceof OnContactSelectedListener) { + onContactSelectedListener = (OnContactSelectedListener) context; + } + + if (context instanceof OnSelectionLimitReachedListener) { + onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context; + } + } + + @Override + public void onActivityCreated(Bundle icicle) { + super.onActivityCreated(icicle); + + initializeCursor(); + } + + @Override + public void onStart() { + super.onStart(); + + Permissions.with(this) + .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) + .ifNecessary() + .onAllGranted(() -> { + if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { + handleContactPermissionGranted(); + } else { + LoaderManager.getInstance(this).initLoader(0, null, this); + } + }) + .onAnyDenied(() -> { + FragmentActivity activity = requireActivity(); + + activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + + if (activity.getIntent().getBooleanExtra(RECENTS, false)) { + LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this); + } else { + initializeNoContactsPermission(); + } + }) + .execute(); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); + + emptyText = view.findViewById(android.R.id.empty); + recyclerView = view.findViewById(R.id.recycler_view); + swipeRefresh = view.findViewById(R.id.swipe_refresh); + fastScroller = view.findViewById(R.id.fast_scroller); + showContactsLayout = view.findViewById(R.id.show_contacts_container); + showContactsButton = view.findViewById(R.id.show_contacts_button); + showContactsDescription = view.findViewById(R.id.show_contacts_description); + showContactsProgress = view.findViewById(R.id.progress); + chipGroup = view.findViewById(R.id.chipGroup); + chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer); + groupLimit = view.findViewById(R.id.group_limit); + constraintLayout = view.findViewById(R.id.container); + + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + recyclerView.setItemAnimator(new DefaultItemAnimator() { + @Override + public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { + return true; + } + }); + + Intent intent = requireActivity().getIntent(); + + swipeRefresh.setEnabled(intent.getBooleanExtra(REFRESHABLE, true)); + + hideCount = intent.getBooleanExtra(HIDE_COUNT, false); + selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS); + isMulti = selectionLimit != null; + canSelectSelf = intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti); + + if (!isMulti) { + selectionLimit = SelectionLimits.NO_LIMITS; + } + + currentSelection = getCurrentSelection(); + + updateGroupLimit(getChipCount()); + + return view; + } + + private void updateGroupLimit(int chipCount) { + int members = currentSelection.size() + chipCount; + groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members)); + groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE); + groupLimit.setWarning(selectionWarningLimitExceeded()); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + public @NonNull List getSelectedContacts() { + if (cursorRecyclerViewAdapter == null) { + return Collections.emptyList(); + } + + return cursorRecyclerViewAdapter.getSelectedContacts(); + } + + public int getSelectedContactsCount() { + if (cursorRecyclerViewAdapter == null) { + return 0; + } + + return cursorRecyclerViewAdapter.getSelectedContactsCount(); + } + + private Set getCurrentSelection() { + List currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION); + + return currentSelection == null ? Collections.emptySet() + : Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet())); + } + + public boolean isMulti() { + return isMulti; + } + + private void initializeCursor() { + glideRequests = GlideApp.with(this); + + cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(), + glideRequests, + null, + new ListClickListener(), + isMulti, + currentSelection); + + RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader(); + + if (listCallback != null) { + headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback)); + headerAdapter.hide(); + concatenateAdapter.addAdapter(headerAdapter); + } + + concatenateAdapter.addAdapter(cursorRecyclerViewAdapter); + + if (listCallback != null) { + footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback)); + footerAdapter.hide(); + concatenateAdapter.addAdapter(footerAdapter); + } + + recyclerView.setAdapter(concatenateAdapter); + recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0)); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + if (scrollCallback != null) { + scrollCallback.onBeginScroll(); + } + } + } + }); + } + + private View createInviteActionView(@NonNull ListCallback listCallback) { + View view = LayoutInflater.from(requireContext()) + .inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false); + view.setOnClickListener(v -> listCallback.onInvite()); + return view; + } + + private View createNewGroupItem(@NonNull ListCallback listCallback) { + View view = LayoutInflater.from(requireContext()) + .inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false); + view.setOnClickListener(v -> listCallback.onNewGroup(false)); + return view; + } + + private void initializeNoContactsPermission() { + swipeRefresh.setVisibility(View.GONE); + + showContactsLayout.setVisibility(View.VISIBLE); + showContactsProgress.setVisibility(View.INVISIBLE); + showContactsDescription.setText(R.string.contact_selection_list_fragment__signal_needs_access_to_your_contacts_in_order_to_display_them); + showContactsButton.setVisibility(View.VISIBLE); + + showContactsButton.setOnClickListener(v -> { + Permissions.with(this) + .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts)) + .onSomeGranted(permissions -> { + if (permissions.contains(Manifest.permission.WRITE_CONTACTS)) { + handleContactPermissionGranted(); + } + }) + .execute(); + }); + } + + public void setQueryFilter(String filter) { + this.cursorFilter = filter; + LoaderManager.getInstance(this).restartLoader(0, null, this); + } + + public void resetQueryFilter() { + setQueryFilter(null); + swipeRefresh.setRefreshing(false); + } + + public boolean hasQueryFilter() { + return !TextUtils.isEmpty(cursorFilter); + } + + public void setRefreshing(boolean refreshing) { + swipeRefresh.setRefreshing(refreshing); + } + + public void reset() { + cursorRecyclerViewAdapter.clearSelectedContacts(); + + if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) { + LoaderManager.getInstance(this).restartLoader(0, null, this); + } + } + + @Override + public @NonNull Loader onCreateLoader(int id, Bundle args) { + FragmentActivity activity = requireActivity(); + return new ContactsCursorLoader(activity, + activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL), + cursorFilter, activity.getIntent().getBooleanExtra(RECENTS, false)); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, @Nullable Cursor data) { + swipeRefresh.setVisibility(View.VISIBLE); + showContactsLayout.setVisibility(View.GONE); + + cursorRecyclerViewAdapter.changeCursor(data); + + if (footerAdapter != null) { + footerAdapter.show(); + } + + if (headerAdapter != null) { + if (TextUtils.isEmpty(cursorFilter)) { + headerAdapter.show(); + } else { + headerAdapter.hide(); + } + } + + emptyText.setText(R.string.contact_selection_group_activity__no_contacts); + boolean useFastScroller = data != null && data.getCount() > 20; + recyclerView.setVerticalScrollBarEnabled(!useFastScroller); + if (useFastScroller) { + fastScroller.setVisibility(View.VISIBLE); + fastScroller.setRecyclerView(recyclerView); + } else { + fastScroller.setRecyclerView(null); + fastScroller.setVisibility(View.GONE); + } + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + cursorRecyclerViewAdapter.changeCursor(null); + fastScroller.setVisibility(View.GONE); + } + + @SuppressLint("StaticFieldLeak") + private void handleContactPermissionGranted() { + final Context context = requireContext(); + + new AsyncTask() { + @Override + protected void onPreExecute() { + swipeRefresh.setVisibility(View.GONE); + showContactsLayout.setVisibility(View.VISIBLE); + showContactsButton.setVisibility(View.INVISIBLE); + showContactsDescription.setText(R.string.ConversationListFragment_loading); + showContactsProgress.setVisibility(View.VISIBLE); + showContactsProgress.spin(); + } + + @Override + protected Boolean doInBackground(Void... voids) { + try { + DirectoryHelper.refreshDirectory(context, false); + return true; + } catch (IOException e) { + Log.w(TAG, e); + } + return false; + } + + @Override + protected void onPostExecute(Boolean result) { + if (result) { + showContactsLayout.setVisibility(View.GONE); + swipeRefresh.setVisibility(View.VISIBLE); + reset(); + } else { + Context context = getContext(); + if (context != null) { + Toast.makeText(getContext(), R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection, Toast.LENGTH_LONG).show(); + initializeNoContactsPermission(); + } + } + } + }.execute(); + } + + private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener { + @Override + public void onItemClick(ContactSelectionListItem contact) { + SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber()) + : SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber()); + + if (!canSelectSelf && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) { + Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show(); + return; + } + + if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) { + if (selectionHardLimitReached()) { + if (onSelectionLimitReachedListener != null) { + onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit()); + } else { + GroupLimitDialog.showHardLimitMessage(requireContext()); + } + return; + } + + if (contact.isUsernameType()) { + AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext()); + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + return UsernameUtil.fetchUuidForUsername(requireContext(), contact.getNumber()); + }, uuid -> { + loadingDialog.dismiss(); + if (uuid.isPresent()) { + Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber()); + SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber()); + + if (onContactSelectedListener != null) { + if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) { + markContactSelected(selected); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + } + } else { + markContactSelected(selected); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + } + } else { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.ContactSelectionListFragment_username_not_found) + .setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber())) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .show(); + } + }); + } else { + if (onContactSelectedListener != null) { + if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) { + markContactSelected(selectedContact); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + } + } else { + markContactSelected(selectedContact); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + } + } + } else { + markContactUnselected(selectedContact); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + + if (onContactSelectedListener != null) { + onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber()); + } + } + } + } + + private boolean selectionHardLimitReached() { + return getChipCount() + currentSelection.size() >= selectionLimit.getHardLimit(); + } + + private boolean selectionWarningLimitReachedExactly() { + return getChipCount() + currentSelection.size() == selectionLimit.getRecommendedLimit(); + } + + private boolean selectionWarningLimitExceeded() { + return getChipCount() + currentSelection.size() > selectionLimit.getRecommendedLimit(); + } + + private void markContactSelected(@NonNull SelectedContact selectedContact) { + cursorRecyclerViewAdapter.addSelectedContact(selectedContact); + if (isMulti) { + addChipForSelectedContact(selectedContact); + } + } + + private void markContactUnselected(@NonNull SelectedContact selectedContact) { + cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + removeChipForContact(selectedContact); + } + + private void removeChipForContact(@NonNull SelectedContact contact) { + for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) { + View v = chipGroup.getChildAt(i); + if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) { + chipGroup.removeView(v); + } + } + + updateGroupLimit(getChipCount()); + + if (getChipCount() == 0) { + setChipGroupVisibility(ConstraintSet.GONE); + } + } + + private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), + () -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())), + resolved -> addChipForRecipient(resolved, selectedContact)); + } + + private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) { + final ContactChip chip = new ContactChip(requireContext()); + + if (getChipCount() == 0) { + setChipGroupVisibility(ConstraintSet.VISIBLE); + } + + chip.setText(recipient.getShortDisplayName(requireContext())); + chip.setContact(selectedContact); + chip.setCloseIconVisible(true); + chip.setOnCloseIconClickListener(view -> { + markContactUnselected(selectedContact); + + if (onContactSelectedListener != null) { + onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orNull()); + } + }); + + chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() { + @Override + public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) { + } + + @Override + public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) { + if (view == chip && transitionType == LayoutTransition.APPEARING) { + chipGroup.getLayoutTransition().removeTransitionListener(this); + registerChipRecipientObserver(chip, recipient.live()); + chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd); + } + } + }); + + chip.setAvatar(glideRequests, recipient, () -> addChip(chip)); + } + + private void addChip(@NonNull ContactChip chip) { + chipGroup.addView(chip); + updateGroupLimit(getChipCount()); + if (selectionWarningLimitReachedExactly()) { + if (onSelectionLimitReachedListener != null) { + onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit()); + } else { + GroupLimitDialog.showRecommendedLimitMessage(requireContext()); + } + } + } + + private int getChipCount() { + int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT; + if (count < 0) throw new AssertionError(); + return count; + } + + private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) { + if (recipient != null) { + recipient.observe(getViewLifecycleOwner(), resolved -> { + if (chip.isAttachedToWindow()) { + chip.setAvatar(glideRequests, resolved, null); + chip.setText(resolved.getShortDisplayName(chip.getContext())); + } + }); + } + } + + private void setChipGroupVisibility(int visibility) { + if (!requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true)) { + return; + } + + TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS)); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(constraintLayout); + constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility); + constraintSet.applyTo(constraintLayout); + } + + public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) { + this.swipeRefresh.setOnRefreshListener(onRefreshListener); + } + + private void smoothScrollChipsToEnd() { + int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0; + chipGroupScrollContainer.smoothScrollTo(x, 0); + } + + public interface OnContactSelectedListener { + /** @return True if the contact is allowed to be selected, otherwise false. */ + boolean onBeforeContactSelected(Optional recipientId, String number); + void onContactDeselected(Optional recipientId, String number); + } + + public interface OnSelectionLimitReachedListener { + void onSuggestedLimitReached(int limit); + void onHardLimitReached(int limit); + } + + public interface ListCallback { + void onInvite(); + void onNewGroup(boolean forceV1); + } + + public interface ScrollCallback { + void onBeginScroll(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java new file mode 100644 index 00000000..06cd92f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java @@ -0,0 +1,201 @@ +package org.thoughtcrime.securesms; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Parcelable; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.thoughtcrime.securesms.database.SmsMigrator.ProgressDescription; +import org.thoughtcrime.securesms.service.ApplicationMigrationService; +import org.thoughtcrime.securesms.service.ApplicationMigrationService.ImportState; + +public class DatabaseMigrationActivity extends PassphraseRequiredActivity { + + private final ImportServiceConnection serviceConnection = new ImportServiceConnection(); + private final ImportStateHandler importStateHandler = new ImportStateHandler(); + private final BroadcastReceiver completedReceiver = new NullReceiver(); + + private LinearLayout promptLayout; + private LinearLayout progressLayout; + private Button skipButton; + private Button importButton; + private ProgressBar progress; + private TextView progressLabel; + + private ApplicationMigrationService importService; + private boolean isVisible = false; + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.database_migration_activity); + + initializeResources(); + initializeServiceBinding(); + } + + @Override + public void onResume() { + super.onResume(); + isVisible = true; + registerForCompletedNotification(); + } + + @Override + public void onPause() { + super.onPause(); + isVisible = false; + unregisterForCompletedNotification(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + shutdownServiceBinding(); + } + + @Override + public void onBackPressed() { + + } + + private void initializeServiceBinding() { + Intent intent = new Intent(this, ApplicationMigrationService.class); + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + } + + private void initializeResources() { + this.promptLayout = (LinearLayout)findViewById(R.id.prompt_layout); + this.progressLayout = (LinearLayout)findViewById(R.id.progress_layout); + this.skipButton = (Button) findViewById(R.id.skip_button); + this.importButton = (Button) findViewById(R.id.import_button); + this.progress = (ProgressBar) findViewById(R.id.import_progress); + this.progressLabel = (TextView) findViewById(R.id.import_status); + + this.progressLayout.setVisibility(View.GONE); + this.promptLayout.setVisibility(View.GONE); + + this.importButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(DatabaseMigrationActivity.this, ApplicationMigrationService.class); + intent.setAction(ApplicationMigrationService.MIGRATE_DATABASE); + intent.putExtra("master_secret", (Parcelable)getIntent().getParcelableExtra("master_secret")); + startService(intent); + + promptLayout.setVisibility(View.GONE); + progressLayout.setVisibility(View.VISIBLE); + } + }); + + this.skipButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ApplicationMigrationService.setDatabaseImported(DatabaseMigrationActivity.this); + handleImportComplete(); + } + }); + } + + private void registerForCompletedNotification() { + IntentFilter filter = new IntentFilter(); + filter.addAction(ApplicationMigrationService.COMPLETED_ACTION); + filter.setPriority(1000); + + registerReceiver(completedReceiver, filter); + } + + private void unregisterForCompletedNotification() { + unregisterReceiver(completedReceiver); + } + + private void shutdownServiceBinding() { + unbindService(serviceConnection); + } + + private void handleStateIdle() { + this.promptLayout.setVisibility(View.VISIBLE); + this.progressLayout.setVisibility(View.GONE); + } + + private void handleStateProgress(ProgressDescription update) { + this.promptLayout.setVisibility(View.GONE); + this.progressLayout.setVisibility(View.VISIBLE); + this.progressLabel.setText(update.primaryComplete + "/" + update.primaryTotal); + + double max = this.progress.getMax(); + double primaryTotal = update.primaryTotal; + double primaryComplete = update.primaryComplete; + double secondaryTotal = update.secondaryTotal; + double secondaryComplete = update.secondaryComplete; + + this.progress.setProgress((int)Math.round((primaryComplete / primaryTotal) * max)); + this.progress.setSecondaryProgress((int)Math.round((secondaryComplete / secondaryTotal) * max)); + } + + private void handleImportComplete() { + if (isVisible) { + if (getIntent().hasExtra("next_intent")) { + startActivity((Intent)getIntent().getParcelableExtra("next_intent")); + } else { + // TODO [greyson] Navigation + startActivity(MainActivity.clearTop(this)); + } + } + + finish(); + } + + private class ImportStateHandler extends Handler { + + public ImportStateHandler() { + super(Looper.getMainLooper()); + } + + @Override + public void handleMessage(Message message) { + switch (message.what) { + case ImportState.STATE_IDLE: handleStateIdle(); break; + case ImportState.STATE_MIGRATING_IN_PROGRESS: handleStateProgress((ProgressDescription)message.obj); break; + case ImportState.STATE_MIGRATING_COMPLETE: handleImportComplete(); break; + } + } + } + + private class ImportServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + importService = ((ApplicationMigrationService.ApplicationMigrationBinder)service).getService(); + importService.setImportStateHandler(importStateHandler); + + ImportState state = importService.getState(); + importStateHandler.obtainMessage(state.state, state.progress).sendToTarget(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + importService.setImportStateHandler(null); + } + } + + private static class NullReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + abortBroadcast(); + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java new file mode 100644 index 00000000..28ef89f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -0,0 +1,250 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Vibrator; +import android.text.TextUtils; +import android.transition.TransitionInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.qr.ScanListener; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException; + +import java.io.IOException; + +public class DeviceActivity extends PassphraseRequiredActivity + implements Button.OnClickListener, ScanListener, DeviceLinkFragment.LinkClickedListener +{ + + private static final String TAG = DeviceActivity.class.getSimpleName(); + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private DeviceAddFragment deviceAddFragment; + private DeviceListFragment deviceListFragment; + private DeviceLinkFragment deviceLinkFragment; + + @Override + public void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + getSupportActionBar().setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_left_24)); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.AndroidManifest__linked_devices); + this.deviceAddFragment = new DeviceAddFragment(); + this.deviceListFragment = new DeviceListFragment(); + this.deviceLinkFragment = new DeviceLinkFragment(); + + this.deviceListFragment.setAddDeviceButtonListener(this); + this.deviceAddFragment.setScanListener(this); + + if (getIntent().getBooleanExtra("add", false)) { + initFragment(android.R.id.content, deviceAddFragment, dynamicLanguage.getCurrentLocale()); + } else { + initFragment(android.R.id.content, deviceListFragment, dynamicLanguage.getCurrentLocale()); + } + + overridePendingTransition(R.anim.slide_from_end, R.anim.slide_to_start); + } + + @Override + protected void onPause() { + if (isFinishing()) { + overridePendingTransition(R.anim.slide_from_start, R.anim.slide_to_end); + } + super.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + + return false; + } + + @Override + public void onClick(View v) { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.DeviceActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code)) + .onAllGranted(() -> { + getSupportFragmentManager().beginTransaction() + .replace(android.R.id.content, deviceAddFragment) + .addToBackStack(null) + .commitAllowingStateLoss(); + }) + .onAnyDenied(() -> Toast.makeText(this, R.string.DeviceActivity_unable_to_scan_a_qr_code_without_the_camera_permission, Toast.LENGTH_LONG).show()) + .execute(); + } + + @Override + public void onQrDataFound(final String data) { + Util.runOnMain(() -> { + ((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50); + Uri uri = Uri.parse(data); + deviceLinkFragment.setLinkClickedListener(uri, DeviceActivity.this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared)); + deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade)); + + deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared)); + deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade)); + + getSupportFragmentManager().beginTransaction() + .addToBackStack(null) + .addSharedElement(deviceAddFragment.getDevicesImage(), "devices") + .replace(android.R.id.content, deviceLinkFragment) + .commit(); + + } else { + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_from_bottom, R.anim.slide_to_bottom, + R.anim.slide_from_bottom, R.anim.slide_to_bottom) + .replace(android.R.id.content, deviceLinkFragment) + .addToBackStack(null) + .commit(); + } + }); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @SuppressLint("StaticFieldLeak") + @Override + public void onLink(final Uri uri) { + new ProgressDialogAsyncTask(this, + R.string.DeviceProvisioningActivity_content_progress_title, + R.string.DeviceProvisioningActivity_content_progress_content) + { + private static final int SUCCESS = 0; + private static final int NO_DEVICE = 1; + private static final int NETWORK_ERROR = 2; + private static final int KEY_ERROR = 3; + private static final int LIMIT_EXCEEDED = 4; + private static final int BAD_CODE = 5; + + @Override + protected Integer doInBackground(Void... params) { + boolean isMultiDevice = TextSecurePreferences.isMultiDevice(DeviceActivity.this); + + try { + Context context = DeviceActivity.this; + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + String verificationCode = accountManager.getNewDeviceVerificationCode(); + String ephemeralId = uri.getQueryParameter("uuid"); + String publicKeyEncoded = uri.getQueryParameter("pub_key"); + + if (TextUtils.isEmpty(ephemeralId) || TextUtils.isEmpty(publicKeyEncoded)) { + Log.w(TAG, "UUID or Key is empty!"); + return BAD_CODE; + } + + ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0); + IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(context); + Optional profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext())); + + TextSecurePreferences.setMultiDevice(DeviceActivity.this, true); + TextSecurePreferences.setIsUnidentifiedDeliveryEnabled(context, false); + accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode); + + return SUCCESS; + } catch (NotFoundException e) { + Log.w(TAG, e); + TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice); + return NO_DEVICE; + } catch (DeviceLimitExceededException e) { + Log.w(TAG, e); + TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice); + return LIMIT_EXCEEDED; + } catch (IOException e) { + Log.w(TAG, e); + TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice); + return NETWORK_ERROR; + } catch (InvalidKeyException e) { + Log.w(TAG, e); + TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice); + return KEY_ERROR; + } + } + + @Override + protected void onPostExecute(Integer result) { + super.onPostExecute(result); + + Context context = DeviceActivity.this; + + switch (result) { + case SUCCESS: + Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_success, Toast.LENGTH_SHORT).show(); + finish(); + return; + case NO_DEVICE: + Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_no_device, Toast.LENGTH_LONG).show(); + break; + case NETWORK_ERROR: + Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_network_error, Toast.LENGTH_LONG).show(); + break; + case KEY_ERROR: + Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_key_error, Toast.LENGTH_LONG).show(); + break; + case LIMIT_EXCEEDED: + Toast.makeText(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already, Toast.LENGTH_LONG).show(); + break; + case BAD_CODE: + Toast.makeText(context, R.string.DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code, Toast.LENGTH_LONG).show(); + break; + } + + getSupportFragmentManager().popBackStackImmediate(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java new file mode 100644 index 00000000..cd446bc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms; + +import android.animation.Animator; +import android.annotation.TargetApi; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.components.camera.CameraView; +import org.thoughtcrime.securesms.qr.ScanListener; +import org.thoughtcrime.securesms.qr.ScanningThread; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class DeviceAddFragment extends LoggingFragment { + + private ViewGroup container; + private LinearLayout overlay; + private ImageView devicesImage; + private CameraView scannerView; + private ScanningThread scanningThread; + private ScanListener scanListener; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment); + this.overlay = this.container.findViewById(R.id.overlay); + this.scannerView = this.container.findViewById(R.id.scanner); + this.devicesImage = this.container.findViewById(R.id.devices); + + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + this.overlay.setOrientation(LinearLayout.HORIZONTAL); + } else { + this.overlay.setOrientation(LinearLayout.VERTICAL); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) + { + v.removeOnLayoutChangeListener(this); + + Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom)); + reveal.setInterpolator(new DecelerateInterpolator(2f)); + reveal.setDuration(800); + reveal.start(); + } + }); + } + + return this.container; + } + + @Override + public void onResume() { + super.onResume(); + this.scanningThread = new ScanningThread(); + this.scanningThread.setScanListener(scanListener); + this.scannerView.onResume(); + this.scannerView.setPreviewCallback(scanningThread); + this.scanningThread.start(); + } + + @Override + public void onPause() { + super.onPause(); + this.scannerView.onPause(); + this.scanningThread.stopScanning(); + } + + @Override + public void onConfigurationChanged(Configuration newConfiguration) { + super.onConfigurationChanged(newConfiguration); + + this.scannerView.onPause(); + + if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + overlay.setOrientation(LinearLayout.HORIZONTAL); + } else { + overlay.setOrientation(LinearLayout.VERTICAL); + } + + this.scannerView.onResume(); + this.scannerView.setPreviewCallback(scanningThread); + } + + + public ImageView getDevicesImage() { + return devicesImage; + } + + public void setScanListener(ScanListener scanListener) { + this.scanListener = scanListener; + + if (this.scanningThread != null) { + this.scanningThread.setScanListener(scanListener); + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java new file mode 100644 index 00000000..1a3b8a9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms; + +import android.content.res.Configuration; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +public class DeviceLinkFragment extends Fragment implements View.OnClickListener { + + private LinearLayout container; + private LinkClickedListener linkClickedListener; + private Uri uri; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false); + this.container.findViewById(R.id.link_device).setOnClickListener(this); + + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + container.setOrientation(LinearLayout.HORIZONTAL); + } else { + container.setOrientation(LinearLayout.VERTICAL); + } + + return this.container; + } + + @Override + public void onConfigurationChanged(Configuration newConfiguration) { + super.onConfigurationChanged(newConfiguration); + if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + container.setOrientation(LinearLayout.HORIZONTAL); + } else { + container.setOrientation(LinearLayout.VERTICAL); + } + } + + public void setLinkClickedListener(Uri uri, LinkClickedListener linkClickedListener) { + this.uri = uri; + this.linkClickedListener = linkClickedListener; + } + + @Override + public void onClick(View v) { + if (linkClickedListener != null) { + linkClickedListener.onLink(uri); + } + } + + public interface LinkClickedListener { + void onLink(Uri uri); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java new file mode 100644 index 00000000..740bf8d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java @@ -0,0 +1,220 @@ +package org.thoughtcrime.securesms; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.ListFragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.melnykov.fab.FloatingActionButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.loaders.DeviceListLoader; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.devicelist.Device; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +public class DeviceListFragment extends ListFragment + implements LoaderManager.LoaderCallbacks>, + ListView.OnItemClickListener, Button.OnClickListener +{ + + private static final String TAG = DeviceListFragment.class.getSimpleName(); + + private SignalServiceAccountManager accountManager; + private Locale locale; + private View empty; + private View progressContainer; + private FloatingActionButton addDeviceButton; + private Button.OnClickListener addDeviceButtonListener; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.locale = (Locale) getArguments().getSerializable(PassphraseRequiredActivity.LOCALE_EXTRA); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + View view = inflater.inflate(R.layout.device_list_fragment, container, false); + + this.empty = view.findViewById(R.id.empty); + this.progressContainer = view.findViewById(R.id.progress_container); + this.addDeviceButton = view.findViewById(R.id.add_device); + this.addDeviceButton.setOnClickListener(this); + + return view; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + getLoaderManager().initLoader(0, null, this); + getListView().setOnItemClickListener(this); + } + + public void setAddDeviceButtonListener(Button.OnClickListener listener) { + this.addDeviceButtonListener = listener; + } + + @Override + public @NonNull Loader> onCreateLoader(int id, Bundle args) { + empty.setVisibility(View.GONE); + progressContainer.setVisibility(View.VISIBLE); + + return new DeviceListLoader(getActivity(), accountManager); + } + + @Override + public void onLoadFinished(@NonNull Loader> loader, List data) { + progressContainer.setVisibility(View.GONE); + + if (data == null) { + handleLoaderFailed(); + return; + } + + setListAdapter(new DeviceListAdapter(getActivity(), R.layout.device_list_item_view, data, locale)); + + if (data.isEmpty()) { + empty.setVisibility(View.VISIBLE); + TextSecurePreferences.setMultiDevice(getActivity(), false); + } else { + empty.setVisibility(View.GONE); + } + } + + @Override + public void onLoaderReset(@NonNull Loader> loader) { + setListAdapter(null); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final String deviceName = ((DeviceListItem)view).getDeviceName(); + final long deviceId = ((DeviceListItem)view).getDeviceId(); + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getActivity().getString(R.string.DeviceListActivity_unlink_s, deviceName)); + builder.setMessage(R.string.DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + handleDisconnectDevice(deviceId); + } + }); + builder.show(); + } + + private void handleLoaderFailed() { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(R.string.DeviceListActivity_network_connection_failed); + builder.setPositiveButton(R.string.DeviceListActivity_try_again, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + getLoaderManager().restartLoader(0, null, DeviceListFragment.this); + } + }); + + builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + DeviceListFragment.this.getActivity().onBackPressed(); + } + }); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + DeviceListFragment.this.getActivity().onBackPressed(); + } + }); + + builder.show(); + } + + @SuppressLint("StaticFieldLeak") + private void handleDisconnectDevice(final long deviceId) { + new ProgressDialogAsyncTask(getActivity(), + R.string.DeviceListActivity_unlinking_device_no_ellipsis, + R.string.DeviceListActivity_unlinking_device) + { + @Override + protected Boolean doInBackground(Void... params) { + try { + accountManager.removeDevice(deviceId); + return true; + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } + + @Override + protected void onPostExecute(Boolean result) { + super.onPostExecute(result); + if (result) { + getLoaderManager().restartLoader(0, null, DeviceListFragment.this); + } else { + Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show(); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @Override + public void onClick(View v) { + if (addDeviceButtonListener != null) addDeviceButtonListener.onClick(v); + } + + private static class DeviceListAdapter extends ArrayAdapter { + + private final int resource; + private final Locale locale; + + public DeviceListAdapter(Context context, int resource, List objects, Locale locale) { + super(context, resource, objects); + this.resource = resource; + this.locale = locale; + } + + @Override + public @NonNull View getView(int position, View convertView, @NonNull ViewGroup parent) { + if (convertView == null) { + convertView = ((Activity)getContext()).getLayoutInflater().inflate(resource, parent, false); + } + + ((DeviceListItem)convertView).set(getItem(position), locale); + + return convertView; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java new file mode 100644 index 00000000..52849457 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.thoughtcrime.securesms.devicelist.Device; +import org.thoughtcrime.securesms.util.DateUtils; + +import java.util.Locale; + +public class DeviceListItem extends LinearLayout { + + private long deviceId; + private TextView name; + private TextView created; + private TextView lastActive; + + public DeviceListItem(Context context) { + super(context); + } + + public DeviceListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.name = (TextView) findViewById(R.id.name); + this.created = (TextView) findViewById(R.id.created); + this.lastActive = (TextView) findViewById(R.id.active); + } + + public void set(Device deviceInfo, Locale locale) { + if (TextUtils.isEmpty(deviceInfo.getName())) this.name.setText(R.string.DeviceListItem_unnamed_device); + else this.name.setText(deviceInfo.getName()); + + this.created.setText(getContext().getString(R.string.DeviceListItem_linked_s, + DateUtils.getDayPrecisionTimeSpanString(getContext(), + locale, + deviceInfo.getCreated()))); + + this.lastActive.setText(getContext().getString(R.string.DeviceListItem_last_active_s, + DateUtils.getDayPrecisionTimeSpanString(getContext(), + locale, + deviceInfo.getLastSeen()))); + + this.deviceId = deviceInfo.getId(); + } + + public long getDeviceId() { + return deviceId; + } + + public String getDeviceName() { + return name.getText().toString(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java new file mode 100644 index 00000000..6625d5fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Window; + +import androidx.appcompat.app.AlertDialog; + +public class DeviceProvisioningActivity extends PassphraseRequiredActivity { + + @SuppressWarnings("unused") + private static final String TAG = DeviceProvisioningActivity.class.getSimpleName(); + + @Override + protected void onPreCreate() { + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + } + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device)) + .setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner)) + .setPositiveButton(R.string.DeviceProvisioningActivity_continue, (dialog1, which) -> { + Intent intent = new Intent(DeviceProvisioningActivity.this, DeviceActivity.class); + intent.putExtra("add", true); + startActivity(intent); + finish(); + }) + .setNegativeButton(android.R.string.cancel, (dialog12, which) -> { + dialog12.dismiss(); + finish(); + }) + .setOnDismissListener(dialog13 -> finish()) + .create(); + + dialog.setIcon(getResources().getDrawable(R.drawable.icon_dialog)); + dialog.show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java new file mode 100644 index 00000000..bed0d92e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.os.Bundle; + +/** + * Workaround for Android bug: + * https://code.google.com/p/android/issues/detail?id=53313 + */ +public class DummyActivity extends Activity { + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java new file mode 100644 index 00000000..10f11dab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.util.ExpirationUtil; + +import java.util.Arrays; + +import cn.carbswang.android.numberpickerview.library.NumberPickerView; + +public class ExpirationDialog extends AlertDialog { + + protected ExpirationDialog(Context context) { + super(context); + } + + protected ExpirationDialog(Context context, int theme) { + super(context, theme); + } + + protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) { + super(context, cancelable, cancelListener); + } + + public static void show(final Context context, + final int currentExpiration, + final @NonNull OnClickListener listener) + { + final View view = createNumberPickerView(context, currentExpiration); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages)); + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue(); + listener.onClick(getExpirationTimes(context, currentExpiration)[selected]); + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + private static View createNumberPickerView(final Context context, final int currentExpiration) { + final LayoutInflater inflater = LayoutInflater.from(context); + final View view = inflater.inflate(R.layout.expiration_dialog, null); + final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker); + final TextView textView = view.findViewById(R.id.expiration_details); + final int[] expirationTimes = getExpirationTimes(context, currentExpiration); + final String[] expirationDisplayValues = new String[expirationTimes.length]; + + int selectedIndex = expirationTimes.length - 1; + + for (int i=0;i= expirationTimes[i]) && + (i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) { + selectedIndex = i; + } + } + + numberPickerView.setDisplayedValues(expirationDisplayValues); + numberPickerView.setMinValue(0); + numberPickerView.setMaxValue(expirationTimes.length-1); + + NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> { + if (newVal == 0) { + textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire); + } else { + textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal])); + } + }; + + numberPickerView.setOnValueChangedListener(listener); + numberPickerView.setValue(selectedIndex); + listener.onValueChange(numberPickerView, selectedIndex, selectedIndex); + + return view; + } + + private static int[] getExpirationTimes(Context context, int currentExpiration) { + int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times); + int location = Arrays.binarySearch(expirationTimes, currentExpiration); + if (location < 0) { + int[] temp = Arrays.copyOf(expirationTimes, expirationTimes.length + 1); + temp[temp.length - 1] = currentExpiration; + Arrays.sort(temp); + expirationTimes = temp; + } + + return expirationTimes; + } + + public interface OnClickListener { + public void onClick(int expirationTime); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java new file mode 100644 index 00000000..0d11e111 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; + +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; + +import java.util.List; + +public final class GroupMembersDialog { + + private final FragmentActivity fragmentActivity; + private final Recipient groupRecipient; + + public GroupMembersDialog(@NonNull FragmentActivity activity, + @NonNull Recipient groupRecipient) + { + this.fragmentActivity = activity; + this.groupRecipient = groupRecipient; + } + + public void display() { + AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) + .setTitle(R.string.ConversationActivity_group_members) + .setIcon(R.drawable.ic_group_24) + .setCancelable(true) + .setView(R.layout.dialog_group_members) + .setPositiveButton(android.R.string.ok, null) + .show(); + + GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); + + LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId()); + LiveData> fullMembers = liveGroup.getFullMembers(); + + //noinspection ConstantConditions + fullMembers.observe(fragmentActivity, memberListView::setMembers); + + dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity)); + + memberListView.setRecipientClickListener(recipient -> { + dialog.dismiss(); + contactClick(recipient); + }); + } + + private void contactClick(@NonNull Recipient recipient) { + RecipientBottomSheetDialogFragment.create(recipient.getId(), groupRecipient.requireGroupId()) + .show(fragmentActivity.getSupportFragmentManager(), "BOTTOM"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java new file mode 100644 index 00000000..23da2180 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -0,0 +1,310 @@ +package org.thoughtcrime.securesms; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.graphics.PorterDuff; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.AnimRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import org.thoughtcrime.securesms.components.ContactFilterToolbar; +import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.concurrent.ExecutionException; + +public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener { + + private ContactSelectionListFragment contactsFragment; + private EditText inviteText; + private ViewGroup smsSendFrame; + private Button smsSendButton; + private Animation slideInAnimation; + private Animation slideOutAnimation; + private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme(); + private Toolbar primaryToolbar; + + @Override + protected void onPreCreate() { + super.onPreCreate(); + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS); + getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS); + getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true); + getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); + + setContentView(R.layout.invite_activity); + + initializeAppBar(); + initializeResources(); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + private void initializeAppBar() { + primaryToolbar = findViewById(R.id.toolbar); + setSupportActionBar(primaryToolbar); + + assert getSupportActionBar() != null; + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.AndroidManifest__invite_friends); + } + + private void initializeResources() { + slideInAnimation = loadAnimation(R.anim.slide_from_bottom); + slideOutAnimation = loadAnimation(R.anim.slide_to_bottom); + + View shareButton = findViewById(R.id.share_button); + Button smsButton = findViewById(R.id.sms_button); + Button smsCancelButton = findViewById(R.id.cancel_sms_button); + ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter); + + inviteText = findViewById(R.id.invite_text); + smsSendFrame = findViewById(R.id.sms_send_frame); + smsSendButton = findViewById(R.id.send_sms_button); + contactsFragment = (ContactSelectionListFragment)getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + + inviteText.setText(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))); + inviteText.addTextChangedListener(new AfterTextChanged(editable -> { + boolean isEnabled = editable.length() > 0; + smsButton.setEnabled(isEnabled); + shareButton.setEnabled(isEnabled); + smsButton.animate().alpha(isEnabled ? 1f : 0.5f); + shareButton.animate().alpha(isEnabled ? 1f : 0.5f); + })); + + updateSmsButtonText(contactsFragment.getSelectedContacts().size()); + + smsCancelButton.setOnClickListener(new SmsCancelClickListener()); + smsSendButton.setOnClickListener(new SmsSendClickListener()); + contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener()); + contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24); + + if (Util.isDefaultSmsProvider(this)) { + shareButton.setOnClickListener(new ShareClickListener()); + smsButton.setOnClickListener(new SmsClickListener()); + } else { + shareButton.setVisibility(View.GONE); + smsButton.setOnClickListener(new ShareClickListener()); + smsButton.setText(R.string.InviteActivity_share); + } + } + + private Animation loadAnimation(@AnimRes int animResId) { + final Animation animation = AnimationUtils.loadAnimation(this, animResId); + animation.setInterpolator(new FastOutSlowInInterpolator()); + return animation; + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1); + return true; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + updateSmsButtonText(contactsFragment.getSelectedContacts().size()); + } + + private void sendSmsInvites() { + new SendSmsInvitesAsyncTask(this, inviteText.getText().toString()) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + contactsFragment.getSelectedContacts() + .toArray(new SelectedContact[0])); + } + + private void updateSmsButtonText(int count) { + smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends, + count, + count)); + smsSendButton.setEnabled(count > 0); + } + + @Override public void onBackPressed() { + if (smsSendFrame.getVisibility() == View.VISIBLE) { + cancelSmsSelection(); + } else { + super.onBackPressed(); + } + } + + private void cancelSmsSelection() { + setPrimaryColorsToolbarNormal(); + contactsFragment.reset(); + updateSmsButtonText(contactsFragment.getSelectedContacts().size()); + ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE); + } + + private void setPrimaryColorsToolbarNormal() { + primaryToolbar.setBackgroundColor(0); + primaryToolbar.getNavigationIcon().setColorFilter(null); + primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary)); + + if (Build.VERSION.SDK_INT >= 23) { + WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor)); + getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor)); + WindowUtil.setLightStatusBarFromTheme(this); + } + + WindowUtil.setLightNavigationBarFromTheme(this); + } + + private void setPrimaryColorsToolbarForSms() { + primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine)); + primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN); + primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title)); + + if (Build.VERSION.SDK_INT >= 23) { + WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine)); + WindowUtil.clearLightStatusBar(getWindow()); + } + + if (Build.VERSION.SDK_INT >= 27) { + getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine)); + WindowUtil.clearLightNavigationBar(getWindow()); + } + } + + private class ShareClickListener implements OnClickListener { + @Override + public void onClick(View v) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, inviteText.getText().toString()); + sendIntent.setType("text/plain"); + if (sendIntent.resolveActivity(getPackageManager()) != null) { + startActivity(Intent.createChooser(sendIntent, getString(R.string.InviteActivity_invite_to_signal))); + } else { + Toast.makeText(InviteActivity.this, R.string.InviteActivity_no_app_to_share_to, Toast.LENGTH_LONG).show(); + } + } + } + + private class SmsClickListener implements OnClickListener { + @Override + public void onClick(View v) { + setPrimaryColorsToolbarForSms(); + ViewUtil.animateIn(smsSendFrame, slideInAnimation); + } + } + + private class SmsCancelClickListener implements OnClickListener { + @Override + public void onClick(View v) { + cancelSmsSelection(); + } + } + + private class SmsSendClickListener implements OnClickListener { + @Override + public void onClick(View v) { + new AlertDialog.Builder(InviteActivity.this) + .setTitle(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_invites, + contactsFragment.getSelectedContacts().size(), + contactsFragment.getSelectedContacts().size())) + .setMessage(inviteText.getText().toString()) + .setPositiveButton(R.string.yes, (dialog, which) -> sendSmsInvites()) + .setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss()) + .show(); + } + } + + private class ContactFilterChangedListener implements OnFilterChangedListener { + @Override + public void onFilterChanged(String filter) { + contactsFragment.setQueryFilter(filter); + } + } + + @SuppressLint("StaticFieldLeak") + private class SendSmsInvitesAsyncTask extends ProgressDialogAsyncTask { + private final String message; + + SendSmsInvitesAsyncTask(Context context, String message) { + super(context, R.string.InviteActivity_sending, R.string.InviteActivity_sending); + this.message = message; + } + + @Override + protected Void doInBackground(SelectedContact... contacts) { + final Context context = getContext(); + if (context == null) return null; + + for (SelectedContact contact : contacts) { + RecipientId recipientId = contact.getOrCreateRecipientId(context); + Recipient recipient = Recipient.resolved(recipientId); + int subscriptionId = recipient.getDefaultSubscriptionId().or(-1); + + MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null); + + if (recipient.getContactUri() != null) { + DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId()); + } + } + + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + final Context context = getContext(); + if (context == null) return; + + ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE).addListener(new Listener() { + @Override + public void onSuccess(Boolean result) { + contactsFragment.reset(); + } + + @Override + public void onFailure(ExecutionException e) {} + }); + Toast.makeText(context, R.string.InviteActivity_invitations_sent, Toast.LENGTH_LONG).show(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java b/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java new file mode 100644 index 00000000..aa3afa40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +/** + * Used in our {@link BuildConfig} to tie together the various attributes of a KBS instance. This + * is sitting in the root directory so it can be accessed by the build config. + */ +public final class KbsEnclave { + + private final String enclaveName; + private final String serviceId; + private final String mrEnclave; + + public KbsEnclave(@NonNull String enclaveName, @NonNull String serviceId, @NonNull String mrEnclave) { + this.enclaveName = enclaveName; + this.serviceId = serviceId; + this.mrEnclave = mrEnclave; + } + + public @NonNull String getMrEnclave() { + return mrEnclave; + } + + public @NonNull String getEnclaveName() { + return enclaveName; + } + + public @NonNull String getServiceId() { + return serviceId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + KbsEnclave enclave = (KbsEnclave) o; + return enclaveName.equals(enclave.enclaveName) && + serviceId.equals(enclave.serviceId) && + mrEnclave.equals(enclave.mrEnclave); + } + + @Override + public int hashCode() { + return Objects.hash(enclaveName, serviceId, mrEnclave); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java new file mode 100644 index 00000000..e9a8eeea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.signal.core.util.logging.Log; + +/** + * Simply logs out lifecycle events. + */ +public abstract class LoggingFragment extends Fragment { + + private static final String TAG = Log.tag(LoggingFragment.class); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + logEvent("onCreate()"); + super.onCreate(savedInstanceState); + } + + @Override + public void onStart() { + logEvent("onStart()"); + super.onStart(); + } + + @Override + public void onStop() { + logEvent("onStop()"); + super.onStop(); + } + + @Override + public void onDestroy() { + logEvent("onDestroy()"); + super.onDestroy(); + } + + private void logEvent(@NonNull String event) { + Log.d(TAG, "[" + Log.tag(getClass()) + "] " + event); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java new file mode 100644 index 00000000..15844129 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class MainActivity extends PassphraseRequiredActivity { + + public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final MainNavigator navigator = new MainNavigator(this); + + public static @NonNull Intent clearTop(@NonNull Context context) { + Intent intent = new Intent(context, MainActivity.class); + + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | + Intent.FLAG_ACTIVITY_NEW_TASK | + Intent.FLAG_ACTIVITY_SINGLE_TOP); + + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + AppStartup.getInstance().onCriticalRenderEventStart(); + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.main_activity); + + navigator.onCreate(savedInstanceState); + + handleGroupLinkInIntent(getIntent()); + handleProxyInIntent(getIntent()); + + CachedInflater.from(this).clear(); + } + + @Override + public Intent getIntent() { + return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | + Intent.FLAG_ACTIVITY_NEW_TASK | + Intent.FLAG_ACTIVITY_SINGLE_TOP); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + handleGroupLinkInIntent(intent); + handleProxyInIntent(intent); + } + + @Override + protected void onPreCreate() { + super.onPreCreate(); + dynamicTheme.onCreate(this); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public void onBackPressed() { + if (!navigator.onBackPressed()) { + super.onBackPressed(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) { + recreate(); + } + } + + public @NonNull MainNavigator getNavigator() { + return navigator; + } + + private void handleGroupLinkInIntent(Intent intent) { + Uri data = intent.getData(); + if (data != null) { + CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString()); + } + } + + private void handleProxyInIntent(Intent intent) { + Uri data = intent.getData(); + if (data != null) { + CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java b/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java new file mode 100644 index 00000000..fcac5438 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public class MainFragment extends LoggingFragment { + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (!(requireActivity() instanceof MainActivity)) { + throw new IllegalStateException("Can only be used inside of MainActivity!"); + } + } + + protected @NonNull MainNavigator getNavigator() { + return MainNavigator.get(requireActivity()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java new file mode 100644 index 00000000..67a29153 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment; +import org.thoughtcrime.securesms.conversationlist.ConversationListFragment; +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; +import org.thoughtcrime.securesms.insights.InsightsLauncher; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public class MainNavigator { + + public static final int REQUEST_CONFIG_CHANGES = 901; + + private final MainActivity activity; + + public MainNavigator(@NonNull MainActivity activity) { + this.activity = activity; + } + + public static MainNavigator get(@NonNull Activity activity) { + if (!(activity instanceof MainActivity)) { + throw new IllegalArgumentException("Activity must be an instance of MainActivity!"); + } + + return ((MainActivity) activity).getNavigator(); + } + + public void onCreate(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + return; + } + + getFragmentManager().beginTransaction() + .add(R.id.fragment_container, ConversationListFragment.newInstance()) + .commit(); + } + + /** + * @return True if the back pressed was handled in our own custom way, false if it should be given + * to the system to do the default behavior. + */ + public boolean onBackPressed() { + Fragment fragment = getFragmentManager().findFragmentById(R.id.fragment_container); + + if (fragment instanceof BackHandler) { + return ((BackHandler) fragment).onBackPressed(); + } + + return false; + } + + public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) { + Intent intent = ConversationIntents.createBuilder(activity, recipientId, threadId) + .withDistributionType(distributionType) + .withStartingPosition(startingPosition) + .build(); + + activity.startActivity(intent); + activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out); + } + + public void goToAppSettings() { + Intent intent = new Intent(activity, ApplicationPreferencesActivity.class); + activity.startActivityForResult(intent, REQUEST_CONFIG_CHANGES); + } + + public void goToArchiveList() { + getFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end) + .replace(R.id.fragment_container, ConversationListArchiveFragment.newInstance()) + .addToBackStack(null) + .commit(); + } + + public void goToGroupCreation() { + activity.startActivity(CreateGroupActivity.newIntent(activity)); + } + + public void goToInvite() { + Intent intent = new Intent(activity, InviteActivity.class); + activity.startActivity(intent); + } + + public void goToInsights() { + InsightsLauncher.showInsightsDashboard(activity.getSupportFragmentManager()); + } + + private @NonNull FragmentManager getFragmentManager() { + return activity.getSupportFragmentManager(); + } + + public interface BackHandler { + /** + * @return True if the back pressed was handled in our own custom way, false if it should be given + * to the system to do the default behavior. + */ + boolean onBackPressed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java b/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java new file mode 100644 index 00000000..66070e6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms; + +public interface MasterSecretListener { + void onMasterSecretCleared(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java new file mode 100644 index 00000000..aa06149f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -0,0 +1,867 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.app.ShareCompat; +import androidx.core.util.Pair; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.lifecycle.ViewModelProviders; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.animation.DepthPageTransformer; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; +import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; +import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment; +import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel; +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.util.AttachmentUtil; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.FullscreenHelper; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; +import org.thoughtcrime.securesms.util.StorageUtil; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Activity for displaying media attachments in-app + */ +public final class MediaPreviewActivity extends PassphraseRequiredActivity + implements LoaderManager.LoaderCallbacks>, + MediaRailAdapter.RailItemListener, + MediaPreviewFragment.Events +{ + + private final static String TAG = MediaPreviewActivity.class.getSimpleName(); + + private static final int NOT_IN_A_THREAD = -2; + + public static final String THREAD_ID_EXTRA = "thread_id"; + public static final String DATE_EXTRA = "date"; + public static final String SIZE_EXTRA = "size"; + public static final String CAPTION_EXTRA = "caption"; + public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent"; + public static final String HIDE_ALL_MEDIA_EXTRA = "came_from_all_media"; + public static final String SHOW_THREAD_EXTRA = "show_thread"; + public static final String SORTING_EXTRA = "sorting"; + + private ViewPager mediaPager; + private View detailsContainer; + private TextView caption; + private View captionContainer; + private RecyclerView albumRail; + private MediaRailAdapter albumRailAdapter; + private ViewGroup playbackControlsContainer; + private Uri initialMediaUri; + private String initialMediaType; + private long initialMediaSize; + private String initialCaption; + private boolean leftIsRecent; + private MediaPreviewViewModel viewModel; + private ViewPagerListener viewPagerListener; + + private int restartItem = -1; + private long threadId = NOT_IN_A_THREAD; + private boolean cameFromAllMedia; + private boolean showThread; + private MediaDatabase.Sorting sorting; + private FullscreenHelper fullscreenHelper; + + private @Nullable Cursor cursor = null; + + public static @NonNull Intent intentFromMediaRecord(@NonNull Context context, + @NonNull MediaRecord mediaRecord, + boolean leftIsRecent) + { + DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment()); + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, mediaRecord.getThreadId()); + intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, attachment.getSize()); + intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, attachment.getCaption()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, leftIsRecent); + intent.setDataAndType(attachment.getUri(), mediaRecord.getContentType()); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @SuppressWarnings("ConstantConditions") + @Override + protected void onCreate(Bundle bundle, boolean ready) { + this.setTheme(R.style.TextSecure_MediaPreview); + setContentView(R.layout.media_preview_activity); + + setSupportActionBar(findViewById(R.id.toolbar)); + + viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class); + + fullscreenHelper = new FullscreenHelper(this); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + initializeViews(); + initializeResources(); + initializeObservers(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onRailItemClicked(int distanceFromActive) { + mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive); + } + + @Override + public void onRailItemDeleteClicked(int distanceFromActive) { + throw new UnsupportedOperationException("Callback unsupported."); + } + + @SuppressWarnings("ConstantConditions") + private void initializeActionBar() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + getSupportActionBar().setTitle(getTitleText(mediaItem)); + getSupportActionBar().setSubtitle(getSubTitleText(mediaItem)); + } + } + + private @NonNull String getTitleText(@NonNull MediaItem mediaItem) { + String from; + if (mediaItem.outgoing) from = getString(R.string.MediaPreviewActivity_you); + else if (mediaItem.recipient != null) from = mediaItem.recipient.getDisplayName(this); + else from = ""; + + if (showThread) { + String to = null; + Recipient threadRecipient = mediaItem.threadRecipient; + + if (threadRecipient != null) { + if (mediaItem.outgoing || threadRecipient.isGroup()) { + if (threadRecipient.isSelf()) { + from = getString(R.string.note_to_self); + } else { + to = threadRecipient.getDisplayName(this); + } + } else { + to = getString(R.string.MediaPreviewActivity_you); + } + } + + return to != null ? getString(R.string.MediaPreviewActivity_s_to_s, from, to) + : from; + } else { + return from; + } + } + + private @NonNull String getSubTitleText(@NonNull MediaItem mediaItem) { + if (mediaItem.date > 0) { + return DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), mediaItem.date); + } else { + return getString(R.string.MediaPreviewActivity_draft); + } + } + + @Override + public void onResume() { + super.onResume(); + + initializeMedia(); + } + + @Override + public void onPause() { + super.onPause(); + restartItem = cleanupMedia(); + } + + @Override + protected void onDestroy() { + if (cursor != null) { + cursor.close(); + cursor = null; + } + super.onDestroy(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + initializeResources(); + } + + private void initializeViews() { + mediaPager = findViewById(R.id.media_pager); + mediaPager.setOffscreenPageLimit(1); + mediaPager.setPageTransformer(true, new DepthPageTransformer()); + + viewPagerListener = new ViewPagerListener(); + mediaPager.addOnPageChangeListener(viewPagerListener); + + albumRail = findViewById(R.id.media_preview_album_rail); + albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false); + + albumRail.setItemAnimator(null); // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682 + albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); + albumRail.setAdapter(albumRailAdapter); + + detailsContainer = findViewById(R.id.media_preview_details_container); + caption = findViewById(R.id.media_preview_caption); + captionContainer = findViewById(R.id.media_preview_caption_container); + playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container); + + View toolbarLayout = findViewById(R.id.toolbar_layout); + + anchorMarginsToBottomInsets(detailsContainer); + + fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer)); + + fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout); + } + + private void initializeResources() { + Intent intent = getIntent(); + + threadId = intent.getLongExtra(THREAD_ID_EXTRA, NOT_IN_A_THREAD); + cameFromAllMedia = intent.getBooleanExtra(HIDE_ALL_MEDIA_EXTRA, false); + showThread = intent.getBooleanExtra(SHOW_THREAD_EXTRA, false); + sorting = MediaDatabase.Sorting.values()[intent.getIntExtra(SORTING_EXTRA, 0)]; + + initialMediaUri = intent.getData(); + initialMediaType = intent.getType(); + initialMediaSize = intent.getLongExtra(SIZE_EXTRA, 0); + initialCaption = intent.getStringExtra(CAPTION_EXTRA); + leftIsRecent = intent.getBooleanExtra(LEFT_IS_RECENT_EXTRA, false); + restartItem = -1; + } + + private void initializeObservers() { + viewModel.getPreviewData().observe(this, previewData -> { + if (previewData == null || mediaPager == null || mediaPager.getAdapter() == null) { + return; + } + + if (!((MediaItemAdapter) mediaPager.getAdapter()).hasFragmentFor(mediaPager.getCurrentItem())) { + Log.d(TAG, "MediaItemAdapter wasn't ready. Posting again..."); + viewModel.resubmitPreviewData(); + } + + View playbackControls = ((MediaItemAdapter) mediaPager.getAdapter()).getPlaybackControls(mediaPager.getCurrentItem()); + + if (previewData.getAlbumThumbnails().isEmpty() && previewData.getCaption() == null && playbackControls == null) { + detailsContainer.setVisibility(View.GONE); + } else { + detailsContainer.setVisibility(View.VISIBLE); + } + + albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE); + albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition()); + albumRail.smoothScrollToPosition(previewData.getActivePosition()); + + captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE); + caption.setText(previewData.getCaption()); + + if (playbackControls != null) { + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + playbackControls.setLayoutParams(params); + + playbackControlsContainer.removeAllViews(); + playbackControlsContainer.addView(playbackControls); + } else { + playbackControlsContainer.removeAllViews(); + } + }); + } + + private void initializeMedia() { + if (!isContentTypeSupported(initialMediaType)) { + Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing."); + Toast.makeText(getApplicationContext(), R.string.MediaPreviewActivity_unssuported_media_type, Toast.LENGTH_LONG).show(); + finish(); + } + + Log.i(TAG, "Loading Part URI: " + initialMediaUri); + + if (isMediaInDb()) { + LoaderManager.getInstance(this).restartLoader(0, null, this); + } else { + mediaPager.setAdapter(new SingleItemPagerAdapter(getSupportFragmentManager(), initialMediaUri, initialMediaType, initialMediaSize)); + + if (initialCaption != null) { + detailsContainer.setVisibility(View.VISIBLE); + captionContainer.setVisibility(View.VISIBLE); + caption.setText(initialCaption); + } + } + } + + private int cleanupMedia() { + int restartItem = mediaPager.getCurrentItem(); + + mediaPager.removeAllViews(); + mediaPager.setAdapter(null); + viewModel.setCursor(this, null, leftIsRecent); + + return restartItem; + } + + private void showOverview() { + startActivity(MediaOverviewActivity.forThread(this, threadId)); + } + + private void forward() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + Intent composeIntent = new Intent(this, ShareActivity.class); + composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri); + composeIntent.setType(mediaItem.type); + startActivity(composeIntent); + } + } + + private void share() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + Uri publicUri = PartAuthority.getAttachmentPublicUri(mediaItem.uri); + String mimeType = Intent.normalizeMimeType(mediaItem.type); + Intent shareIntent = ShareCompat.IntentBuilder.from(this) + .setStream(publicUri) + .setType(mimeType) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + try { + startActivity(shareIntent); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "No activity existed to share the media.", e); + Toast.makeText(this, R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show(); + } + } + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + private void saveToDisk() { + MediaItem mediaItem = getCurrentMediaItem(); + + if (mediaItem != null) { + SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSavetoDisk(mediaItem); + return; + } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(() -> { + performSavetoDisk(mediaItem); + }) + .execute(); + }); + } + } + + private void performSavetoDisk(@NonNull MediaItem mediaItem) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); + long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); + } + + @SuppressLint("StaticFieldLeak") + private void deleteMedia() { + MediaItem mediaItem = getCurrentMediaItem(); + if (mediaItem == null || mediaItem.attachment == null) { + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setIcon(R.drawable.ic_warning); + builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title); + builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message); + builder.setCancelable(true); + + builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> { + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(), + mediaItem.attachment); + return null; + } + }.execute(); + + finish(); + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.clear(); + MenuInflater inflater = this.getMenuInflater(); + inflater.inflate(R.menu.media_preview, menu); + + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + if (!isMediaInDb()) { + menu.findItem(R.id.media_preview__overview).setVisible(false); + menu.findItem(R.id.delete).setVisible(false); + } + + // Restricted to API26 because of MemoryFileUtil not supporting lower API levels well + menu.findItem(R.id.media_preview__share).setVisible(Build.VERSION.SDK_INT >= 26); + + if (cameFromAllMedia) { + menu.findItem(R.id.media_preview__overview).setVisible(false); + } + + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + int itemId = item.getItemId(); + + if (itemId == R.id.media_preview__overview) { showOverview(); return true; } + if (itemId == R.id.media_preview__forward) { forward(); return true; } + if (itemId == R.id.media_preview__share) { share(); return true; } + if (itemId == R.id.save) { saveToDisk(); return true; } + if (itemId == R.id.delete) { deleteMedia(); return true; } + if (itemId == android.R.id.home) { finish(); return true; } + + return false; + } + + private boolean isMediaInDb() { + return threadId != NOT_IN_A_THREAD; + } + + private @Nullable MediaItem getCurrentMediaItem() { + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + return adapter.getMediaItemFor(mediaPager.getCurrentItem()); + } else { + return null; + } + } + + public static boolean isContentTypeSupported(final String contentType) { + return contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/")); + } + + @Override + public @NonNull Loader> onCreateLoader(int id, Bundle args) { + return new PagingMediaLoader(this, threadId, initialMediaUri, leftIsRecent, sorting); + } + + @Override + public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { + if (data != null) { + if (data.first == cursor) { + return; + } + + if (cursor != null) { + cursor.close(); + } + cursor = Objects.requireNonNull(data.first); + + int mediaPosition = Objects.requireNonNull(data.second); + + CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, cursor, mediaPosition, leftIsRecent); + mediaPager.setAdapter(adapter); + adapter.setActive(true); + + viewModel.setCursor(this, cursor, leftIsRecent); + + int item = restartItem >= 0 ? restartItem : mediaPosition; + mediaPager.setCurrentItem(item); + + if (item == 0) { + viewPagerListener.onPageSelected(0); + } + + cursor.registerContentObserver(new ContentObserver(new Handler(getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + onMediaChange(); + } + }); + } else { + mediaNotAvailable(); + } + } + + private void onMediaChange() { + MediaItemAdapter adapter = (MediaItemAdapter) mediaPager.getAdapter(); + + if (adapter != null) { + adapter.checkMedia(mediaPager.getCurrentItem()); + } + } + + @Override + public void onLoaderReset(@NonNull Loader> loader) { + + } + + @Override + public boolean singleTapOnMedia() { + fullscreenHelper.toggleUiVisibility(); + return true; + } + + @Override + public void mediaNotAvailable() { + Toast.makeText(this, R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show(); + finish(); + } + + private class ViewPagerListener extends ExtendedOnPageChangedListener { + + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + MediaItem item = adapter.getMediaItemFor(position); + if (item.recipient != null) item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar()); + viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position); + initializeActionBar(); + } + } + + + @Override + public void onPageUnselected(int position) { + MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + + if (adapter != null) { + MediaItem item = adapter.getMediaItemFor(position); + if (item.recipient != null) item.recipient.live().removeObservers(MediaPreviewActivity.this); + + adapter.pause(position); + } + } + } + + private static class SingleItemPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter { + + private final Uri uri; + private final String mediaType; + private final long size; + + private MediaPreviewFragment mediaPreviewFragment; + + SingleItemPagerAdapter(@NonNull FragmentManager fragmentManager, + @NonNull Uri uri, + @NonNull String mediaType, + long size) + { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.uri = uri; + this.mediaType = mediaType; + this.size = size; + } + + @Override + public int getCount() { + return 1; + } + + @NonNull + @Override + public Fragment getItem(int position) { + mediaPreviewFragment = MediaPreviewFragment.newInstance(uri, mediaType, size, true); + return mediaPreviewFragment; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + if (mediaPreviewFragment != null) { + mediaPreviewFragment.cleanUp(); + mediaPreviewFragment = null; + } + } + + @Override + public MediaItem getMediaItemFor(int position) { + return new MediaItem(null, null, null, uri, mediaType, -1, true); + } + + @Override + public void pause(int position) { + if (mediaPreviewFragment != null) { + mediaPreviewFragment.pause(); + } + } + + @Override + public @Nullable View getPlaybackControls(int position) { + if (mediaPreviewFragment != null) { + return mediaPreviewFragment.getPlaybackControls(); + } + return null; + } + + @Override + public boolean hasFragmentFor(int position) { + return mediaPreviewFragment != null; + } + + @Override + public void checkMedia(int currentItem) { + + } + } + + private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) { + ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor, (view, insets) -> { + ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + + layoutParams.setMargins(insets.getSystemWindowInsetLeft(), + layoutParams.topMargin, + insets.getSystemWindowInsetRight(), + insets.getSystemWindowInsetBottom()); + + view.setLayoutParams(layoutParams); + + return insets; + }); + } + + private static class CursorPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter { + + @SuppressLint("UseSparseArrays") + private final Map mediaFragments = new HashMap<>(); + + private final Context context; + private final Cursor cursor; + private final boolean leftIsRecent; + + private boolean active; + private int autoPlayPosition; + + CursorPagerAdapter(@NonNull FragmentManager fragmentManager, + @NonNull Context context, + @NonNull Cursor cursor, + int autoPlayPosition, + boolean leftIsRecent) + { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.context = context.getApplicationContext(); + this.cursor = cursor; + this.autoPlayPosition = autoPlayPosition; + this.leftIsRecent = leftIsRecent; + } + + public void setActive(boolean active) { + this.active = active; + notifyDataSetChanged(); + } + + @Override + public int getCount() { + if (!active) return 0; + else return cursor.getCount(); + } + + @NonNull + @Override + public Fragment getItem(int position) { + boolean autoPlay = autoPlayPosition == position; + int cursorPosition = getCursorPosition(position); + + autoPlayPosition = -1; + + cursor.moveToPosition(cursorPosition); + + MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(context, cursor); + DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment()); + MediaPreviewFragment fragment = MediaPreviewFragment.newInstance(attachment, autoPlay); + + mediaFragments.put(position, fragment); + + return fragment; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + MediaPreviewFragment removed = mediaFragments.remove(position); + + if (removed != null) { + removed.cleanUp(); + } + + super.destroyItem(container, position, object); + } + + public MediaItem getMediaItemFor(int position) { + cursor.moveToPosition(getCursorPosition(position)); + + MediaRecord mediaRecord = MediaRecord.from(context, cursor); + DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment()); + RecipientId recipientId = mediaRecord.getRecipientId(); + RecipientId threadRecipientId = mediaRecord.getThreadRecipientId(); + + return new MediaItem(Recipient.live(recipientId).get(), + Recipient.live(threadRecipientId).get(), + attachment, + Objects.requireNonNull(attachment.getUri()), + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.isOutgoing()); + } + + @Override + public void pause(int position) { + MediaPreviewFragment mediaView = mediaFragments.get(position); + if (mediaView != null) mediaView.pause(); + } + + @Override + public @Nullable View getPlaybackControls(int position) { + MediaPreviewFragment mediaView = mediaFragments.get(position); + if (mediaView != null) return mediaView.getPlaybackControls(); + return null; + } + + @Override + public boolean hasFragmentFor(int position) { + return mediaFragments.containsKey(position); + } + + @Override + public void checkMedia(int position) { + MediaPreviewFragment fragment = mediaFragments.get(position); + if (fragment != null) { + fragment.checkMediaStillAvailable(); + } + } + + private int getCursorPosition(int position) { + if (leftIsRecent) return position; + else return cursor.getCount() - 1 - position; + } + } + + private static class MediaItem { + private final @Nullable Recipient recipient; + private final @Nullable Recipient threadRecipient; + private final @Nullable DatabaseAttachment attachment; + private final @NonNull Uri uri; + private final @NonNull String type; + private final long date; + private final boolean outgoing; + + private MediaItem(@Nullable Recipient recipient, + @Nullable Recipient threadRecipient, + @Nullable DatabaseAttachment attachment, + @NonNull Uri uri, + @NonNull String type, + long date, + boolean outgoing) + { + this.recipient = recipient; + this.threadRecipient = threadRecipient; + this.attachment = attachment; + this.uri = uri; + this.type = type; + this.date = date; + this.outgoing = outgoing; + } + } + + interface MediaItemAdapter { + MediaItem getMediaItemFor(int position); + void pause(int position); + @Nullable View getPlaybackControls(int position); + boolean hasFragmentFor(int position); + void checkMedia(int currentItem); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java new file mode 100644 index 00000000..84f91078 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.DialogInterface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import java.util.concurrent.TimeUnit; + +public class MuteDialog extends AlertDialog { + + + protected MuteDialog(Context context) { + super(context); + } + + protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) { + super(context, cancelable, cancelListener); + } + + protected MuteDialog(Context context, int theme) { + super(context, theme); + } + + public static void show(final Context context, final @NonNull MuteSelectionListener listener) { + show(context, listener, null); + } + + public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.MuteDialog_mute_notifications); + builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, final int which) { + final long muteUntil; + + switch (which) { + case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break; + case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break; + case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break; + case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break; + case 4: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365); break; + default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break; + } + + listener.onMuted(muteUntil); + } + }); + + if (cancelListener != null) { + builder.setOnCancelListener(dialog -> { + cancelListener.run(); + dialog.dismiss(); + }); + } + + builder.show(); + + } + + public interface MuteSelectionListener { + public void onMuted(long until); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java new file mode 100644 index 00000000..b01bd638 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; + +/** + * Activity container for starting a new conversation. + * + * @author Moxie Marlinspike + * + */ +public class NewConversationActivity extends ContactSelectionActivity + implements ContactSelectionListFragment.ListCallback +{ + + @SuppressWarnings("unused") + private static final String TAG = NewConversationActivity.class.getSimpleName(); + + @Override + public void onCreate(Bundle bundle, boolean ready) { + super.onCreate(bundle, ready); + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + if (recipientId.isPresent()) { + launch(Recipient.resolved(recipientId.get())); + } else { + Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); + + if (TextSecurePreferences.isPushRegistered(this) && NetworkConstraint.isMet(this)) { + Log.i(TAG, "[onContactSelected] Doing contact refresh."); + + AlertDialog progress = SimpleProgressDialog.show(this); + + SimpleTask.run(getLifecycle(), () -> { + Recipient resolved = Recipient.external(this, number); + + if (!resolved.isRegistered() || !resolved.hasUuid()) { + Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh."); + try { + DirectoryHelper.refreshDirectoryFor(this, resolved, false); + resolved = Recipient.resolved(resolved.getId()); + } catch (IOException e) { + Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact."); + } + } + + return resolved; + }, resolved -> { + progress.dismiss(); + launch(resolved); + }); + } else { + launch(Recipient.external(this, number)); + } + } + + return true; + } + + private void launch(Recipient recipient) { + long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()); + Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread) + .withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT)) + .withDataUri(getIntent().getData()) + .withDataType(getIntent().getType()) + .build(); + + startActivity(intent); + finish(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case android.R.id.home: super.onBackPressed(); return true; + case R.id.menu_refresh: handleManualRefresh(); return true; + case R.id.menu_new_group: handleCreateGroup(); return true; + case R.id.menu_invite: handleInvite(); return true; + } + + return false; + } + + private void handleManualRefresh() { + contactsFragment.setRefreshing(true); + onRefresh(); + } + + private void handleCreateGroup() { + startActivity(CreateGroupActivity.newIntent(this)); + } + + private void handleInvite() { + startActivity(new Intent(this, InviteActivity.class)); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.clear(); + getMenuInflater().inflate(R.menu.new_conversation_activity, menu); + + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public void onInvite() { + handleInvite(); + finish(); + } + + @Override + public void onNewGroup(boolean forceV1) { + handleCreateGroup(); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java new file mode 100644 index 00000000..5bf576a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java @@ -0,0 +1,77 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.service.KeyCachingService; + + +/** + * Base Activity for changing/prompting local encryption passphrase. + * + * @author Moxie Marlinspike + */ +public abstract class PassphraseActivity extends BaseActivity { + + private static final String TAG = PassphraseActivity.class.getSimpleName(); + + private KeyCachingService keyCachingService; + private MasterSecret masterSecret; + + protected void setMasterSecret(MasterSecret masterSecret) { + this.masterSecret = masterSecret; + Intent bindIntent = new Intent(this, KeyCachingService.class); + startService(bindIntent); + bindService(bindIntent, serviceConnection, Context.BIND_AUTO_CREATE); + } + + protected abstract void cleanup(); + + private ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + keyCachingService = ((KeyCachingService.KeySetBinder)service).getService(); + keyCachingService.setMasterSecret(masterSecret); + + PassphraseActivity.this.unbindService(PassphraseActivity.this.serviceConnection); + + masterSecret = null; + cleanup(); + + Intent nextIntent = getIntent().getParcelableExtra("next_intent"); + if (nextIntent != null) { + try { + startActivity(nextIntent); + } catch (java.lang.SecurityException e) { + Log.w(TAG, "Access permission not passed from PassphraseActivity, retry sharing."); + } + } + finish(); + } + @Override + public void onServiceDisconnected(ComponentName name) { + keyCachingService = null; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java new file mode 100644 index 00000000..316e4e10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java @@ -0,0 +1,177 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.Editable; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.EditText; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.InvalidPassphraseException; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Activity for changing a user's local encryption passphrase. + * + * @author Moxie Marlinspike + */ + +public class PassphraseChangeActivity extends PassphraseActivity { + + private static final String TAG = Log.tag(PassphraseChangeActivity.class); + + private DynamicTheme dynamicTheme = new DynamicTheme(); + private DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private EditText originalPassphrase; + private EditText newPassphrase; + private EditText repeatPassphrase; + private Button okButton; + private Button cancelButton; + + @Override + public void onCreate(Bundle savedInstanceState) { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + super.onCreate(savedInstanceState); + + setContentView(R.layout.change_passphrase_activity); + + initializeResources(); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + private void initializeResources() { + this.originalPassphrase = (EditText) findViewById(R.id.old_passphrase ); + this.newPassphrase = (EditText) findViewById(R.id.new_passphrase ); + this.repeatPassphrase = (EditText) findViewById(R.id.repeat_passphrase ); + + this.okButton = (Button ) findViewById(R.id.ok_button ); + this.cancelButton = (Button ) findViewById(R.id.cancel_button ); + + this.okButton.setOnClickListener(new OkButtonClickListener()); + this.cancelButton.setOnClickListener(new CancelButtonClickListener()); + + if (TextSecurePreferences.isPasswordDisabled(this)) { + this.originalPassphrase.setVisibility(View.GONE); + } else { + this.originalPassphrase.setVisibility(View.VISIBLE); + } + } + + private void verifyAndSavePassphrases() { + Editable originalText = this.originalPassphrase.getText(); + Editable newText = this.newPassphrase.getText(); + Editable repeatText = this.repeatPassphrase.getText(); + + String original = (originalText == null ? "" : originalText.toString()); + String passphrase = (newText == null ? "" : newText.toString()); + String passphraseRepeat = (repeatText == null ? "" : repeatText.toString()); + + if (TextSecurePreferences.isPasswordDisabled(this)) { + original = MasterSecretUtil.UNENCRYPTED_PASSPHRASE; + } + + if (!passphrase.equals(passphraseRepeat)) { + this.newPassphrase.setText(""); + this.repeatPassphrase.setText(""); + this.newPassphrase.setError(getString(R.string.PassphraseChangeActivity_passphrases_dont_match_exclamation)); + this.newPassphrase.requestFocus(); + } else if (passphrase.equals("")) { + this.newPassphrase.setError(getString(R.string.PassphraseChangeActivity_enter_new_passphrase_exclamation)); + this.newPassphrase.requestFocus(); + } else { + new ChangePassphraseTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, original, passphrase); + } + } + + private class CancelButtonClickListener implements OnClickListener { + public void onClick(View v) { + finish(); + } + } + + private class OkButtonClickListener implements OnClickListener { + public void onClick(View v) { + verifyAndSavePassphrases(); + } + } + + private class ChangePassphraseTask extends AsyncTask { + private final Context context; + + public ChangePassphraseTask(Context context) { + this.context = context; + } + + @Override + protected void onPreExecute() { + okButton.setEnabled(false); + } + + @Override + protected MasterSecret doInBackground(String... params) { + try { + MasterSecret masterSecret = MasterSecretUtil.changeMasterSecretPassphrase(context, params[0], params[1]); + TextSecurePreferences.setPasswordDisabled(context, false); + + return masterSecret; + + } catch (InvalidPassphraseException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + protected void onPostExecute(MasterSecret masterSecret) { + okButton.setEnabled(true); + + if (masterSecret != null) { + setMasterSecret(masterSecret); + } else { + originalPassphrase.setText(""); + originalPassphrase.setError(getString(R.string.PassphraseChangeActivity_incorrect_old_passphrase_exclamation)); + originalPassphrase.requestFocus(); + } + } + } + + @Override + protected void cleanup() { + this.originalPassphrase = null; + this.newPassphrase = null; + this.repeatPassphrase = null; + + System.gc(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java new file mode 100644 index 00000000..18c3887b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java @@ -0,0 +1,80 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.os.AsyncTask; +import android.os.Bundle; + +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.util.VersionTracker; + +/** + * Activity for creating a user's local encryption passphrase. + * + * @author Moxie Marlinspike + */ + +public class PassphraseCreateActivity extends PassphraseActivity { + + public PassphraseCreateActivity() { } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.create_passphrase_activity); + + initializeResources(); + } + + private void initializeResources() { + new SecretGenerator().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + } + + private class SecretGenerator extends AsyncTask { + private MasterSecret masterSecret; + + @Override + protected void onPreExecute() { + } + + @Override + protected Void doInBackground(String... params) { + String passphrase = params[0]; + masterSecret = MasterSecretUtil.generateMasterSecret(PassphraseCreateActivity.this, + passphrase); + + MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret); + IdentityKeyUtil.generateIdentityKeys(PassphraseCreateActivity.this); + VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this); + + return null; + } + + @Override + protected void onPostExecute(Void param) { + setMasterSecret(masterSecret); + } + } + + @Override + protected void cleanup() { + System.gc(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java new file mode 100644 index 00000000..6f3f0fe1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.animation.Animator; +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.PorterDuff; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputType; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.RelativeSizeSpan; +import android.text.style.TypefaceSpan; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.BounceInterpolator; +import android.view.animation.TranslateAnimation; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricManager.Authenticators; +import androidx.biometric.BiometricPrompt; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.crypto.InvalidPassphraseException; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.DynamicIntroTheme; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.SupportEmailUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Activity that prompts for a user's passphrase. + * + * @author Moxie Marlinspike + */ +public class PassphrasePromptActivity extends PassphraseActivity { + + private static final String TAG = Log.tag(PassphrasePromptActivity.class); + private static final int BIOMETRIC_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG | Authenticators.BIOMETRIC_WEAK; + private static final int ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS | Authenticators.DEVICE_CREDENTIAL; + private static final short AUTHENTICATE_REQUEST_CODE = 1007; + private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown"; + public static final String FROM_FOREGROUND = "from_foreground"; + + private DynamicIntroTheme dynamicTheme = new DynamicIntroTheme(); + private DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private View passphraseAuthContainer; + private ImageView fingerprintPrompt; + private TextView lockScreenButton; + + private EditText passphraseText; + private ImageButton showButton; + private ImageButton hideButton; + private AnimatingToggle visibilityToggle; + + private BiometricManager biometricManager; + private BiometricPrompt biometricPrompt; + private BiometricPrompt.PromptInfo biometricPromptInfo; + + private boolean authenticated; + private boolean hadFailure; + private boolean alreadyShown; + + @Override + public void onCreate(Bundle savedInstanceState) { + Log.i(TAG, "onCreate()"); + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + super.onCreate(savedInstanceState); + + setContentView(R.layout.prompt_passphrase_activity); + initializeResources(); + + alreadyShown = (savedInstanceState != null && savedInstanceState.getBoolean(BUNDLE_ALREADY_SHOWN)) || + getIntent().getBooleanExtra(FROM_FOREGROUND, false); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(BUNDLE_ALREADY_SHOWN, alreadyShown); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + + setLockTypeVisibility(); + + if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) { + resumeScreenLock(!alreadyShown); + alreadyShown = true; + } + + hadFailure = false; + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.passphrase_prompt, menu); + + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + if (item.getItemId() == R.id.menu_submit_debug_logs) { + handleLogSubmit(); + return true; + } else if (item.getItemId() == R.id.menu_contact_support) { + sendEmailToSupport(); + return true; + } + + return false; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != AUTHENTICATE_REQUEST_CODE) return; + + if (resultCode == RESULT_OK) { + handleAuthenticated(); + } else { + Log.w(TAG, "Authentication failed"); + hadFailure = true; + } + } + + private void handleLogSubmit() { + Intent intent = new Intent(this, SubmitDebugLogActivity.class); + startActivity(intent); + } + + private void handlePassphrase() { + try { + Editable text = passphraseText.getText(); + String passphrase = (text == null ? "" : text.toString()); + MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, passphrase); + + setMasterSecret(masterSecret); + } catch (InvalidPassphraseException ipe) { + passphraseText.setText(""); + passphraseText.setError( + getString(R.string.PassphrasePromptActivity_invalid_passphrase_exclamation)); + } + } + + private void handleAuthenticated() { + try { + authenticated = true; + + MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + setMasterSecret(masterSecret); + } catch (InvalidPassphraseException e) { + throw new AssertionError(e); + } + } + + private void setPassphraseVisibility(boolean visibility) { + int cursorPosition = passphraseText.getSelectionStart(); + if (visibility) { + passphraseText.setInputType(InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); + } else { + passphraseText.setInputType(InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_VARIATION_PASSWORD); + } + passphraseText.setSelection(cursorPosition); + } + + private void initializeResources() { + + ImageButton okButton = findViewById(R.id.ok_button); + Toolbar toolbar = findViewById(R.id.toolbar); + + showButton = findViewById(R.id.passphrase_visibility); + hideButton = findViewById(R.id.passphrase_visibility_off); + visibilityToggle = findViewById(R.id.button_toggle); + passphraseText = findViewById(R.id.passphrase_edit); + passphraseAuthContainer = findViewById(R.id.password_auth_container); + fingerprintPrompt = findViewById(R.id.fingerprint_auth_container); + lockScreenButton = findViewById(R.id.lock_screen_auth_container); + biometricManager = BiometricManager.from(this); + biometricPrompt = new BiometricPrompt(this, new BiometricAuthenticationListener()); + biometricPromptInfo = new BiometricPrompt.PromptInfo + .Builder() + .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS) + .setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal)) + .build(); + + setSupportActionBar(toolbar); + getSupportActionBar().setTitle(""); + + SpannableString hint = new SpannableString(" " + getString(R.string.PassphrasePromptActivity_enter_passphrase)); + hint.setSpan(new RelativeSizeSpan(0.9f), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + hint.setSpan(new TypefaceSpan("sans-serif"), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + passphraseText.setHint(hint); + okButton.setOnClickListener(new OkButtonClickListener()); + showButton.setOnClickListener(new ShowButtonOnClickListener()); + hideButton.setOnClickListener(new HideButtonOnClickListener()); + passphraseText.setOnEditorActionListener(new PassphraseActionListener()); + passphraseText.setImeActionLabel(getString(R.string.prompt_passphrase_activity__unlock), + EditorInfo.IME_ACTION_DONE); + + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); + + lockScreenButton.setOnClickListener(v -> resumeScreenLock(true)); + } + + private void setLockTypeVisibility() { + if (TextSecurePreferences.isScreenLockEnabled(this)) { + passphraseAuthContainer.setVisibility(View.GONE); + fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE + : View.GONE); + lockScreenButton.setVisibility(View.VISIBLE); + } else { + passphraseAuthContainer.setVisibility(View.VISIBLE); + fingerprintPrompt.setVisibility(View.GONE); + lockScreenButton.setVisibility(View.GONE); + } + } + + private void resumeScreenLock(boolean force) { + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + + assert keyguardManager != null; + + if (!keyguardManager.isKeyguardSecure()) { + Log.w(TAG ,"Keyguard not secure..."); + handleAuthenticated(); + return; + } + + if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) { + if (force) { + Log.i(TAG, "Listening for biometric authentication..."); + biometricPrompt.authenticate(biometricPromptInfo); + } else { + Log.i(TAG, "Skipping show system biometric dialog unless forced"); + } + } else if (Build.VERSION.SDK_INT >= 21) { + if (force) { + Log.i(TAG, "firing intent..."); + Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), ""); + startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE); + } else { + Log.i(TAG, "Skipping firing intent unless forced"); + } + } else { + Log.w(TAG, "Not compatible..."); + handleAuthenticated(); + } + } + + private void sendEmailToSupport() { + String body = SupportEmailUtil.generateSupportEmailBody(this, + R.string.PassphrasePromptActivity_signal_android_lock_screen, + null, + null); + CommunicationActions.openEmail(this, + SupportEmailUtil.getSupportEmailAddress(this), + getString(R.string.PassphrasePromptActivity_signal_android_lock_screen), + body); + } + + private class PassphraseActionListener implements TextView.OnEditorActionListener { + @Override + public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) { + if ((keyEvent == null && actionId == EditorInfo.IME_ACTION_DONE) || + (keyEvent != null && keyEvent.getAction() == KeyEvent.ACTION_DOWN && + (actionId == EditorInfo.IME_NULL))) + { + handlePassphrase(); + return true; + } else if (keyEvent != null && keyEvent.getAction() == KeyEvent.ACTION_UP && + actionId == EditorInfo.IME_NULL) + { + return true; + } + + return false; + } + } + + private class OkButtonClickListener implements OnClickListener { + @Override + public void onClick(View v) { + handlePassphrase(); + } + } + + private class ShowButtonOnClickListener implements OnClickListener { + @Override + public void onClick(View v) { + visibilityToggle.display(hideButton); + setPassphraseVisibility(true); + } + } + + private class HideButtonOnClickListener implements OnClickListener { + @Override + public void onClick(View v) { + visibilityToggle.display(showButton); + setPassphraseVisibility(false); + } + } + + @Override + protected void cleanup() { + this.passphraseText.setText(""); + System.gc(); + } + + private class BiometricAuthenticationListener extends BiometricPrompt.AuthenticationCallback { + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errorString) { + Log.w(TAG, "Authentication error: " + errorCode); + hadFailure = true; + + if (errorCode != BiometricPrompt.ERROR_CANCELED && errorCode != BiometricPrompt.ERROR_USER_CANCELED) { + onAuthenticationFailed(); + } + } + + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + Log.i(TAG, "onAuthenticationSucceeded"); + fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + handleAuthenticated(); + + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); + } + }).start(); + } + + @Override + public void onAuthenticationFailed() { + Log.w(TAG, "onAuthenticationFailed()"); + + fingerprintPrompt.setImageResource(R.drawable.ic_close_white_48dp); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.SRC_IN); + + TranslateAnimation shake = new TranslateAnimation(0, 30, 0, 0); + shake.setDuration(50); + shake.setRepeatCount(7); + shake.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + fingerprintPrompt.startAnimation(shake); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java new file mode 100644 index 00000000..cfe7e3f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java @@ -0,0 +1,262 @@ +package org.thoughtcrime.securesms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.signal.core.util.logging.Log; +import org.signal.core.util.tracing.Tracer; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity; +import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.pin.PinRestoreActivity; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; + +public abstract class PassphraseRequiredActivity extends BaseActivity implements MasterSecretListener { + private static final String TAG = PassphraseRequiredActivity.class.getSimpleName(); + + public static final String LOCALE_EXTRA = "locale_extra"; + public static final String NEXT_INTENT_EXTRA = "next_intent"; + + private static final int STATE_NORMAL = 0; + private static final int STATE_CREATE_PASSPHRASE = 1; + private static final int STATE_PROMPT_PASSPHRASE = 2; + private static final int STATE_UI_BLOCKING_UPGRADE = 3; + private static final int STATE_WELCOME_PUSH_SCREEN = 4; + private static final int STATE_ENTER_SIGNAL_PIN = 5; + private static final int STATE_CREATE_PROFILE_NAME = 6; + private static final int STATE_CREATE_SIGNAL_PIN = 7; + + private SignalServiceNetworkAccess networkAccess; + private BroadcastReceiver clearKeyReceiver; + + @Override + protected final void onCreate(Bundle savedInstanceState) { + Tracer.getInstance().start(Log.tag(getClass()) + "#onCreate()"); + AppStartup.getInstance().onCriticalRenderEventStart(); + this.networkAccess = new SignalServiceNetworkAccess(this); + onPreCreate(); + + final boolean locked = KeyCachingService.isLocked(this); + routeApplicationState(locked); + + super.onCreate(savedInstanceState); + + if (!isFinishing()) { + initializeClearKeyReceiver(); + onCreate(savedInstanceState, true); + } + + AppStartup.getInstance().onCriticalRenderEventEnd(); + Tracer.getInstance().end(Log.tag(getClass()) + "#onCreate()"); + } + + protected void onPreCreate() {} + protected void onCreate(Bundle savedInstanceState, boolean ready) {} + + @Override + protected void onResume() { + super.onResume(); + + if (networkAccess.isCensored(this)) { + ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob()); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + removeClearKeyReceiver(this); + } + + @Override + public void onMasterSecretCleared() { + Log.d(TAG, "onMasterSecretCleared()"); + if (ApplicationDependencies.getAppForegroundObserver().isForegrounded()) routeApplicationState(true); + else finish(); + } + + protected T initFragment(@IdRes int target, + @NonNull T fragment) + { + return initFragment(target, fragment, null); + } + + protected T initFragment(@IdRes int target, + @NonNull T fragment, + @Nullable Locale locale) + { + return initFragment(target, fragment, locale, null); + } + + protected T initFragment(@IdRes int target, + @NonNull T fragment, + @Nullable Locale locale, + @Nullable Bundle extras) + { + Bundle args = new Bundle(); + args.putSerializable(LOCALE_EXTRA, locale); + + if (extras != null) { + args.putAll(extras); + } + + fragment.setArguments(args); + getSupportFragmentManager().beginTransaction() + .replace(target, fragment) + .commitAllowingStateLoss(); + return fragment; + } + + private void routeApplicationState(boolean locked) { + Intent intent = getIntentForState(getApplicationState(locked)); + if (intent != null) { + startActivity(intent); + finish(); + } + } + + private Intent getIntentForState(int state) { + Log.d(TAG, "routeApplicationState(), state: " + state); + + switch (state) { + case STATE_CREATE_PASSPHRASE: return getCreatePassphraseIntent(); + case STATE_PROMPT_PASSPHRASE: return getPromptPassphraseIntent(); + case STATE_UI_BLOCKING_UPGRADE: return getUiBlockingUpgradeIntent(); + case STATE_WELCOME_PUSH_SCREEN: return getPushRegistrationIntent(); + case STATE_ENTER_SIGNAL_PIN: return getEnterSignalPinIntent(); + case STATE_CREATE_SIGNAL_PIN: return getCreateSignalPinIntent(); + case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent(); + default: return null; + } + } + + private int getApplicationState(boolean locked) { + if (!MasterSecretUtil.isPassphraseInitialized(this)) { + return STATE_CREATE_PASSPHRASE; + } else if (locked) { + return STATE_PROMPT_PASSPHRASE; + } else if (ApplicationMigrations.isUpdate(this) && ApplicationMigrations.isUiBlockingMigrationRunning()) { + return STATE_UI_BLOCKING_UPGRADE; + } else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) { + return STATE_WELCOME_PUSH_SCREEN; + } else if (SignalStore.storageServiceValues().needsAccountRestore()) { + return STATE_ENTER_SIGNAL_PIN; + } else if (userMustSetProfileName()) { + return STATE_CREATE_PROFILE_NAME; + } else if (userMustCreateSignalPin()) { + return STATE_CREATE_SIGNAL_PIN; + } else { + return STATE_NORMAL; + } + } + + private boolean userMustCreateSignalPin() { + return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut(); + } + + private boolean userMustSetProfileName() { + return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty(); + } + + private Intent getCreatePassphraseIntent() { + return getRoutedIntent(PassphraseCreateActivity.class, getIntent()); + } + + private Intent getPromptPassphraseIntent() { + Intent intent = getRoutedIntent(PassphrasePromptActivity.class, getIntent()); + intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, ApplicationDependencies.getAppForegroundObserver().isForegrounded()); + return intent; + } + + private Intent getUiBlockingUpgradeIntent() { + return getRoutedIntent(ApplicationMigrationActivity.class, + TextSecurePreferences.hasPromptedPushRegistration(this) + ? getConversationListIntent() + : getPushRegistrationIntent()); + } + + private Intent getPushRegistrationIntent() { + return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent()); + } + + private Intent getEnterSignalPinIntent() { + return getRoutedIntent(PinRestoreActivity.class, getIntent()); + } + + private Intent getCreateSignalPinIntent() { + + final Intent intent; + if (userMustSetProfileName()) { + intent = getCreateProfileNameIntent(); + } else { + intent = getIntent(); + } + + return getRoutedIntent(CreateKbsPinActivity.class, intent); + } + + private Intent getCreateProfileNameIntent() { + return getRoutedIntent(EditProfileActivity.class, getIntent()); + } + + private Intent getRoutedIntent(Class destination, @Nullable Intent nextIntent) { + final Intent intent = new Intent(this, destination); + if (nextIntent != null) intent.putExtra("next_intent", nextIntent); + return intent; + } + + private Intent getConversationListIntent() { + // TODO [greyson] Navigation + return MainActivity.clearTop(this); + } + + private void initializeClearKeyReceiver() { + this.clearKeyReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "onReceive() for clear key event. PasswordDisabled: " + TextSecurePreferences.isPasswordDisabled(context) + ", ScreenLock: " + TextSecurePreferences.isScreenLockEnabled(context)); + if (TextSecurePreferences.isScreenLockEnabled(context) || !TextSecurePreferences.isPasswordDisabled(context)) { + onMasterSecretCleared(); + } + } + }; + + IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); + registerReceiver(clearKeyReceiver, filter, KeyCachingService.KEY_PERMISSION, null); + } + + private void removeClearKeyReceiver(Context context) { + if (clearKeyReceiver != null) { + context.unregisterReceiver(clearKeyReceiver); + clearKeyReceiver = null; + } + } + + /** + * Puts an extra in {@code intent} so that {@code nextIntent} will be shown after it. + */ + public static @NonNull Intent chainIntent(@NonNull Intent intent, @NonNull Intent nextIntent) { + intent.putExtra(NEXT_INTENT_EXTRA, nextIntent); + return intent; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java new file mode 100644 index 00000000..ff4029fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.os.Bundle; + +import androidx.fragment.app.FragmentActivity; + +public class PlayServicesProblemActivity extends FragmentActivity { + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + PlayServicesProblemFragment fragment = new PlayServicesProblemFragment(); + fragment.show(getSupportFragmentManager(), "dialog"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java new file mode 100644 index 00000000..9864872f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java @@ -0,0 +1,66 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.android.gms.common.GoogleApiAvailability; + +public class PlayServicesProblemFragment extends DialogFragment { + + @Override + public @NonNull Dialog onCreateDialog(@Nullable Bundle bundle) { + int code = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getActivity()); + Dialog dialog = GoogleApiAvailability.getInstance().getErrorDialog(getActivity(), code, 9111); + + if (dialog == null) { + return new AlertDialog.Builder(requireActivity()) + .setNegativeButton(android.R.string.ok, null) + .setMessage(R.string.PlayServicesProblemFragment_the_version_of_google_play_services_you_have_installed_is_not_functioning) + .create(); + } else { + return dialog; + } + } + + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); + finish(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + finish(); + } + + private void finish() { + Activity activity = getActivity(); + if (activity != null) activity.finish(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java new file mode 100644 index 00000000..c7d40024 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.Button; + +import org.thoughtcrime.securesms.preferences.MmsPreferencesActivity; + +public class PromptMmsActivity extends PassphraseRequiredActivity { + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.prompt_apn_activity); + initializeResources(); + } + + private void initializeResources() { + Button okButton = findViewById(R.id.ok_button); + Button cancelButton = findViewById(R.id.cancel_button); + + okButton.setOnClickListener(v -> { + Intent intent = new Intent(PromptMmsActivity.this, MmsPreferencesActivity.class); + intent.putExtras(PromptMmsActivity.this.getIntent().getExtras()); + startActivity(intent); + finish(); + }); + + cancelButton.setOnClickListener(v -> finish()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java new file mode 100644 index 00000000..5e729813 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.content.Intent; +import android.os.Bundle; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.ArrayList; +import java.util.List; + +/** + * Activity container for selecting a list of contacts. + * + * @author Moxie Marlinspike + * + */ +public class PushContactSelectionActivity extends ContactSelectionActivity { + + public static final String KEY_SELECTED_RECIPIENTS = "recipients"; + + @SuppressWarnings("unused") + private final static String TAG = PushContactSelectionActivity.class.getSimpleName(); + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + super.onCreate(icicle, ready); + + initializeToolbar(); + } + + protected void initializeToolbar() { + getToolbar().setNavigationIcon(R.drawable.ic_check_24); + getToolbar().setNavigationOnClickListener(v -> { + onFinishedSelection(); + }); + } + + protected final void onFinishedSelection() { + Intent resultIntent = getIntent(); + List selectedContacts = contactsFragment.getSelectedContacts(); + List recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList(); + + resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients)); + + setResult(RESULT_OK, resultIntent); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java new file mode 100644 index 00000000..28f1dd58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.TaskStackBuilder; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CommunicationActions; + +public class ShortcutLauncherActivity extends AppCompatActivity { + + private static final String KEY_RECIPIENT = "recipient_id"; + + public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) { + Intent intent = new Intent(context, ShortcutLauncherActivity.class); + intent.setAction(Intent.ACTION_MAIN); + intent.putExtra(KEY_RECIPIENT, recipientId.serialize()); + + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String rawId = getIntent().getStringExtra(KEY_RECIPIENT); + + if (rawId == null) { + Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show(); + // TODO [greyson] Navigation + startActivity(MainActivity.clearTop(this)); + finish(); + return; + } + + Recipient recipient = Recipient.live(RecipientId.from(rawId)).get(); + // TODO [greyson] Navigation + TaskStackBuilder backStack = TaskStackBuilder.create(this) + .addNextIntent(MainActivity.clearTop(this)); + + CommunicationActions.startConversation(this, recipient, null, backStack); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java new file mode 100644 index 00000000..53686d8f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Rfc5724Uri; + +import java.net.URISyntaxException; + +public class SmsSendtoActivity extends Activity { + + private static final String TAG = SmsSendtoActivity.class.getSimpleName(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + startActivity(getNextIntent(getIntent())); + finish(); + super.onCreate(savedInstanceState); + } + + private Intent getNextIntent(Intent original) { + DestinationAndBody destination; + + if (original.getAction().equals(Intent.ACTION_SENDTO)) { + destination = getDestinationForSendTo(original); + } else if (original.getData() != null && "content".equals(original.getData().getScheme())) { + destination = getDestinationForSyncAdapter(original); + } else { + destination = getDestinationForView(original); + } + + final Intent nextIntent; + + if (TextUtils.isEmpty(destination.destination)) { + nextIntent = new Intent(this, NewConversationActivity.class); + nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody()); + Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show(); + } else { + Recipient recipient = Recipient.external(this, destination.getDestination()); + long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()); + + nextIntent = ConversationIntents.createBuilder(this, recipient.getId(), threadId) + .withDraftText(destination.getBody()) + .build(); + } + return nextIntent; + } + + private @NonNull DestinationAndBody getDestinationForSendTo(Intent intent) { + return new DestinationAndBody(intent.getData().getSchemeSpecificPart(), + intent.getStringExtra("sms_body")); + } + + private @NonNull DestinationAndBody getDestinationForView(Intent intent) { + try { + Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString()); + return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body")); + } catch (URISyntaxException e) { + Log.w(TAG, "unable to parse RFC5724 URI from intent", e); + return new DestinationAndBody("", ""); + } + } + + private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) { + Cursor cursor = null; + + try { + cursor = getContentResolver().query(intent.getData(), null, null, null, null); + + if (cursor != null && cursor.moveToNext()) { + return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), ""); + } + + return new DestinationAndBody("", ""); + } finally { + if (cursor != null) cursor.close(); + } + } + + private static class DestinationAndBody { + private final String destination; + private final String body; + + private DestinationAndBody(String destination, String body) { + this.destination = destination; + this.body = body; + } + + public String getDestination() { + return destination; + } + + public String getBody() { + return body; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java b/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java new file mode 100644 index 00000000..f207b5d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms; + +public class TextSecureExpiredException extends Exception { + public TextSecureExpiredException(String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java new file mode 100644 index 00000000..902bd755 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.CharacterCalculator; +import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; +import org.whispersystems.libsignal.util.guava.Optional; + +public class TransportOption implements Parcelable { + + public enum Type { + SMS, + TEXTSECURE + } + + private final int drawable; + private final int backgroundColor; + private final @NonNull String text; + private final @NonNull Type type; + private final @NonNull String composeHint; + private final @NonNull CharacterCalculator characterCalculator; + private final @NonNull Optional simName; + private final @NonNull Optional simSubscriptionId; + + public TransportOption(@NonNull Type type, + @DrawableRes int drawable, + int backgroundColor, + @NonNull String text, + @NonNull String composeHint, + @NonNull CharacterCalculator characterCalculator) + { + this(type, drawable, backgroundColor, text, composeHint, characterCalculator, + Optional.absent(), Optional.absent()); + } + + public TransportOption(@NonNull Type type, + @DrawableRes int drawable, + int backgroundColor, + @NonNull String text, + @NonNull String composeHint, + @NonNull CharacterCalculator characterCalculator, + @NonNull Optional simName, + @NonNull Optional simSubscriptionId) + { + this.type = type; + this.drawable = drawable; + this.backgroundColor = backgroundColor; + this.text = text; + this.composeHint = composeHint; + this.characterCalculator = characterCalculator; + this.simName = simName; + this.simSubscriptionId = simSubscriptionId; + } + + TransportOption(Parcel in) { + this(Type.valueOf(in.readString()), + in.readInt(), + in.readInt(), + in.readString(), + in.readString(), + CharacterCalculator.readFromParcel(in), + Optional.fromNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)), + in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.absent()); + } + + public @NonNull Type getType() { + return type; + } + + public boolean isType(Type type) { + return this.type == type; + } + + public boolean isSms() { + return type == Type.SMS; + } + + public CharacterState calculateCharacters(String messageBody) { + return characterCalculator.calculateCharacters(messageBody); + } + + public @DrawableRes int getDrawable() { + return drawable; + } + + public int getBackgroundColor() { + return backgroundColor; + } + + public @NonNull String getComposeHint() { + return composeHint; + } + + public @NonNull String getDescription() { + return text; + } + + @NonNull + public Optional getSimName() { + return simName; + } + + @NonNull + public Optional getSimSubscriptionId() { + return simSubscriptionId; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(type.name()); + dest.writeInt(drawable); + dest.writeInt(backgroundColor); + dest.writeString(text); + dest.writeString(composeHint); + CharacterCalculator.writeToParcel(dest, characterCalculator); + TextUtils.writeToParcel(simName.orNull(), dest, flags); + + if (simSubscriptionId.isPresent()) { + dest.writeInt(1); + dest.writeInt(simSubscriptionId.get()); + } else { + dest.writeInt(0); + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public TransportOption createFromParcel(Parcel in) { + return new TransportOption(in); + } + + @Override + public TransportOption[] newArray(int size) { + return new TransportOption[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java new file mode 100644 index 00000000..386c1e59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java @@ -0,0 +1,224 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.CharacterCalculator; +import org.thoughtcrime.securesms.util.MmsCharacterCalculator; +import org.thoughtcrime.securesms.util.PushCharacterCalculator; +import org.thoughtcrime.securesms.util.SmsCharacterCalculator; +import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat; +import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +import static org.thoughtcrime.securesms.TransportOption.Type; + +public class TransportOptions { + + private static final String TAG = TransportOptions.class.getSimpleName(); + + private final List listeners = new LinkedList<>(); + private final Context context; + private final List enabledTransports; + + private Type defaultTransportType = Type.SMS; + private Optional defaultSubscriptionId = Optional.absent(); + private Optional selectedOption = Optional.absent(); + + private final Optional systemSubscriptionId; + + public TransportOptions(Context context, boolean media) { + this.context = context; + this.enabledTransports = initializeAvailableTransports(media); + this.systemSubscriptionId = new SubscriptionManagerCompat(context).getPreferredSubscriptionId(); + } + + public void reset(boolean media) { + List transportOptions = initializeAvailableTransports(media); + + this.enabledTransports.clear(); + this.enabledTransports.addAll(transportOptions); + + if (selectedOption.isPresent() && !isEnabled(selectedOption.get())) { + setSelectedTransport(null); + } else { + this.defaultTransportType = Type.SMS; + this.defaultSubscriptionId = Optional.absent(); + + notifyTransportChangeListeners(); + } + } + + public void setDefaultTransport(Type type) { + this.defaultTransportType = type; + + if (!selectedOption.isPresent()) { + notifyTransportChangeListeners(); + } + } + + public void setDefaultSubscriptionId(Optional subscriptionId) { + if (defaultSubscriptionId.equals(subscriptionId)) { + return; + } + + this.defaultSubscriptionId = subscriptionId; + + if (!selectedOption.isPresent()) { + notifyTransportChangeListeners(); + } + } + + public void setSelectedTransport(@Nullable TransportOption transportOption) { + this.selectedOption = Optional.fromNullable(transportOption); + notifyTransportChangeListeners(); + } + + public boolean isManualSelection() { + return this.selectedOption.isPresent(); + } + + public @NonNull TransportOption getSelectedTransport() { + if (selectedOption.isPresent()) return selectedOption.get(); + + if (defaultTransportType == Type.SMS) { + TransportOption transportOption = findEnabledSmsTransportOption(defaultSubscriptionId.or(systemSubscriptionId)); + if (transportOption != null) { + return transportOption; + } + } + + for (TransportOption transportOption : enabledTransports) { + if (transportOption.getType() == defaultTransportType) { + return transportOption; + } + } + + throw new AssertionError("No options of default type!"); + } + + public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) { + return new TransportOption(Type.TEXTSECURE, + R.drawable.ic_send_lock_24, + context.getResources().getColor(R.color.core_ultramarine), + context.getString(R.string.ConversationActivity_transport_signal), + context.getString(R.string.conversation_activity__type_message_push), + new PushCharacterCalculator()); + + } + + private @Nullable TransportOption findEnabledSmsTransportOption(Optional subscriptionId) { + if (subscriptionId.isPresent()) { + final int subId = subscriptionId.get(); + + for (TransportOption transportOption : enabledTransports) { + if (transportOption.getType() == Type.SMS && + subId == transportOption.getSimSubscriptionId().or(-1)) { + return transportOption; + } + } + } + return null; + } + + public void disableTransport(Type type) { + TransportOption selected = selectedOption.orNull(); + + Iterator iterator = enabledTransports.iterator(); + while (iterator.hasNext()) { + TransportOption option = iterator.next(); + + if (option.isType(type)) { + if (selected == option) { + setSelectedTransport(null); + } + iterator.remove(); + } + } + } + + public List getEnabledTransports() { + return enabledTransports; + } + + public void addOnTransportChangedListener(OnTransportChangedListener listener) { + this.listeners.add(listener); + } + + private List initializeAvailableTransports(boolean isMediaMessage) { + List results = new LinkedList<>(); + + if (isMediaMessage) { + results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_mms), + context.getString(R.string.conversation_activity__type_message_mms_insecure), + new MmsCharacterCalculator())); + } else { + results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_sms), + context.getString(R.string.conversation_activity__type_message_sms_insecure), + new SmsCharacterCalculator())); + } + + results.add(getPushTransportOption(context)); + + return results; + } + + private @NonNull List getTransportOptionsForSimCards(@NonNull String text, + @NonNull String composeHint, + @NonNull CharacterCalculator characterCalculator) + { + List results = new LinkedList<>(); + SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(context); + Collection subscriptions; + + if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE)) { + subscriptions = subscriptionManager.getActiveAndReadySubscriptionInfos(); + } else { + subscriptions = Collections.emptyList(); + } + + if (subscriptions.size() < 2) { + results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24, + context.getResources().getColor(R.color.core_grey_50), + text, composeHint, characterCalculator)); + } else { + for (SubscriptionInfoCompat subscriptionInfo : subscriptions) { + results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24, + context.getResources().getColor(R.color.core_grey_50), + text, composeHint, characterCalculator, + Optional.of(subscriptionInfo.getDisplayName()), + Optional.of(subscriptionInfo.getSubscriptionId()))); + } + } + + return results; + } + + private void notifyTransportChangeListeners() { + for (OnTransportChangedListener listener : listeners) { + listener.onChange(getSelectedTransport(), selectedOption.isPresent()); + } + } + + private boolean isEnabled(TransportOption transportOption) { + for (TransportOption option : enabledTransports) { + if (option.equals(transportOption)) return true; + } + + return false; + } + + public interface OnTransportChangedListener { + public void onChange(TransportOption newTransport, boolean manuallySelected); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java new file mode 100644 index 00000000..b7bfaa44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.graphics.PorterDuff.Mode; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import java.util.List; + +public class TransportOptionsAdapter extends BaseAdapter { + + private final LayoutInflater inflater; + + private List enabledTransports; + + public TransportOptionsAdapter(@NonNull Context context, + @NonNull List enabledTransports) + { + super(); + this.inflater = LayoutInflater.from(context); + this.enabledTransports = enabledTransports; + } + + public void setEnabledTransports(List enabledTransports) { + this.enabledTransports = enabledTransports; + } + + @Override + public int getCount() { + return enabledTransports.size(); + } + + @Override + public Object getItem(int position) { + return enabledTransports.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = inflater.inflate(R.layout.transport_selection_list_item, parent, false); + } + + TransportOption transport = (TransportOption) getItem(position); + ImageView imageView = convertView.findViewById(R.id.icon); + TextView textView = convertView.findViewById(R.id.text); + TextView subtextView = convertView.findViewById(R.id.subtext); + + imageView.getBackground().setColorFilter(transport.getBackgroundColor(), Mode.MULTIPLY); + imageView.setImageResource(transport.getDrawable()); + textView.setText(transport.getDescription()); + + if (transport.getSimName().isPresent()) { + subtextView.setText(transport.getSimName().get()); + subtextView.setVisibility(View.VISIBLE); + } else { + subtextView.setVisibility(View.GONE); + } + + return convertView; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java new file mode 100644 index 00000000..63e8096f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.ListPopupWindow; + +import java.util.LinkedList; +import java.util.List; + +public class TransportOptionsPopup extends ListPopupWindow implements ListView.OnItemClickListener { + + private final TransportOptionsAdapter adapter; + private final SelectedListener listener; + + public TransportOptionsPopup(@NonNull Context context, @NonNull View anchor, @NonNull SelectedListener listener) { + super(context); + this.listener = listener; + this.adapter = new TransportOptionsAdapter(context, new LinkedList()); + + setVerticalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_yoff)); + setHorizontalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_xoff)); + setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + setModal(true); + setAnchorView(anchor); + setAdapter(adapter); + setContentWidth(context.getResources().getDimensionPixelSize(R.dimen.transport_selection_popup_width)); + + setOnItemClickListener(this); + } + + public void display(List enabledTransports) { + adapter.setEnabledTransports(enabledTransports); + adapter.notifyDataSetChanged(); + show(); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + listener.onSelected((TransportOption)adapter.getItem(position)); + } + + public interface SelectedListener { + void onSelected(TransportOption option); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java b/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java new file mode 100644 index 00000000..3dd5cd8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms; + +public interface Unbindable { + public void unbind(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java new file mode 100644 index 00000000..e864f636 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -0,0 +1,700 @@ +/* + * Copyright (C) 2016-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.animation.TypeEvaluator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Vibrator; +import android.text.Html; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnticipateInterpolator; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.components.camera.CameraView; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.qr.ScanListener; +import org.thoughtcrime.securesms.qr.ScanningThread; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.fingerprint.Fingerprint; +import org.whispersystems.libsignal.fingerprint.FingerprintParsingException; +import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException; +import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.Locale; + +/** + * Activity for verifying identity keys. + * + * @author Moxie Marlinspike + */ +@SuppressLint("StaticFieldLeak") +public class VerifyIdentityActivity extends PassphraseRequiredActivity implements ScanListener, View.OnClickListener { + + private static final String TAG = Log.tag(VerifyIdentityActivity.class); + + private static final String RECIPIENT_EXTRA = "recipient_id"; + private static final String IDENTITY_EXTRA = "recipient_identity"; + private static final String VERIFIED_EXTRA = "verified_state"; + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + + private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment(); + private final VerifyScanFragment scanFragment = new VerifyScanFragment(); + + public static Intent newIntent(@NonNull Context context, + @NonNull IdentityDatabase.IdentityRecord identityRecord) + { + return newIntent(context, + identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + identityRecord.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED); + } + + public static Intent newIntent(@NonNull Context context, + @NonNull IdentityDatabase.IdentityRecord identityRecord, + boolean verified) + { + return newIntent(context, + identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + verified); + } + + public static Intent newIntent(@NonNull Context context, + @NonNull RecipientId recipientId, + @NonNull IdentityKey identityKey, + boolean verified) + { + Intent intent = new Intent(context, VerifyIdentityActivity.class); + + intent.putExtra(RECIPIENT_EXTRA, recipientId); + intent.putExtra(IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey)); + intent.putExtra(VERIFIED_EXTRA, verified); + + return intent; + } + + @Override + public void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle state, boolean ready) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.AndroidManifest__verify_safety_number); + + Bundle extras = new Bundle(); + extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA)); + extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA)); + extras.putParcelable(VerifyDisplayFragment.LOCAL_IDENTITY, new IdentityKeyParcelable(IdentityKeyUtil.getIdentityKey(this))); + extras.putString(VerifyDisplayFragment.LOCAL_NUMBER, TextSecurePreferences.getLocalNumber(this)); + extras.putBoolean(VerifyDisplayFragment.VERIFIED_STATE, getIntent().getBooleanExtra(VERIFIED_EXTRA, false)); + + scanFragment.setScanListener(this); + displayFragment.setClickListener(this); + + initFragment(android.R.id.content, displayFragment, Locale.getDefault(), extras); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + + return false; + } + + @Override + public void onQrDataFound(final String data) { + Util.runOnMain(() -> { + ((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50); + + getSupportFragmentManager().popBackStack(); + displayFragment.setScannedFingerprint(data); + }); + } + + @Override + public void onClick(View v) { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied)) + .onAllGranted(() -> { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + transaction.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom, + R.anim.slide_from_bottom, R.anim.slide_to_top); + + transaction.replace(android.R.id.content, scanFragment) + .addToBackStack(null) + .commitAllowingStateLoss(); + }) + .onAnyDenied(() -> Toast.makeText(this, R.string.VerifyIdentityActivity_unable_to_scan_qr_code_without_camera_permission, Toast.LENGTH_LONG).show()) + .execute(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void setActionBarNotificationBarColor(MaterialColor color) { + getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this))); + + WindowUtil.setStatusBarColor(getWindow(), color.toStatusBarColor(this)); + } + + public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener { + + public static final String RECIPIENT_ID = "recipient_id"; + public static final String REMOTE_NUMBER = "remote_number"; + public static final String REMOTE_IDENTITY = "remote_identity"; + public static final String LOCAL_IDENTITY = "local_identity"; + public static final String LOCAL_NUMBER = "local_number"; + public static final String VERIFIED_STATE = "verified_state"; + + private LiveRecipient recipient; + private IdentityKey localIdentity; + private IdentityKey remoteIdentity; + private Fingerprint fingerprint; + + private View container; + private View numbersContainer; + private ImageView qrCode; + private ImageView qrVerified; + private TextView tapLabel; + private TextView description; + private View.OnClickListener clickListener; + private SwitchCompat verified; + + private TextView[] codes = new TextView[12]; + private boolean animateSuccessOnDraw = false; + private boolean animateFailureOnDraw = false; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment); + this.numbersContainer = container.findViewById(R.id.number_table); + this.qrCode = container.findViewById(R.id.qr_code); + this.verified = container.findViewById(R.id.verified_switch); + this.qrVerified = container.findViewById(R.id.qr_verified); + this.description = container.findViewById(R.id.description); + this.tapLabel = container.findViewById(R.id.tap_label); + this.codes[0] = container.findViewById(R.id.code_first); + this.codes[1] = container.findViewById(R.id.code_second); + this.codes[2] = container.findViewById(R.id.code_third); + this.codes[3] = container.findViewById(R.id.code_fourth); + this.codes[4] = container.findViewById(R.id.code_fifth); + this.codes[5] = container.findViewById(R.id.code_sixth); + this.codes[6] = container.findViewById(R.id.code_seventh); + this.codes[7] = container.findViewById(R.id.code_eighth); + this.codes[8] = container.findViewById(R.id.code_ninth); + this.codes[9] = container.findViewById(R.id.code_tenth); + this.codes[10] = container.findViewById(R.id.code_eleventh); + this.codes[11] = container.findViewById(R.id.code_twelth); + + this.qrCode.setOnClickListener(clickListener); + this.registerForContextMenu(numbersContainer); + + this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false)); + this.verified.setOnCheckedChangeListener(this); + + return container; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID); + IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY); + IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY); + + if (recipientId == null) throw new AssertionError("RecipientId required"); + if (localIdentityParcelable == null) throw new AssertionError("local identity required"); + if (remoteIdentityParcelable == null) throw new AssertionError("remote identity required"); + + this.localIdentity = localIdentityParcelable.get(); + this.recipient = Recipient.live(recipientId); + this.remoteIdentity = remoteIdentityParcelable.get(); + + int version; + byte[] localId; + byte[] remoteId; + + //noinspection WrongThread + Recipient resolved = recipient.resolve(); + + if (FeatureFlags.verifyV2() && resolved.getUuid().isPresent()) { + Log.i(TAG, "Using UUID (version 2)."); + version = 2; + localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext())); + remoteId = UuidUtil.toByteArray(resolved.getUuid().get()); + } else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) { + Log.i(TAG, "Using E164 (version 1)."); + version = 1; + localId = TextSecurePreferences.getLocalNumber(requireContext()).getBytes(); + remoteId = resolved.requireE164().getBytes(); + } else { + Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getUuid().isPresent(), resolved.getE164().isPresent())); + new AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext()))) + .setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish()) + .setOnDismissListener(dialog -> requireActivity().finish()) + .show(); + return; + } + + this.recipient.observe(this, this::setRecipientText); + + new AsyncTask() { + @Override + protected Fingerprint doInBackground(Void... params) { + return new NumericFingerprintGenerator(5200).createFor(version, + localId, localIdentity, + remoteId, remoteIdentity); + } + + @Override + protected void onPostExecute(Fingerprint fingerprint) { + VerifyDisplayFragment.this.fingerprint = fingerprint; + setFingerprintViews(fingerprint, true); + getActivity().supportInvalidateOptionsMenu(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + setHasOptionsMenu(true); + } + + @Override + public void onResume() { + super.onResume(); + + setRecipientText(recipient.get()); + + if (fingerprint != null) { + setFingerprintViews(fingerprint, false); + } + + if (animateSuccessOnDraw) { + animateSuccessOnDraw = false; + animateVerifiedSuccess(); + } else if (animateFailureOnDraw) { + animateFailureOnDraw = false; + animateVerifiedFailure(); + } + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, + ContextMenuInfo menuInfo) + { + super.onCreateContextMenu(menu, view, menuInfo); + + if (fingerprint != null) { + MenuInflater inflater = getActivity().getMenuInflater(); + inflater.inflate(R.menu.verify_display_fragment_context_menu, menu); + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + if (fingerprint == null) return super.onContextItemSelected(item); + + switch (item.getItemId()) { + case R.id.menu_copy: handleCopyToClipboard(fingerprint, codes.length); return true; + case R.id.menu_compare: handleCompareWithClipboard(fingerprint); return true; + default: return super.onContextItemSelected(item); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + if (fingerprint != null) { + inflater.inflate(R.menu.verify_identity, menu); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.verify_identity__share: handleShare(fingerprint, codes.length); return true; + } + + return false; + } + + public void setScannedFingerprint(String scanned) { + try { + if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) { + this.animateSuccessOnDraw = true; + } else { + this.animateFailureOnDraw = true; + } + } catch (FingerprintVersionMismatchException e) { + Log.w(TAG, e); + if (e.getOurVersion() < e.getTheirVersion()) { + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show(); + } + } catch (FingerprintParsingException e) { + Log.w(TAG, e); + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show(); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + public void setClickListener(View.OnClickListener listener) { + this.clickListener = listener; + } + + private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) { + String[] segments = getSegments(fingerprint, segmentCount); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < segments.length; i++) { + result.append(segments[i]); + + if (i != segments.length - 1) { + if (((i+1) % 4) == 0) result.append('\n'); + else result.append(' '); + } + } + + return result.toString(); + } + + private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) { + Util.writeTextToClipboard(getActivity(), getFormattedSafetyNumbers(fingerprint, segmentCount)); + } + + private void handleCompareWithClipboard(Fingerprint fingerprint) { + String clipboardData = Util.readTextFromClipboard(getActivity()); + + if (clipboardData == null) { + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show(); + return; + } + + String numericClipboardData = clipboardData.replaceAll("\\D", ""); + + if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) { + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show(); + return; + } + + if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) { + animateVerifiedSuccess(); + } else { + animateVerifiedFailure(); + } + } + + private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) { + String shareString = + getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" + + getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n"; + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, shareString); + intent.setType("text/plain"); + + try { + startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via))); + } catch (ActivityNotFoundException e) { + Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show(); + } + } + + private void setRecipientText(Recipient recipient) { + description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext())))); + description.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void setFingerprintViews(Fingerprint fingerprint, boolean animate) { + String[] segments = getSegments(fingerprint, codes.length); + + for (int i=0;i() { + public Integer evaluate(float fraction, Integer startValue, Integer endValue) { + return Math.round(startValue + (endValue - startValue) * fraction); + } + }); + + valueAnimator.setDuration(1000); + valueAnimator.start(); + } + + private String[] getSegments(Fingerprint fingerprint, int segmentCount) { + String[] segments = new String[segmentCount]; + String digits = fingerprint.getDisplayableFingerprint().getDisplayText(); + int partSize = digits.length() / segmentCount; + + for (int i=0;i { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (isChecked) { + Log.i(TAG, "Saving identity: " + recipientId); + DatabaseFactory.getIdentityDatabase(getActivity()) + .saveIdentity(recipientId, + remoteIdentity, + VerifiedStatus.VERIFIED, false, + System.currentTimeMillis(), true); + } else { + DatabaseFactory.getIdentityDatabase(getActivity()) + .setVerified(recipientId, + remoteIdentity, + VerifiedStatus.DEFAULT); + } + + ApplicationDependencies.getJobManager() + .add(new MultiDeviceVerifiedUpdateJob(recipientId, + remoteIdentity, + isChecked ? VerifiedStatus.VERIFIED + : VerifiedStatus.DEFAULT)); + StorageSyncHelper.scheduleSyncForDataChange(); + + IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false); + } + }); + } + } + + public static class VerifyScanFragment extends Fragment { + + private View container; + private CameraView cameraView; + private ScanningThread scanningThread; + private ScanListener scanListener; + + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment); + this.cameraView = container.findViewById(R.id.scanner); + + return container; + } + + @Override + public void onResume() { + super.onResume(); + this.scanningThread = new ScanningThread(); + this.scanningThread.setScanListener(scanListener); + this.scanningThread.setCharacterSet("ISO-8859-1"); + this.cameraView.onResume(); + this.cameraView.setPreviewCallback(scanningThread); + this.scanningThread.start(); + } + + @Override + public void onPause() { + super.onPause(); + this.cameraView.onPause(); + this.scanningThread.stopScanning(); + } + + @Override + public void onConfigurationChanged(Configuration newConfiguration) { + super.onConfigurationChanged(newConfiguration); + this.cameraView.onPause(); + this.cameraView.onResume(); + this.cameraView.setPreviewCallback(scanningThread); + } + + public void setScanListener(ScanListener listener) { + if (this.scanningThread != null) scanningThread.setScanListener(listener); + this.scanListener = listener; + } + + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java new file mode 100644 index 00000000..da14a2b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -0,0 +1,749 @@ +/* + * Copyright (C) 2016 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms; + +import android.Manifest; +import android.app.PictureInPictureParams; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.util.Rational; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow; +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; +import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; +import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil; +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput; +import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView; +import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; +import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog; +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.EllapsedTimeFormatter; +import org.thoughtcrime.securesms.util.FullscreenHelper; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +import java.util.List; + +public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback { + + private static final String TAG = Log.tag(WebRtcCallActivity.class); + + private static final int STANDARD_DELAY_FINISH = 1000; + + public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION"; + public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION"; + public static final String END_CALL_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".END_CALL_ACTION"; + + public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE"; + + private CallParticipantsListUpdatePopupWindow participantUpdateWindow; + private DeviceOrientationMonitor deviceOrientationMonitor; + + private FullscreenHelper fullscreenHelper; + private WebRtcCallView callScreen; + private TooltipPopup videoTooltip; + private WebRtcCallViewModel viewModel; + private boolean enableVideoIfAvailable; + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + Log.i(TAG, "onCreate()"); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + super.onCreate(savedInstanceState); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.webrtc_call_activity); + + fullscreenHelper = new FullscreenHelper(this); + + setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); + + initializeResources(); + initializeViewModel(); + + processIntent(getIntent()); + + enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false); + getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE); + } + + @Override + public void onResume() { + Log.i(TAG, "onResume()"); + super.onResume(); + initializeScreenshotSecurity(); + + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this); + } + } + + @Override + public void onNewIntent(Intent intent) { + Log.i(TAG, "onNewIntent"); + super.onNewIntent(intent); + processIntent(intent); + } + + @Override + public void onPause() { + Log.i(TAG, "onPause"); + super.onPause(); + + if (!isInPipMode()) { + EventBus.getDefault().unregister(this); + } + + if (!viewModel.isCallStarting()) { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) { + finish(); + } + } + } + + @Override + protected void onStop() { + Log.i(TAG, "onStop"); + super.onStop(); + + EventBus.getDefault().unregister(this); + + if (!viewModel.isCallStarting()) { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_CANCEL_PRE_JOIN_CALL); + startService(intent); + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + protected void onUserLeaveHint() { + enterPipModeIfPossible(); + } + + @Override + public void onBackPressed() { + if (!enterPipModeIfPossible()) { + super.onBackPressed(); + } + } + + @Override + public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { + viewModel.setIsInPipMode(isInPictureInPictureMode); + participantUpdateWindow.setEnabled(!isInPictureInPictureMode); + } + + private boolean enterPipModeIfPossible() { + if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) { + PictureInPictureParams params = new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(9, 16)) + .build(); + enterPictureInPictureMode(params); + CallParticipantsListDialog.dismiss(getSupportFragmentManager()); + + return true; + } + return false; + } + + private boolean isInPipMode() { + return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode(); + } + + private void processIntent(@NonNull Intent intent) { + if (ANSWER_ACTION.equals(intent.getAction())) { + viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient()); + handleAnswerWithAudio(); + } else if (DENY_ACTION.equals(intent.getAction())) { + handleDenyCall(); + } else if (END_CALL_ACTION.equals(intent.getAction())) { + handleEndCall(); + } + } + + private void initializeScreenshotSecurity() { + if (TextSecurePreferences.isScreenSecurityEnabled(this)) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + } + + private void initializeResources() { + callScreen = findViewById(R.id.callScreen); + callScreen.setControlsListener(new ControlsListener()); + + participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen); + } + + private void initializeViewModel() { + deviceOrientationMonitor = new DeviceOrientationMonitor(this); + getLifecycle().addObserver(deviceOrientationMonitor); + + WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor); + + viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class); + viewModel.setIsInPipMode(isInPipMode()); + viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled); + viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls); + viewModel.getEvents().observe(this, this::handleViewModelEvent); + viewModel.getCallTime().observe(this, this::handleCallTime); + viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants); + viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate); + viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent); + viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall()); + viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint); + + callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state != null) { + if (state.needsNewRequestSizes()) { + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS); + startService(intent); + } + } + }); + + viewModel.getOrientation().observe(this, orientation -> { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_ORIENTATION_CHANGED) + .putExtra(WebRtcCallService.EXTRA_ORIENTATION_DEGREES, orientation.getDegrees()); + + startService(intent); + + switch (orientation) { + case LANDSCAPE_LEFT_EDGE: + callScreen.rotateControls(90); + break; + case LANDSCAPE_RIGHT_EDGE: + callScreen.rotateControls(-90); + break; + case PORTRAIT_BOTTOM_EDGE: + callScreen.rotateControls(0); + } + }); + } + + private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) { + if (event instanceof WebRtcCallViewModel.Event.StartCall) { + startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall()); + return; + } else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) { + SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords()); + return; + } + + if (isInPipMode()) { + return; + } + + if (event instanceof WebRtcCallViewModel.Event.ShowVideoTooltip) { + if (videoTooltip == null) { + videoTooltip = TooltipPopup.forTarget(callScreen.getVideoTooltipTarget()) + .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(this, R.color.core_white)) + .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video) + .setOnDismissListener(() -> viewModel.onDismissedVideoTooltip()) + .show(TooltipPopup.POSITION_ABOVE); + return; + } + } else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) { + if (videoTooltip != null) { + videoTooltip.dismiss(); + videoTooltip = null; + } + } else { + throw new IllegalArgumentException("Unknown event: " + event); + } + } + + private void handleCallTime(long callTime) { + EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime); + + if (ellapsedTimeFormatter == null) { + return; + } + + callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString())); + } + + private void handleSetAudioHandset() { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER); + startService(intent); + } + + private void handleSetAudioSpeaker() { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER); + intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, true); + startService(intent); + } + + private void handleSetAudioBluetooth() { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_BLUETOOTH); + intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, true); + startService(intent); + } + + private void handleSetMuteAudio(boolean enabled) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_MUTE_AUDIO); + intent.putExtra(WebRtcCallService.EXTRA_MUTE, enabled); + startService(intent); + } + + private void handleSetMuteVideo(boolean muted) { + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { + String recipientDisplayName = recipient.getDisplayName(this); + + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName), R.drawable.ic_video_solid_24_tinted) + .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName)) + .onAllGranted(() -> { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO); + intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted); + startService(intent); + }) + .execute(); + } + } + + private void handleFlipCamera() { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_FLIP_CAMERA); + startService(intent); + } + + private void handleAnswerWithAudio() { + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)), + R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls)) + .onAllGranted(() -> { + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_answering)); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL); + startService(intent); + }) + .onAnyDenied(this::handleDenyCall) + .execute(); + } + } + + private void handleAnswerWithVideo() { + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)), + R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted) + .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls)) + .onAllGranted(() -> { + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_answering)); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL); + intent.putExtra(WebRtcCallService.EXTRA_ANSWER_WITH_VIDEO, true); + startService(intent); + + handleSetMuteVideo(false); + }) + .onAnyDenied(this::handleDenyCall) + .execute(); + } + } + + private void handleDenyCall() { + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_DENY_CALL); + startService(intent); + + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_ending_call)); + delayedFinish(); + } + } + + private void handleEndCall() { + Log.i(TAG, "Hangup pressed, handling termination now..."); + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_LOCAL_HANGUP); + startService(intent); + } + + private void handleOutgoingCall(@NonNull WebRtcViewModel event) { + if (event.getGroupState().isNotIdle()) { + callScreen.setStatusFromGroupCallState(event.getGroupState()); + } else { + callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)); + } + } + + private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessage.Type hangupType) { + Log.i(TAG, "handleTerminate called: " + hangupType.name()); + + callScreen.setStatusFromHangupType(hangupType); + + EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + + if (hangupType == HangupMessage.Type.NEED_PERMISSION) { + startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this, recipient.getId())); + } + delayedFinish(); + } + + private void handleCallRinging() { + callScreen.setStatus(getString(R.string.RedPhone_ringing)); + } + + private void handleCallBusy() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setStatus(getString(R.string.RedPhone_busy)); + delayedFinish(WebRtcCallService.BUSY_TONE_LENGTH); + } + + private void handleCallConnected(@NonNull WebRtcViewModel event) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES); + if (event.getGroupState().isNotIdleOrConnected()) { + callScreen.setStatusFromGroupCallState(event.getGroupState()); + } + } + + private void handleRecipientUnavailable() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable)); + delayedFinish(); + } + + private void handleServerFailure() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setStatus(getString(R.string.RedPhone_network_failed)); + } + + private void handleNoSuchUser(final @NonNull WebRtcViewModel event) { + if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie + new AlertDialog.Builder(this) + .setTitle(R.string.RedPhone_number_not_registered) + .setIcon(R.drawable.ic_warning) + .setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice) + .setCancelable(true) + .setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL)) + .setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL)) + .show(); + } + + private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) { + final IdentityKey theirKey = event.getRemoteParticipants().get(0).getIdentityKey(); + final Recipient recipient = event.getRemoteParticipants().get(0).getRecipient(); + + if (theirKey == null) { + Log.w(TAG, "Untrusted identity without an identity key, terminating call."); + handleTerminate(recipient, HangupMessage.Type.NORMAL); + } + + SafetyNumberChangeDialog.showForCall(getSupportFragmentManager(), recipient.getId()); + } + + public void handleSafetyNumberChangeEvent(@NonNull WebRtcCallViewModel.SafetyNumberChangeEvent safetyNumberChangeEvent) { + if (Util.hasItems(safetyNumberChangeEvent.getRecipientIds())) { + if (safetyNumberChangeEvent.isInPipMode()) { + GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.getRecipient().get()); + } else { + GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.getRecipient().get()); + SafetyNumberChangeDialog.showForDuringGroupCall(getSupportFragmentManager(), safetyNumberChangeEvent.getRecipientIds()); + } + } + } + + private void updateGroupMembersForGroupCall() { + startService(new Intent(this, WebRtcCallService.class).setAction(WebRtcCallService.ACTION_GROUP_REQUEST_UPDATE_MEMBERS)); + } + + private void updateSpeakerHint(boolean showSpeakerHint) { + if (showSpeakerHint) { + callScreen.showSpeakerViewHint(); + } else { + callScreen.hideSpeakerViewHint(); + } + } + + @Override + public void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients) { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state.getGroupCallState().isConnected()) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_GROUP_APPROVE_SAFETY_CHANGE) + .putExtra(WebRtcCallService.EXTRA_RECIPIENT_IDS, RecipientId.toSerializedList(changedRecipients)); + startService(intent); + } else { + viewModel.startCall(state.getLocalParticipant().isVideoEnabled()); + } + } + + @Override + public void onMessageResentAfterSafetyNumberChange() { } + + @Override + public void onCanceled() { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state != null && state.getGroupCallState().isNotIdle()) { + if (state.getCallState().isPreJoinOrNetworkUnavailable()) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_CANCEL_PRE_JOIN_CALL); + startService(intent); + finish(); + } else { + handleEndCall(); + } + } else { + handleTerminate(viewModel.getRecipient().get(), HangupMessage.Type.NORMAL); + } + } + + private boolean isSystemPipEnabledAndAvailable() { + return Build.VERSION.SDK_INT >= 26 && + getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); + } + + private void delayedFinish() { + delayedFinish(STANDARD_DELAY_FINISH); + } + + private void delayedFinish(int delayMillis) { + callScreen.postDelayed(WebRtcCallActivity.this::finish, delayMillis); + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventMainThread(@NonNull WebRtcViewModel event) { + Log.i(TAG, "Got message from service: " + event); + + viewModel.setRecipient(event.getRecipient()); + callScreen.setRecipient(event.getRecipient()); + + switch (event.getState()) { + case CALL_PRE_JOIN: handleCallPreJoin(event); break; + case CALL_CONNECTED: handleCallConnected(event); break; + case NETWORK_FAILURE: handleServerFailure(); break; + case CALL_RINGING: handleCallRinging(); break; + case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break; + case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break; + case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break; + case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break; + case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break; + case NO_SUCH_USER: handleNoSuchUser(event); break; + case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break; + case CALL_OUTGOING: handleOutgoingCall(event); break; + case CALL_BUSY: handleCallBusy(); break; + case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break; + } + + boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable; + + viewModel.updateFromWebRtcViewModel(event, enableVideo); + + if (enableVideo) { + enableVideoIfAvailable = false; + handleSetMuteVideo(false); + } + } + + private void handleCallPreJoin(@NonNull WebRtcViewModel event) { + if (event.getGroupState().isNotIdle()) { + callScreen.setStatusFromGroupCallState(event.getGroupState()); + } + } + + private void startCall(boolean isVideoCall) { + enableVideoIfAvailable = isVideoCall; + + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(viewModel.getRecipient().getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, (isVideoCall ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL).getCode()); + startService(intent); + + MessageSender.onMessageSent(); + } + + private final class ControlsListener implements WebRtcCallView.ControlsListener { + + @Override + public void onStartCall(boolean isVideoCall) { + viewModel.startCall(isVideoCall); + } + + @Override + public void onCancelStartCall() { + finish(); + } + + @Override + public void onControlsFadeOut() { + if (videoTooltip != null) { + videoTooltip.dismiss(); + } + } + + @Override + public void showSystemUI() { + fullscreenHelper.showSystemUI(); + } + + @Override + public void hideSystemUI() { + fullscreenHelper.hideSystemUI(); + } + + @Override + public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) { + switch (audioOutput) { + case HANDSET: + handleSetAudioHandset(); + break; + case HEADSET: + handleSetAudioBluetooth(); + break; + case SPEAKER: + handleSetAudioSpeaker(); + break; + default: + throw new IllegalStateException("Unknown output: " + audioOutput); + } + } + + @Override + public void onVideoChanged(boolean isVideoEnabled) { + handleSetMuteVideo(!isVideoEnabled); + } + + @Override + public void onMicChanged(boolean isMicEnabled) { + handleSetMuteAudio(!isMicEnabled); + } + + @Override + public void onCameraDirectionChanged() { + handleFlipCamera(); + } + + @Override + public void onEndCallPressed() { + handleEndCall(); + } + + @Override + public void onDenyCallPressed() { + handleDenyCall(); + } + + @Override + public void onAcceptCallWithVoiceOnlyPressed() { + handleAnswerWithAudio(); + } + + @Override + public void onAcceptCallPressed() { + if (viewModel.isAnswerWithVideoAvailable()) { + handleAnswerWithVideo(); + } else { + handleAnswerWithAudio(); + } + } + + @Override + public void onShowParticipantsList() { + CallParticipantsListDialog.show(getSupportFragmentManager()); + } + + @Override + public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) { + viewModel.setIsViewingFocusedParticipant(page); + } + + @Override + public void onLocalPictureInPictureClicked() { + viewModel.onLocalPictureInPictureClicked(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java new file mode 100644 index 00000000..3063a04c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.animation; + + +import android.animation.Animator; + +public abstract class AnimationCompleteListener implements Animator.AnimatorListener { + @Override + public final void onAnimationStart(Animator animation) {} + + @Override + public abstract void onAnimationEnd(Animator animation); + + @Override + public final void onAnimationCancel(Animator animation) {} + @Override + public final void onAnimationRepeat(Animator animation) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java new file mode 100644 index 00000000..28317e9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.animation; + +import android.animation.Animator; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +public final class AnimationRepeatListener implements Animator.AnimatorListener { + + private final Consumer animationConsumer; + + public AnimationRepeatListener(@NonNull Consumer animationConsumer) { + this.animationConsumer = animationConsumer; + } + + @Override + public final void onAnimationStart(Animator animation) { + } + + @Override + public final void onAnimationEnd(Animator animation) { + } + + @Override + public final void onAnimationCancel(Animator animation) { + } + + @Override + public final void onAnimationRepeat(Animator animation) { + this.animationConsumer.accept(animation); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java b/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java new file mode 100644 index 00000000..da935281 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.animation; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.ViewPager; + +/** + * Based on https://developer.android.com/training/animation/screen-slide#depth-page + */ +public final class DepthPageTransformer implements ViewPager.PageTransformer { + private static final float MIN_SCALE = 0.75f; + + public void transformPage(@NonNull View view, float position) { + final int pageWidth = view.getWidth(); + + if (position < -1f) { + view.setAlpha(0f); + + } else if (position <= 0f) { + view.setAlpha(1f); + view.setTranslationX(0f); + view.setScaleX(1f); + view.setScaleY(1f); + + } else if (position <= 1f) { + view.setAlpha(1f - position); + + view.setTranslationX(pageWidth * -position); + + final float scaleFactor = MIN_SCALE + (1f - MIN_SCALE) * (1f - Math.abs(position)); + + view.setScaleX(scaleFactor); + view.setScaleY(scaleFactor); + + } else { + view.setAlpha(0f); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java new file mode 100644 index 00000000..53084843 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.animation; + +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +import androidx.annotation.NonNull; + +public class ResizeAnimation extends Animation { + + private final View target; + private final int targetWidthPx; + private final int targetHeightPx; + + private int startWidth; + private int startHeight; + + public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) { + this.target = target; + this.targetWidthPx = targetWidthPx; + this.targetHeightPx = targetHeightPx; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int newWidth = (int) (startWidth + (targetWidthPx - startWidth) * interpolatedTime); + int newHeight = (int) (startHeight + (targetHeightPx - startHeight) * interpolatedTime); + + ViewGroup.LayoutParams params = target.getLayoutParams(); + + params.width = newWidth; + params.height = newHeight; + + target.setLayoutParams(params); + } + + @Override + public void initialize(int width, int height, int parentWidth, int parentHeight) { + super.initialize(width, height, parentWidth, parentHeight); + + this.startWidth = width; + this.startHeight = height; + } + + @Override + public boolean willChangeBounds() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java new file mode 100644 index 00000000..add2d238 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.animation.transitions; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.graphics.drawable.Drawable; +import android.transition.Transition; +import android.transition.TransitionValues; +import android.util.Property; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.core.graphics.drawable.RoundedBitmapDrawable; + +@TargetApi(21) +abstract class CircleSquareImageViewTransition extends Transition { + + private static final String CIRCLE_RATIO = "CIRCLE_RATIO"; + + private final boolean toCircle; + + CircleSquareImageViewTransition(boolean toCircle) { + this.toCircle = toCircle; + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { + View view = transitionValues.view; + if (view instanceof ImageView) { + transitionValues.values.put(CIRCLE_RATIO, toCircle ? 0f : 1f); + } + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + View view = transitionValues.view; + if (view instanceof ImageView) { + transitionValues.values.put(CIRCLE_RATIO, toCircle ? 1f : 0f); + } + } + + @Override + public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) { + if (startValues == null || endValues == null) { + return null; + } + + ImageView endImageView = (ImageView) endValues.view; + float start = (float) startValues.values.get(CIRCLE_RATIO); + float end = (float) endValues.values.get(CIRCLE_RATIO); + + return ObjectAnimator.ofFloat(endImageView, new RadiusRatioProperty(), start, end); + } + + static final class RadiusRatioProperty extends Property { + + private float ratio; + + RadiusRatioProperty() { + super(Float.class, "circle_ratio"); + } + + @Override + final public void set(ImageView imageView, Float ratio) { + this.ratio = ratio; + Drawable imageViewDrawable = imageView.getDrawable(); + if (imageViewDrawable instanceof RoundedBitmapDrawable) { + RoundedBitmapDrawable drawable = (RoundedBitmapDrawable) imageViewDrawable; + if (ratio > 0.95) { + drawable.setCircular(true); + } else { + drawable.setCornerRadius(Math.min(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()) * ratio * 0.5f); + } + } + } + + @Override + public Float get(ImageView object) { + return ratio; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java new file mode 100644 index 00000000..4c07fa0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.animation.transitions; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.AttributeSet; + +/** + * Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}. + */ +@TargetApi(21) +public final class CircleToSquareImageViewTransition extends CircleSquareImageViewTransition { + public CircleToSquareImageViewTransition(Context context, AttributeSet attrs) { + super(false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java new file mode 100644 index 00000000..42b51339 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.animation.transitions; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.AttributeSet; + +/** + * Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}. + */ +@TargetApi(21) +public final class SquareToCircleImageViewTransition extends CircleSquareImageViewTransition { + public SquareToCircleImageViewTransition(Context context, AttributeSet attrs) { + super(true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java new file mode 100644 index 00000000..90696f2c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java @@ -0,0 +1,210 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.audio.AudioHash; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties; +import org.thoughtcrime.securesms.stickers.StickerLocator; + +public abstract class Attachment { + + @NonNull + private final String contentType; + private final int transferState; + private final long size; + + @Nullable + private final String fileName; + + private final int cdnNumber; + + @Nullable + private final String location; + + @Nullable + private final String key; + + @Nullable + private final String relay; + + @Nullable + private final byte[] digest; + + @Nullable + private final String fastPreflightId; + + private final boolean voiceNote; + private final boolean borderless; + private final int width; + private final int height; + private final boolean quote; + private final long uploadTimestamp; + + @Nullable + private final String caption; + + @Nullable + private final StickerLocator stickerLocator; + + @Nullable + private final BlurHash blurHash; + + @Nullable + private final AudioHash audioHash; + + @NonNull + private final TransformProperties transformProperties; + + public Attachment(@NonNull String contentType, + int transferState, + long size, + @Nullable String fileName, + int cdnNumber, + @Nullable String location, + @Nullable String key, + @Nullable String relay, + @Nullable byte[] digest, + @Nullable String fastPreflightId, + boolean voiceNote, + boolean borderless, + int width, + int height, + boolean quote, + long uploadTimestamp, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + @Nullable TransformProperties transformProperties) + { + this.contentType = contentType; + this.transferState = transferState; + this.size = size; + this.fileName = fileName; + this.cdnNumber = cdnNumber; + this.location = location; + this.key = key; + this.relay = relay; + this.digest = digest; + this.fastPreflightId = fastPreflightId; + this.voiceNote = voiceNote; + this.borderless = borderless; + this.width = width; + this.height = height; + this.quote = quote; + this.uploadTimestamp = uploadTimestamp; + this.stickerLocator = stickerLocator; + this.caption = caption; + this.blurHash = blurHash; + this.audioHash = audioHash; + this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty(); + } + + @Nullable + public abstract Uri getUri(); + + public int getTransferState() { + return transferState; + } + + public boolean isInProgress() { + return transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE && + transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED; + } + + public long getSize() { + return size; + } + + @Nullable + public String getFileName() { + return fileName; + } + + @NonNull + public String getContentType() { + return contentType; + } + + public int getCdnNumber() { + return cdnNumber; + } + + @Nullable + public String getLocation() { + return location; + } + + @Nullable + public String getKey() { + return key; + } + + @Nullable + public String getRelay() { + return relay; + } + + @Nullable + public byte[] getDigest() { + return digest; + } + + @Nullable + public String getFastPreflightId() { + return fastPreflightId; + } + + public boolean isVoiceNote() { + return voiceNote; + } + + public boolean isBorderless() { + return borderless; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public boolean isQuote() { + return quote; + } + + public long getUploadTimestamp() { + return uploadTimestamp; + } + + public boolean isSticker() { + return stickerLocator != null; + } + + public @Nullable StickerLocator getSticker() { + return stickerLocator; + } + + public @Nullable BlurHash getBlurHash() { + return blurHash; + } + + public @Nullable AudioHash getAudioHash() { + return audioHash; + } + + public @Nullable String getCaption() { + return caption; + } + + public @NonNull TransformProperties getTransformProperties() { + return transformProperties; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java new file mode 100644 index 00000000..d8d5e105 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.attachments; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.util.Util; + +public class AttachmentId implements Parcelable { + + @JsonProperty + private final long rowId; + + @JsonProperty + private final long uniqueId; + + public AttachmentId(@JsonProperty("rowId") long rowId, @JsonProperty("uniqueId") long uniqueId) { + this.rowId = rowId; + this.uniqueId = uniqueId; + } + + private AttachmentId(Parcel in) { + this.rowId = in.readLong(); + this.uniqueId = in.readLong(); + } + + public long getRowId() { + return rowId; + } + + public long getUniqueId() { + return uniqueId; + } + + public String[] toStrings() { + return new String[] {String.valueOf(rowId), String.valueOf(uniqueId)}; + } + + public @NonNull String toString() { + return "AttachmentId::(" + rowId + ", " + uniqueId + ")"; + } + + public boolean isValid() { + return rowId >= 0 && uniqueId >= 0; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AttachmentId attachmentId = (AttachmentId)o; + + if (rowId != attachmentId.rowId) return false; + return uniqueId == attachmentId.uniqueId; + } + + @Override + public int hashCode() { + return Util.hashCode(rowId, uniqueId); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(rowId); + dest.writeLong(uniqueId); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AttachmentId createFromParcel(Parcel in) { + return new AttachmentId(in); + } + + @Override + public AttachmentId[] newArray(int size) { + return new AttachmentId[size]; + } + }; + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java new file mode 100644 index 00000000..02321164 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.audio.AudioHash; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.stickers.StickerLocator; + +import java.util.Comparator; + +public class DatabaseAttachment extends Attachment { + + private final AttachmentId attachmentId; + private final long mmsId; + private final boolean hasData; + private final boolean hasThumbnail; + private final int displayOrder; + + public DatabaseAttachment(AttachmentId attachmentId, + long mmsId, + boolean hasData, + boolean hasThumbnail, + String contentType, + int transferProgress, + long size, + String fileName, + int cdnNumber, + String location, + String key, + String relay, + byte[] digest, + String fastPreflightId, + boolean voiceNote, + boolean borderless, + int width, + int height, + boolean quote, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + @Nullable TransformProperties transformProperties, + int displayOrder, + long uploadTimestamp) + { + super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties); + this.attachmentId = attachmentId; + this.hasData = hasData; + this.hasThumbnail = hasThumbnail; + this.mmsId = mmsId; + this.displayOrder = displayOrder; + } + + @Override + @Nullable + public Uri getUri() { + if (hasData) { + return PartAuthority.getAttachmentDataUri(attachmentId); + } else { + return null; + } + } + + public AttachmentId getAttachmentId() { + return attachmentId; + } + + public int getDisplayOrder() { + return displayOrder; + } + + @Override + public boolean equals(Object other) { + return other != null && + other instanceof DatabaseAttachment && + ((DatabaseAttachment) other).attachmentId.equals(this.attachmentId); + } + + @Override + public int hashCode() { + return attachmentId.hashCode(); + } + + public long getMmsId() { + return mmsId; + } + + public boolean hasData() { + return hasData; + } + + public boolean hasThumbnail() { + return hasThumbnail; + } + + public static class DisplayOrderComparator implements Comparator { + @Override + public int compare(DatabaseAttachment lhs, DatabaseAttachment rhs) { + return Integer.compare(lhs.getDisplayOrder(), rhs.getDisplayOrder()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java new file mode 100644 index 00000000..94698068 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.attachments; + + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; + +public class MmsNotificationAttachment extends Attachment { + + public MmsNotificationAttachment(int status, long size) { + super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, false, 0, 0, false, 0, null, null, null, null, null); + } + + @Nullable + @Override + public Uri getUri() { + return null; + } + + private static int getTransferStateFromStatus(int status) { + if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED || + status == MmsDatabase.Status.DOWNLOAD_NO_CONNECTIVITY) + { + return AttachmentDatabase.TRANSFER_PROGRESS_PENDING; + } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) { + return AttachmentDatabase.TRANSFER_PROGRESS_STARTED; + } else { + return AttachmentDatabase.TRANSFER_PROGRESS_FAILED; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java new file mode 100644 index 00000000..431e5b07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; + +import java.util.LinkedList; +import java.util.List; + +public class PointerAttachment extends Attachment { + + private PointerAttachment(@NonNull String contentType, + int transferState, + long size, + @Nullable String fileName, + int cdnNumber, + @NonNull String location, + @Nullable String key, + @Nullable String relay, + @Nullable byte[] digest, + @Nullable String fastPreflightId, + boolean voiceNote, + boolean borderless, + int width, + int height, + long uploadTimestamp, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash) + { + super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null); + } + + @Nullable + @Override + public Uri getUri() { + return null; + } + + public static List forPointers(Optional> pointers) { + List results = new LinkedList<>(); + + if (pointers.isPresent()) { + for (SignalServiceAttachment pointer : pointers.get()) { + Optional result = forPointer(Optional.of(pointer)); + + if (result.isPresent()) { + results.add(result.get()); + } + } + } + + return results; + } + + public static List forPointers(List pointers) { + List results = new LinkedList<>(); + + if (pointers != null) { + for (SignalServiceDataMessage.Quote.QuotedAttachment pointer : pointers) { + Optional result = forPointer(pointer); + + if (result.isPresent()) { + results.add(result.get()); + } + } + } + + return results; + } + + public static Optional forPointer(Optional pointer) { + return forPointer(pointer, null, null); + } + + public static Optional forPointer(Optional pointer, @Nullable StickerLocator stickerLocator) { + return forPointer(pointer, stickerLocator, null); + } + + public static Optional forPointer(Optional pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) { + if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.absent(); + + String encodedKey = null; + + if (pointer.get().asPointer().getKey() != null) { + encodedKey = Base64.encodeBytes(pointer.get().asPointer().getKey()); + } + + return Optional.of(new PointerAttachment(pointer.get().getContentType(), + AttachmentDatabase.TRANSFER_PROGRESS_PENDING, + pointer.get().asPointer().getSize().or(0), + pointer.get().asPointer().getFileName().orNull(), + pointer.get().asPointer().getCdnNumber(), + pointer.get().asPointer().getRemoteId().toString(), + encodedKey, null, + pointer.get().asPointer().getDigest().orNull(), + fastPreflightId, + pointer.get().asPointer().getVoiceNote(), + pointer.get().asPointer().isBorderless(), + pointer.get().asPointer().getWidth(), + pointer.get().asPointer().getHeight(), + pointer.get().asPointer().getUploadTimestamp(), + pointer.get().asPointer().getCaption().orNull(), + stickerLocator, + BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull()))); + + } + + public static Optional forPointer(SignalServiceDataMessage.Quote.QuotedAttachment pointer) { + SignalServiceAttachment thumbnail = pointer.getThumbnail(); + + return Optional.of(new PointerAttachment(pointer.getContentType(), + AttachmentDatabase.TRANSFER_PROGRESS_PENDING, + thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0, + pointer.getFileName(), + thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0, + thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0", + thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null, + null, + thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null, + null, + false, + false, + thumbnail != null ? thumbnail.asPointer().getWidth() : 0, + thumbnail != null ? thumbnail.asPointer().getHeight() : 0, + thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0, + thumbnail != null ? thumbnail.asPointer().getCaption().orNull() : null, + null, + null)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java new file mode 100644 index 00000000..4cdee54b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; + +/** + * An attachment that represents where an attachment used to be. Useful when you need to know that + * a message had an attachment and some metadata about it (like the contentType), even though the + * underlying media no longer exists. An example usecase would be view-once messages, so that we can + * quote them and know their contentType even though the media has been deleted. + */ +public class TombstoneAttachment extends Attachment { + + public TombstoneAttachment(@NonNull String contentType, boolean quote) { + super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, false, 0, 0, quote, 0, null, null, null, null, null); + } + + @Override + public @Nullable Uri getUri() { + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java new file mode 100644 index 00000000..cc28d760 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.audio.AudioHash; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties; +import org.thoughtcrime.securesms.stickers.StickerLocator; + +public class UriAttachment extends Attachment { + + private final @NonNull Uri dataUri; + + public UriAttachment(@NonNull Uri uri, + @NonNull String contentType, + int transferState, + long size, + @Nullable String fileName, + boolean voiceNote, + boolean borderless, + boolean quote, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + @Nullable TransformProperties transformProperties) + { + this(uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, borderless, quote, caption, stickerLocator, blurHash, audioHash, transformProperties); + } + + public UriAttachment(@NonNull Uri dataUri, + @NonNull String contentType, + int transferState, + long size, + int width, + int height, + @Nullable String fileName, + @Nullable String fastPreflightId, + boolean voiceNote, + boolean borderless, + boolean quote, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + @Nullable TransformProperties transformProperties) + { + super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, borderless, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties); + this.dataUri = dataUri; + } + + @Override + @NonNull + public Uri getUri() { + return dataUri; + } + + @Override + public boolean equals(Object other) { + return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri); + } + + @Override + public int hashCode() { + return dataUri.hashCode(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java new file mode 100644 index 00000000..192828c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.audio; + +import android.annotation.TargetApi; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaRecorder; +import android.os.Build; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN) +public class AudioCodec { + + private static final String TAG = AudioCodec.class.getSimpleName(); + + private static final int SAMPLE_RATE = 44100; + private static final int SAMPLE_RATE_INDEX = 4; + private static final int CHANNELS = 1; + private static final int BIT_RATE = 32000; + + private final int bufferSize; + private final MediaCodec mediaCodec; + private final AudioRecord audioRecord; + + private boolean running = true; + private boolean finished = false; + + public AudioCodec() throws IOException { + this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); + this.audioRecord = createAudioRecord(this.bufferSize); + this.mediaCodec = createMediaCodec(this.bufferSize); + + this.mediaCodec.start(); + + try { + audioRecord.startRecording(); + } catch (Exception e) { + Log.w(TAG, e); + mediaCodec.release(); + throw new IOException(e); + } + } + + public synchronized void stop() { + running = false; + while (!finished) Util.wait(this, 0); + } + + public void start(final OutputStream outputStream) { + new Thread(new Runnable() { + @Override + public void run() { + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + byte[] audioRecordData = new byte[bufferSize]; + ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers(); + ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers(); + + try { + while (true) { + boolean running = isRunning(); + + handleCodecInput(audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running); + handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream); + + if (!running) break; + } + } catch (IOException e) { + Log.w(TAG, e); + } finally { + mediaCodec.stop(); + audioRecord.stop(); + + mediaCodec.release(); + audioRecord.release(); + + StreamUtil.close(outputStream); + setFinished(); + } + } + }, "signal-AudioCodec").start(); + } + + private synchronized boolean isRunning() { + return running; + } + + private synchronized void setFinished() { + finished = true; + notifyAll(); + } + + private void handleCodecInput(AudioRecord audioRecord, byte[] audioRecordData, + MediaCodec mediaCodec, ByteBuffer[] codecInputBuffers, + boolean running) + { + int length = audioRecord.read(audioRecordData, 0, audioRecordData.length); + int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000); + + if (codecInputBufferIndex >= 0) { + ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex]; + codecBuffer.clear(); + codecBuffer.put(audioRecordData); + mediaCodec.queueInputBuffer(codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + } + + private void handleCodecOutput(MediaCodec mediaCodec, + ByteBuffer[] codecOutputBuffers, + MediaCodec.BufferInfo bufferInfo, + OutputStream outputStream) + throws IOException + { + int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + + while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { + if (codecOutputBufferIndex >= 0) { + ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex]; + + encoderOutputBuffer.position(bufferInfo.offset); + encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size); + + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) { + byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset); + + + outputStream.write(header); + + byte[] data = new byte[encoderOutputBuffer.remaining()]; + encoderOutputBuffer.get(data); + outputStream.write(data); + } + + encoderOutputBuffer.clear(); + + mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); + } else if (codecOutputBufferIndex== MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + codecOutputBuffers = mediaCodec.getOutputBuffers(); + } + + codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + } + + } + + private byte[] createAdtsHeader(int length) { + int frameLength = length + 7; + byte[] adtsHeader = new byte[7]; + + adtsHeader[0] = (byte) 0xFF; // Sync Word + adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC + adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6); + adtsHeader[2] |= (((byte) SAMPLE_RATE_INDEX) << 2); + adtsHeader[2] |= (((byte) CHANNELS) >> 2); + adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03)); + adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF); + adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f); + adtsHeader[6] = (byte) 0xFC; + + return adtsHeader; + } + + private AudioRecord createAudioRecord(int bufferSize) { + return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); + } + + private MediaCodec createMediaCodec(int bufferSize) throws IOException { + MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm"); + MediaFormat mediaFormat = new MediaFormat(); + + mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm"); + mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); + mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); + mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize); + mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE); + mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + + try { + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + } catch (Exception e) { + Log.w(TAG, e); + mediaCodec.release(); + throw new IOException(e); + } + + return mediaCodec; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java new file mode 100644 index 00000000..6550fe37 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.audio; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData; +import org.whispersystems.util.Base64; + +import java.io.IOException; + +/** + * An AudioHash is a compact string representation of the wave form and duration for an audio file. + */ +public final class AudioHash { + + @NonNull private final String hash; + @NonNull private final AudioWaveFormData audioWaveForm; + + private AudioHash(@NonNull String hash, @NonNull AudioWaveFormData audioWaveForm) { + this.hash = hash; + this.audioWaveForm = audioWaveForm; + } + + public AudioHash(@NonNull AudioWaveFormData audioWaveForm) { + this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm); + } + + public static @Nullable AudioHash parseOrNull(@Nullable String hash) { + if (hash == null) return null; + try { + return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash))); + } catch (IOException e) { + return null; + } + } + + @NonNull AudioWaveFormData getAudioWaveForm() { + return audioWaveForm; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AudioHash other = (AudioHash) o; + return hash.equals(other.hash); + } + + @Override + public int hashCode() { + return hash.hashCode(); + } + + public @NonNull String getHash() { + return hash; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java new file mode 100644 index 00000000..f2c59959 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.audio; + +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.whispersystems.libsignal.util.Pair; + +import java.io.IOException; +import java.util.concurrent.ExecutorService; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN) +public class AudioRecorder { + + private static final String TAG = AudioRecorder.class.getSimpleName(); + + private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder"); + + private final Context context; + + private AudioCodec audioCodec; + private Uri captureUri; + + public AudioRecorder(@NonNull Context context) { + this.context = context; + } + + public void startRecording() { + Log.i(TAG, "startRecording()"); + + executor.execute(() -> { + Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId()); + try { + if (audioCodec != null) { + throw new AssertionError("We can only record once at a time."); + } + + ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); + + captureUri = BlobProvider.getInstance() + .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) + .withMimeType(MediaUtil.AUDIO_AAC) + .createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e)); + audioCodec = new AudioCodec(); + + audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); + } catch (IOException e) { + Log.w(TAG, e); + } + }); + } + + public @NonNull ListenableFuture> stopRecording() { + Log.i(TAG, "stopRecording()"); + + final SettableFuture> future = new SettableFuture<>(); + + executor.execute(() -> { + if (audioCodec == null) { + sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!")); + return; + } + + audioCodec.stop(); + + try { + long size = MediaUtil.getMediaSize(context, captureUri); + sendToFuture(future, new Pair<>(captureUri, size)); + } catch (IOException ioe) { + Log.w(TAG, ioe); + sendToFuture(future, ioe); + } + + audioCodec = null; + captureUri = null; + }); + + return future; + } + + private void sendToFuture(final SettableFuture future, final Exception exception) { + Util.runOnMain(() -> future.setException(exception)); + } + + private void sendToFuture(final SettableFuture future, final T result) { + Util.runOnMain(() -> future.set(result)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java new file mode 100644 index 00000000..2a3814e5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java @@ -0,0 +1,311 @@ +package org.thoughtcrime.securesms.audio; + +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.net.Uri; +import android.os.Build; +import android.util.LruCache; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.google.protobuf.ByteString; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData; +import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; +import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Locale; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +@RequiresApi(api = Build.VERSION_CODES.M) +public final class AudioWaveForm { + + private static final String TAG = Log.tag(AudioWaveForm.class); + + private static final int BAR_COUNT = 46; + private static final int SAMPLES_PER_BAR = 4; + + private final Context context; + private final AudioSlide slide; + + public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) { + this.context = context.getApplicationContext(); + this.slide = slide; + } + + private static final LruCache WAVE_FORM_CACHE = new LruCache<>(200); + private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED); + + @AnyThread + public void getWaveForm(@NonNull Consumer onSuccess, @NonNull Runnable onFailure) { + Uri uri = slide.getUri(); + Attachment attachment = slide.asAttachment(); + + if (uri == null) { + Log.w(TAG, "No uri"); + Util.runOnMain(onFailure); + return; + } + + if (!(attachment instanceof DatabaseAttachment)) { + Log.i(TAG, "Not yet in database"); + Util.runOnMain(onFailure); + return; + } + + String cacheKey = uri.toString(); + AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey); + if (cached != null) { + Log.i(TAG, "Loaded wave form from cache " + cacheKey); + Util.runOnMain(() -> onSuccess.accept(cached)); + return; + } + + AUDIO_DECODER_EXECUTOR.execute(() -> { + AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey); + if (cachedInExecutor != null) { + Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey); + Util.runOnMain(() -> onSuccess.accept(cachedInExecutor)); + return; + } + + AudioHash audioHash = attachment.getAudioHash(); + if (audioHash != null) { + AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm()); + if (audioFileInfo.waveForm.length == 0) { + Log.w(TAG, "Recovering from a wave form generation error " + cacheKey); + Util.runOnMain(onFailure); + return; + } else if (audioFileInfo.waveForm.length != BAR_COUNT) { + Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey); + } else { + WAVE_FORM_CACHE.put(cacheKey, audioFileInfo); + Log.i(TAG, "Loaded wave form from DB " + cacheKey); + Util.runOnMain(() -> onSuccess.accept(audioFileInfo)); + return; + } + } + + try { + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment; + long startTime = System.currentTimeMillis(); + + attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance()); + + Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey)); + + AudioFileInfo fileInfo = generateWaveForm(uri); + + Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey)); + + attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf()); + + WAVE_FORM_CACHE.put(cacheKey, fileInfo); + Util.runOnMain(() -> onSuccess.accept(fileInfo)); + } catch (Throwable e) { + Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e); + Util.runOnMain(onFailure); + } + }); + } + + /** + * Based on decode sample from: + *

+ * https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java + */ + @WorkerThread + @RequiresApi(api = 23) + private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException { + try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) { + long[] wave = new long[BAR_COUNT]; + int[] waveSamples = new int[BAR_COUNT]; + + MediaExtractor extractor = dataSource.createExtractor(); + + if (extractor.getTrackCount() == 0) { + throw new IOException("No audio track"); + } + + MediaFormat format = extractor.getTrackFormat(0); + + if (!format.containsKey(MediaFormat.KEY_DURATION)) { + throw new IOException("Unknown duration"); + } + + long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION); + String mime = format.getString(MediaFormat.KEY_MIME); + + if (!mime.startsWith("audio/")) { + throw new IOException("Mime not audio"); + } + + MediaCodec codec = MediaCodec.createDecoderByType(mime); + + if (totalDurationUs == 0) { + throw new IOException("Zero duration"); + } + + codec.configure(format, null, null, 0); + codec.start(); + + ByteBuffer[] codecInputBuffers = codec.getInputBuffers(); + ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers(); + + extractor.selectTrack(0); + + long kTimeOutUs = 5000; + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + boolean sawInputEOS = false; + boolean sawOutputEOS = false; + int noOutputCounter = 0; + + while (!sawOutputEOS && noOutputCounter < 50) { + noOutputCounter++; + if (!sawInputEOS) { + int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs); + if (inputBufIndex >= 0) { + ByteBuffer dstBuf = codecInputBuffers[inputBufIndex]; + int sampleSize = extractor.readSampleData(dstBuf, 0); + long presentationTimeUs = 0; + + if (sampleSize < 0) { + sawInputEOS = true; + sampleSize = 0; + } else { + presentationTimeUs = extractor.getSampleTime(); + } + + codec.queueInputBuffer( + inputBufIndex, + 0, + sampleSize, + presentationTimeUs, + sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0); + + if (!sawInputEOS) { + int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs); + sawInputEOS = !extractor.advance(); + int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs); + while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) { + sawInputEOS = !extractor.advance(); + if (!sawInputEOS) { + nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs); + } + } + } + } + } + + int outputBufferIndex; + do { + outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs); + if (outputBufferIndex >= 0) { + if (info.size > 0) { + noOutputCounter = 0; + } + + ByteBuffer buf = codecOutputBuffers[outputBufferIndex]; + int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs); + long total = 0; + for (int i = 0; i < info.size; i += 2 * 4) { + short aShort = buf.getShort(i); + total += Math.abs(aShort); + } + if (barIndex >= 0 && barIndex < wave.length) { + wave[barIndex] += total; + waveSamples[barIndex] += info.size / 2; + } + codec.releaseOutputBuffer(outputBufferIndex, false); + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + sawOutputEOS = true; + } + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + codecOutputBuffers = codec.getOutputBuffers(); + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + Log.d(TAG, "output format has changed to " + codec.getOutputFormat()); + } + } while (outputBufferIndex >= 0); + } + + codec.stop(); + codec.release(); + extractor.release(); + + float[] floats = new float[BAR_COUNT]; + byte[] bytes = new byte[BAR_COUNT]; + float max = 0; + + for (int i = 0; i < BAR_COUNT; i++) { + if (waveSamples[i] == 0) continue; + + floats[i] = wave[i] / (float) waveSamples[i]; + if (floats[i] > max) { + max = floats[i]; + } + } + + for (int i = 0; i < BAR_COUNT; i++) { + float normalized = floats[i] / max; + bytes[i] = (byte) (255 * normalized); + } + + return new AudioFileInfo(totalDurationUs, bytes); + } + } + + public static class AudioFileInfo { + private final long durationUs; + private final byte[] waveFormBytes; + private final float[] waveForm; + + private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) { + return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray()); + } + + private AudioFileInfo(long durationUs, byte[] waveFormBytes) { + this.durationUs = durationUs; + this.waveFormBytes = waveFormBytes; + this.waveForm = new float[waveFormBytes.length]; + + for (int i = 0; i < waveFormBytes.length; i++) { + int unsigned = waveFormBytes[i] & 0xff; + this.waveForm[i] = unsigned / 255f; + } + } + + public long getDuration(@NonNull TimeUnit timeUnit) { + return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS); + } + + public float[] getWaveForm() { + return waveForm; + } + + private @NonNull AudioWaveFormData toDatabaseProtobuf() { + return AudioWaveFormData.newBuilder() + .setDurationUs(durationUs) + .setWaveForm(ByteString.copyFrom(waveFormBytes)) + .build(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java new file mode 100644 index 00000000..cf6ef6ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.backup; + + +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment; +import org.thoughtcrime.securesms.service.LocalBackupListener; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +public class BackupDialog { + + private static final String TAG = Log.tag(BackupDialog.class); + + public static void showEnableBackupDialog(@NonNull Context context, + @Nullable Intent backupDirectorySelectionIntent, + @Nullable String backupDirectoryDisplayName, + @NonNull Runnable onBackupsEnabled) + { + String[] password = BackupUtil.generateBackupPassphrase(); + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(R.string.BackupDialog_enable_local_backups) + .setView(backupDirectorySelectionIntent != null ? R.layout.backup_enable_dialog_v29 : R.layout.backup_enable_dialog) + .setPositiveButton(R.string.BackupDialog_enable_backups, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.setOnShowListener(created -> { + if (backupDirectoryDisplayName != null) { + TextView folderName = dialog.findViewById(R.id.backup_enable_dialog_folder_name); + if (folderName != null) { + folderName.setText(backupDirectoryDisplayName); + } + } + + Button button = ((AlertDialog) created).getButton(AlertDialog.BUTTON_POSITIVE); + button.setOnClickListener(v -> { + CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check); + if (confirmationCheckBox.isChecked()) { + if (backupDirectorySelectionIntent != null && backupDirectorySelectionIntent.getData() != null) { + Uri backupDirectoryUri = backupDirectorySelectionIntent.getData(); + int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri); + context.getContentResolver() + .takePersistableUriPermission(backupDirectoryUri, takeFlags); + } + + BackupPassphrase.set(context, Util.join(password, " ")); + TextSecurePreferences.setNextBackupTime(context, 0); + TextSecurePreferences.setBackupEnabled(context, true); + LocalBackupListener.schedule(context); + + onBackupsEnabled.run(); + created.dismiss(); + } else { + Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show(); + } + }); + }); + + dialog.show(); + + CheckBox checkBox = dialog.findViewById(R.id.confirmation_check); + TextView textView = dialog.findViewById(R.id.confirmation_text); + + ((TextView)dialog.findViewById(R.id.code_first)).setText(password[0]); + ((TextView)dialog.findViewById(R.id.code_second)).setText(password[1]); + ((TextView)dialog.findViewById(R.id.code_third)).setText(password[2]); + + ((TextView)dialog.findViewById(R.id.code_fourth)).setText(password[3]); + ((TextView)dialog.findViewById(R.id.code_fifth)).setText(password[4]); + ((TextView)dialog.findViewById(R.id.code_sixth)).setText(password[5]); + + textView.setOnClickListener(v -> checkBox.toggle()); + + dialog.findViewById(R.id.number_table).setOnClickListener(v -> { + ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", Util.join(password, " "))); + Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_LONG).show(); + }); + + + } + + @RequiresApi(29) + public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) { + new AlertDialog.Builder(fragment.requireContext()) + .setView(R.layout.backup_choose_location_dialog) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .setPositiveButton(R.string.BackupDialog_choose_folder, ((dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + if (Build.VERSION.SDK_INT >= 26) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().getLatestSignalBackupDirectory()); + } + + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + + try { + fragment.startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException e) { + Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG) + .show(); + } + + dialog.dismiss(); + })) + .create() + .show(); + } + + public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) { + new AlertDialog.Builder(context) + .setTitle(R.string.BackupDialog_delete_backups) + .setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> { + BackupUtil.disableBackups(context); + + onBackupsDisabled.run(); + }) + .create() + .show(); + } + + public static void showVerifyBackupPassphraseDialog(@NonNull Context context) { + View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null); + EditText prompt = view.findViewById(R.id.restore_passphrase_input); + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify) + .setView(view) + .setPositiveButton(R.string.BackupDialog_verify, null) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setEnabled(false); + + RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter(); + + prompt.addTextChangedListener(new AfterTextChanged(editable -> { + formatter.afterTextChanged(editable); + positiveButton.setEnabled(editable.length() == BackupUtil.PASSPHRASE_LENGTH); + })); + + positiveButton.setOnClickListener(v -> { + String passphrase = prompt.getText().toString(); + if (passphrase.equals(BackupPassphrase.get(context))) { + Toast.makeText(context, R.string.BackupDialog_you_successfully_entered_your_backup_passphrase, Toast.LENGTH_SHORT).show(); + dialog.dismiss(); + } else { + Toast.makeText(context, R.string.BackupDialog_passphrase_was_not_correct, Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java new file mode 100644 index 00000000..d0f8006a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.backup; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper; +import org.thoughtcrime.securesms.notifications.NotificationChannels; + +import java.io.IOException; + +public enum BackupFileIOError { + ACCESS_ERROR(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved), + FILE_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_file_is_too_large), + NOT_ENOUGH_SPACE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_there_is_not_enough_space), + UNKNOWN(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_tap_to_manage_backups); + + private static final short BACKUP_FAILED_ID = 31321; + + private final @StringRes int titleId; + private final @StringRes int messageId; + + BackupFileIOError(@StringRes int titleId, @StringRes int messageId) { + this.titleId = titleId; + this.messageId = messageId; + } + + public static void clearNotification(@NonNull Context context) { + NotificationCancellationHelper.cancelLegacy(context, BACKUP_FAILED_ID); + } + + public void postNotification(@NonNull Context context) { + Intent intent = new Intent(context, ApplicationPreferencesActivity.class); + + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_BACKUPS_FRAGMENT, true); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, intent, 0); + Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.FAILURES) + .setSmallIcon(R.drawable.ic_signal_backup) + .setContentTitle(context.getString(titleId)) + .setContentText(context.getString(messageId)) + .setContentIntent(pendingIntent) + .build(); + + NotificationManagerCompat.from(context) + .notify(BACKUP_FAILED_ID, backupFailedNotification); + } + + public static void postNotificationForException(@NonNull Context context, @NonNull IOException e, int runAttempt) { + BackupFileIOError error = getFromException(e); + + if (error != null) { + error.postNotification(context); + } + + if (error == null && runAttempt > 0) { + UNKNOWN.postNotification(context); + } + } + + private static @Nullable BackupFileIOError getFromException(@NonNull IOException e) { + if (e.getMessage() != null) { + if (e.getMessage().contains("EFBIG")) return FILE_TOO_LARGE; + else if (e.getMessage().contains("ENOSPC")) return NOT_ENOUGH_SPACE; + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java new file mode 100644 index 00000000..105f2f12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.backup; + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.KeyStoreHelper; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23. + */ +public final class BackupPassphrase { + + private BackupPassphrase() { + } + + private static final String TAG = BackupPassphrase.class.getSimpleName(); + + public static @Nullable String get(@NonNull Context context) { + String passphrase = TextSecurePreferences.getBackupPassphrase(context); + String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); + + if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) { + return stripSpaces(passphrase); + } + + if (encryptedPassphrase == null) { + Log.i(TAG, "Migrating to encrypted passphrase."); + set(context, passphrase); + encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); + if (encryptedPassphrase == null) throw new AssertionError("Passphrase migration failed"); + } + + KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase); + return stripSpaces(new String(KeyStoreHelper.unseal(data))); + } + + public static void set(@NonNull Context context, @Nullable String passphrase) { + if (passphrase == null || Build.VERSION.SDK_INT < 23) { + TextSecurePreferences.setBackupPassphrase(context, passphrase); + TextSecurePreferences.setEncryptedBackupPassphrase(context, null); + } else { + KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes()); + TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize()); + TextSecurePreferences.setBackupPassphrase(context, null); + } + } + + private static String stripSpaces(@Nullable String passphrase) { + return passphrase != null ? passphrase.replace(" ", "") : null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java new file mode 100644 index 00000000..c88c4fec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.backup; + + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.EventBus; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public abstract class FullBackupBase { + + @SuppressWarnings("unused") + private static final String TAG = FullBackupBase.class.getSimpleName(); + + static class BackupStream { + static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) { + try { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0)); + + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + byte[] input = passphrase.replace(" ", "").getBytes(); + byte[] hash = input; + + if (salt != null) digest.update(salt); + + for (int i=0;i<250000;i++) { + if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0)); + digest.update(hash); + hash = digest.digest(input); + } + + return ByteUtil.trim(hash, 32); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + } + + public static class BackupEvent { + public enum Type { + PROGRESS, + FINISHED + } + + private final Type type; + private final int count; + + BackupEvent(Type type, int count) { + this.type = type; + this.count = count; + } + + public Type getType() { + return type; + } + + public int getCount() { + return count; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java new file mode 100644 index 00000000..7bea78e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -0,0 +1,498 @@ +package org.thoughtcrime.securesms.backup; + + +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; + +import com.annimon.stream.function.Consumer; +import com.annimon.stream.function.Predicate; +import com.google.protobuf.ByteString; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.Conversions; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; +import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.SessionDatabase; +import org.thoughtcrime.securesms.database.SignedPreKeyDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class FullBackupExporter extends FullBackupBase { + + @SuppressWarnings("unused") + private static final String TAG = FullBackupExporter.class.getSimpleName(); + + private static final Set BLACKLISTED_TABLES = SetUtil.newHashSet( + SignedPreKeyDatabase.TABLE_NAME, + OneTimePreKeyDatabase.TABLE_NAME, + SessionDatabase.TABLE_NAME, + SearchDatabase.SMS_FTS_TABLE_NAME, + SearchDatabase.MMS_FTS_TABLE_NAME + ); + + public static void export(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull File output, + @NonNull String passphrase) + throws IOException + { + try (OutputStream outputStream = new FileOutputStream(output)) { + internalExport(context, attachmentSecret, input, outputStream, passphrase); + } + } + + @RequiresApi(29) + public static void export(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull DocumentFile output, + @NonNull String passphrase) + throws IOException + { + try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) { + internalExport(context, attachmentSecret, input, outputStream, passphrase); + } + } + + private static void internalExport(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull OutputStream fileOutputStream, + @NonNull String passphrase) + throws IOException + { + BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase); + int count = 0; + + try { + outputStream.writeDatabaseVersion(input.getVersion()); + + List tables = exportSchema(input, outputStream); + + Stopwatch stopwatch = new Stopwatch("Backup"); + + for (String table : tables) { + if (table.equals(MmsDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count); + } else if (table.equals(SmsDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count); + } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count); + } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count); + } else if (table.equals(StickerDatabase.TABLE_NAME)) { + count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count); + } else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) { + count = exportTable(table, input, outputStream, null, null, count); + } + stopwatch.split("table::" + table); + } + + for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(preference); + } + + stopwatch.split("prefs"); + + for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) { + if (avatar != null) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength()); + } + } + + stopwatch.split("avatars"); + stopwatch.stop(TAG); + + outputStream.writeEnd(); + } finally { + outputStream.close(); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); + } + } + + private static List exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream) + throws IOException + { + List tables = new LinkedList<>(); + + try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master", null)) { + while (cursor != null && cursor.moveToNext()) { + String sql = cursor.getString(0); + String name = cursor.getString(1); + String type = cursor.getString(2); + + if (sql != null) { + + boolean isSmsFtsSecretTable = name != null && !name.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME); + boolean isMmsFtsSecretTable = name != null && !name.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME); + + if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) { + if ("table".equals(type)) { + tables.add(name); + } + + outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(cursor.getString(0)).build()); + } + } + } + } + + return tables; + } + + private static int exportTable(@NonNull String table, + @NonNull SQLiteDatabase input, + @NonNull BackupFrameOutputStream outputStream, + @Nullable Predicate predicate, + @Nullable Consumer postProcess, + int count) + throws IOException + { + String template = "INSERT INTO " + table + " VALUES "; + + try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) { + while (cursor != null && cursor.moveToNext()) { + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count)); + + if (predicate == null || predicate.test(cursor)) { + StringBuilder statement = new StringBuilder(template); + BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder(); + + statement.append('('); + + for (int i=0;i 0) { + InputStream inputStream; + + if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); + else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data)); + + outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + + private static void exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) { + try { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID)); + long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH)); + + String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH)); + byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM)); + + if (!TextUtils.isEmpty(data) && size > 0) { + InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); + outputStream.writeSticker(rowId, inputStream, size); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + + private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException { + long result = 0; + InputStream inputStream; + + if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); + else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data)); + + int read; + byte[] buffer = new byte[8192]; + + while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { + result += read; + } + + return result; + } + + private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) { + return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 && + cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0; + } + + private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) { + return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0; + } + + private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) { + String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE}; + String where = MmsDatabase.ID + " = ?"; + String[] args = new String[] { String.valueOf(mmsId) }; + + try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) { + if (mmsCursor != null && mmsCursor.moveToFirst()) { + return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 && + mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0; + } + } + + return false; + } + + + private static class BackupFrameOutputStream extends BackupStream { + + private final OutputStream outputStream; + private final Cipher cipher; + private final Mac mac; + + private final byte[] cipherKey; + private final byte[] macKey; + + private byte[] iv; + private int counter; + + private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase) throws IOException { + try { + byte[] salt = Util.getSecretBytes(32); + byte[] key = getBackupKey(passphrase, salt); + byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64); + byte[][] split = ByteUtil.split(derived, 32, 32); + + this.cipherKey = split[0]; + this.macKey = split[1]; + + this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); + this.mac = Mac.getInstance("HmacSHA256"); + this.outputStream = output; + this.iv = Util.getSecretBytes(16); + this.counter = Conversions.byteArrayToInt(iv); + + mac.init(new SecretKeySpec(macKey, "HmacSHA256")); + + byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder() + .setIv(ByteString.copyFrom(iv)) + .setSalt(ByteString.copyFrom(salt))) + .build().toByteArray(); + + outputStream.write(Conversions.intToByteArray(header.length)); + outputStream.write(header); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + public void write(BackupProtos.SharedPreference preference) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build()); + } + + public void write(BackupProtos.SqlStatement statement) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build()); + } + + public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder() + .setAvatar(BackupProtos.Avatar.newBuilder() + .setRecipientId(avatarName) + .setLength(Util.toIntExact(size)) + .build()) + .build()); + + if (writeStream(in) != size) { + throw new IOException("Size mismatch!"); + } + } + + public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder() + .setAttachment(BackupProtos.Attachment.newBuilder() + .setRowId(attachmentId.getRowId()) + .setAttachmentId(attachmentId.getUniqueId()) + .setLength(Util.toIntExact(size)) + .build()) + .build()); + + if (writeStream(in) != size) { + throw new IOException("Size mismatch!"); + } + } + + public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder() + .setSticker(BackupProtos.Sticker.newBuilder() + .setRowId(rowId) + .setLength(Util.toIntExact(size)) + .build()) + .build()); + + if (writeStream(in) != size) { + throw new IOException("Size mismatch!"); + } + } + + void writeDatabaseVersion(int version) throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder() + .setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version)) + .build()); + } + + void writeEnd() throws IOException { + write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build()); + } + + /** + * @return The amount of data written from the provided InputStream. + */ + private long writeStream(@NonNull InputStream inputStream) throws IOException { + try { + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + mac.update(iv); + + byte[] buffer = new byte[8192]; + long total = 0; + + int read; + + while ((read = inputStream.read(buffer)) != -1) { + byte[] ciphertext = cipher.update(buffer, 0, read); + + if (ciphertext != null) { + outputStream.write(ciphertext); + mac.update(ciphertext); + } + + total += read; + } + + byte[] remainder = cipher.doFinal(); + outputStream.write(remainder); + mac.update(remainder); + + byte[] attachmentDigest = mac.doFinal(); + outputStream.write(attachmentDigest, 0, 10); + + return total; + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException { + try { + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + + byte[] frameCiphertext = cipher.doFinal(frame.toByteArray()); + byte[] frameMac = mac.doFinal(frameCiphertext); + byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10); + + out.write(length); + out.write(frameCiphertext); + out.write(frameMac, 0, 10); + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + + public void close() throws IOException { + outputStream.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java new file mode 100644 index 00000000..8a865a41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -0,0 +1,366 @@ +package org.thoughtcrime.securesms.backup; + + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.util.Pair; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.Conversions; +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.BackupProtos.Attachment; +import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame; +import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion; +import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference; +import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement; +import org.thoughtcrime.securesms.backup.BackupProtos.Sticker; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.whispersystems.libsignal.kdf.HKDFv3; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class FullBackupImporter extends FullBackupBase { + + @SuppressWarnings("unused") + private static final String TAG = FullBackupImporter.class.getSimpleName(); + + public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase) + throws IOException + { + int count = 0; + + try (InputStream is = getInputStream(context, uri)) { + BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase); + + db.beginTransaction(); + + dropAllTables(db); + + BackupFrame frame; + + while (!(frame = inputStream.readFrame()).getEnd()) { + if (count++ % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count)); + + if (frame.hasVersion()) processVersion(db, frame.getVersion()); + else if (frame.hasStatement()) processStatement(db, frame.getStatement()); + else if (frame.hasPreference()) processPreference(context, frame.getPreference()); + else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream); + else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream); + else if (frame.hasAvatar()) processAvatar(context, db, frame.getAvatar(), inputStream); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count)); + } + + private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{ + if (BackupUtil.isUserSelectionRequired(context)) { + return Objects.requireNonNull(context.getContentResolver().openInputStream(uri)); + } else { + return new FileInputStream(new File(Objects.requireNonNull(uri.getPath()))); + } + } + + private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException { + if (version.getVersion() > db.getVersion()) { + throw new DatabaseDowngradeException(db.getVersion(), version.getVersion()); + } + + db.setVersion(version.getVersion()); + } + + private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) { + boolean isForSmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_"); + boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_"); + boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_"); + + if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) { + Log.i(TAG, "Ignoring import for statement: " + statement.getStatement()); + return; + } + + List parameters = new LinkedList<>(); + + for (SqlStatement.SqlParameter parameter : statement.getParametersList()) { + if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter()); + else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter()); + else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter()); + else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray()); + else if (parameter.hasNullparameter()) parameters.add(null); + } + + if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray()); + else db.execSQL(statement.getStatement()); + } + + private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream) + throws IOException + { + File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE); + File dataFile = File.createTempFile("part", ".mms", partsDirectory); + Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); + + ContentValues contentValues = new ContentValues(); + + try { + inputStream.readAttachmentTo(output.second, attachment.getLength()); + + contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath()); + contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first); + } catch (BadMacException e) { + Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e); + dataFile.delete(); + contentValues.put(AttachmentDatabase.DATA, (String) null); + contentValues.put(AttachmentDatabase.DATA_RANDOM, (String) null); + } + + db.update(AttachmentDatabase.TABLE_NAME, contentValues, + AttachmentDatabase.ROW_ID + " = ? AND " + AttachmentDatabase.UNIQUE_ID + " = ?", + new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())}); + } + + private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream) + throws IOException + { + File stickerDirectory = context.getDir(StickerDatabase.DIRECTORY, Context.MODE_PRIVATE); + File dataFile = File.createTempFile("sticker", ".mms", stickerDirectory); + + Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); + + inputStream.readAttachmentTo(output.second, sticker.getLength()); + + ContentValues contentValues = new ContentValues(); + contentValues.put(StickerDatabase.FILE_PATH, dataFile.getAbsolutePath()); + contentValues.put(StickerDatabase.FILE_LENGTH, sticker.getLength()); + contentValues.put(StickerDatabase.FILE_RANDOM, output.first); + + db.update(StickerDatabase.TABLE_NAME, contentValues, + StickerDatabase._ID + " = ?", + new String[] {String.valueOf(sticker.getRowId())}); + } + + private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException { + if (avatar.hasRecipientId()) { + RecipientId recipientId = RecipientId.from(avatar.getRecipientId()); + inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength()); + } else { + if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) { + Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later."); + db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.getName() }); + } else if (avatar.hasName() && SqlUtil.tableExists(db, "recipient")) { + Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar so it can be fetched later."); + db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() }); + } else { + Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore."); + } + + inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength()); + } + } + + @SuppressLint("ApplySharedPref") + private static void processPreference(@NonNull Context context, SharedPreference preference) { + SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0); + preferences.edit().putString(preference.getKey(), preference.getValue()).commit(); + } + + private static void dropAllTables(@NonNull SQLiteDatabase db) { + try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) { + while (cursor != null && cursor.moveToNext()) { + String name = cursor.getString(0); + String type = cursor.getString(1); + + if ("table".equals(type) && !name.startsWith("sqlite_")) { + db.execSQL("DROP TABLE IF EXISTS " + name); + } + } + } + } + + private static class BackupRecordInputStream extends BackupStream { + + private final InputStream in; + private final Cipher cipher; + private final Mac mac; + + private final byte[] cipherKey; + private final byte[] macKey; + + private byte[] iv; + private int counter; + + private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException { + try { + this.in = in; + + byte[] headerLengthBytes = new byte[4]; + StreamUtil.readFully(in, headerLengthBytes); + + int headerLength = Conversions.byteArrayToInt(headerLengthBytes); + byte[] headerFrame = new byte[headerLength]; + StreamUtil.readFully(in, headerFrame); + + BackupFrame frame = BackupFrame.parseFrom(headerFrame); + + if (!frame.hasHeader()) { + throw new IOException("Backup stream does not start with header!"); + } + + BackupProtos.Header header = frame.getHeader(); + + this.iv = header.getIv().toByteArray(); + + if (iv.length != 16) { + throw new IOException("Invalid IV length!"); + } + + byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null); + byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64); + byte[][] split = ByteUtil.split(derived, 32, 32); + + this.cipherKey = split[0]; + this.macKey = split[1]; + + this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); + this.mac = Mac.getInstance("HmacSHA256"); + this.mac.init(new SecretKeySpec(macKey, "HmacSHA256")); + + this.counter = Conversions.byteArrayToInt(iv); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + BackupFrame readFrame() throws IOException { + return readFrame(in); + } + + void readAttachmentTo(OutputStream out, int length) throws IOException { + try { + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + mac.update(iv); + + byte[] buffer = new byte[8192]; + + while (length > 0) { + int read = in.read(buffer, 0, Math.min(buffer.length, length)); + if (read == -1) throw new IOException("File ended early!"); + + mac.update(buffer, 0, read); + + byte[] plaintext = cipher.update(buffer, 0, read); + + if (plaintext != null) { + out.write(plaintext, 0, plaintext.length); + } + + length -= read; + } + + byte[] plaintext = cipher.doFinal(); + + if (plaintext != null) { + out.write(plaintext, 0, plaintext.length); + } + + out.close(); + + byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); + byte[] theirMac = new byte[10]; + + try { + StreamUtil.readFully(in, theirMac); + } catch (IOException e) { + throw new IOException(e); + } + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new BadMacException(); + } + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + private BackupFrame readFrame(InputStream in) throws IOException { + try { + byte[] length = new byte[4]; + StreamUtil.readFully(in, length); + + byte[] frame = new byte[Conversions.byteArrayToInt(length)]; + StreamUtil.readFully(in, frame); + + byte[] theirMac = new byte[10]; + System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length); + + mac.update(frame, 0, frame.length - 10); + byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new IOException("Bad MAC"); + } + + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + + byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10); + + return BackupFrame.parseFrom(plaintext); + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + } + + private static class BadMacException extends IOException {} + + public static class DatabaseDowngradeException extends IOException { + DatabaseDowngradeException(int currentVersion, int backupVersion) { + super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java new file mode 100644 index 00000000..9f8bd6e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -0,0 +1,169 @@ +package org.thoughtcrime.securesms.blocked; + +import android.app.AlertDialog; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.widget.ViewSwitcher; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.snackbar.Snackbar; + +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ContactFilterToolbar; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.whispersystems.libsignal.util.guava.Optional; + +public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener { + + private static final String CONTACT_SELECTION_FRAGMENT = "Contact.Selection.Fragment"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + private BlockedUsersViewModel viewModel; + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + + dynamicTheme.onCreate(this); + + setContentView(R.layout.blocked_users_activity); + + BlockedUsersRepository repository = new BlockedUsersRepository(this); + BlockedUsersViewModel.Factory factory = new BlockedUsersViewModel.Factory(repository); + + viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class); + + ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher); + Toolbar toolbar = findViewById(R.id.toolbar); + ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar); + View container = findViewById(R.id.fragment_container); + + toolbar.setNavigationOnClickListener(unused -> onBackPressed()); + contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed()); + contactFilterToolbar.setOnFilterChangedListener(query -> { + Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT); + if (fragment != null) { + ((ContactSelectionListFragment) fragment).setQueryFilter(query); + } + }); + contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user); + + //noinspection CodeBlock2Expr + getSupportFragmentManager().addOnBackStackChangedListener(() -> { + viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount()); + + if (getSupportFragmentManager().getBackStackEntryCount() == 1) { + contactFilterToolbar.focusAndShowKeyboard(); + } + }); + + getSupportFragmentManager().beginTransaction() + .add(R.id.fragment_container, new BlockedUsersFragment()) + .commit(); + + viewModel.getEvents().observe(this, event -> handleEvent(container, event)); + } + + @Override + protected void onResume() { + super.onResume(); + + dynamicTheme.onResume(this); + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number); + + AlertDialog confirmationDialog = new AlertDialog.Builder(BlockedUsersActivity.this) + .setTitle(R.string.BlockedUsersActivity__block_user) + .setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName)) + .setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> { + if (recipientId.isPresent()) { + viewModel.block(recipientId.get()); + } else { + viewModel.createAndBlock(number); + } + dialog.dismiss(); + onBackPressed(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .create(); + + confirmationDialog.setOnShowListener(dialog -> { + confirmationDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(Color.RED); + }); + + confirmationDialog.show(); + + return false; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + + } + + @Override + public void handleAddUserToBlockedList() { + ContactSelectionListFragment fragment = new ContactSelectionListFragment(); + Intent intent = getIntent(); + + intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1); + intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true); + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, + ContactsCursorLoader.DisplayMode.FLAG_PUSH | + ContactsCursorLoader.DisplayMode.FLAG_SMS | + ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS | + ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS | + ContactsCursorLoader.DisplayMode.FLAG_BLOCK); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT) + .addToBackStack(null) + .commit(); + } + + private void handleEvent(@NonNull View view, @NonNull BlockedUsersViewModel.Event event) { + final String displayName; + + if (event.getRecipient() == null) { + displayName = event.getNumber(); + } else { + displayName = event.getRecipient().getDisplayName(this); + } + + final @StringRes int messageResId; + switch (event.getEventType()) { + case BLOCK_SUCCEEDED: + messageResId = R.string.BlockedUsersActivity__s_has_been_blocked; + break; + case BLOCK_FAILED: + messageResId = R.string.BlockedUsersActivity__failed_to_block_s; + break; + case UNBLOCK_SUCCEEDED: + messageResId = R.string.BlockedUsersActivity__s_has_been_unblocked; + break; + default: + throw new IllegalArgumentException("Unsupported event type " + event); + } + + Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java new file mode 100644 index 00000000..f5a2dd09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.blocked; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Objects; + +final class BlockedUsersAdapter extends ListAdapter { + + private final RecipientClickedListener recipientClickedListener; + + BlockedUsersAdapter(@NonNull RecipientClickedListener recipientClickedListener) { + super(new RecipientDiffCallback()); + + this.recipientClickedListener = recipientClickedListener; + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.blocked_users_adapter_item, parent, false), + position -> recipientClickedListener.onRecipientClicked(Objects.requireNonNull(getItem(position)))); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(Objects.requireNonNull(getItem(position))); + } + + static final class ViewHolder extends RecyclerView.ViewHolder { + + private final AvatarImageView avatar; + private final TextView displayName; + private final TextView numberOrUsername; + + public ViewHolder(@NonNull View itemView, Consumer clickConsumer) { + super(itemView); + + this.avatar = itemView.findViewById(R.id.avatar); + this.displayName = itemView.findViewById(R.id.display_name); + this.numberOrUsername = itemView.findViewById(R.id.number_or_username); + + itemView.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + clickConsumer.accept(getAdapterPosition()); + } + }); + } + + public void bind(@NonNull Recipient recipient) { + avatar.setAvatar(recipient); + displayName.setText(recipient.getDisplayName(itemView.getContext())); + + if (recipient.hasAUserSetDisplayName(itemView.getContext())) { + String identifier = recipient.getE164().or(recipient.getUsername()).orNull(); + + if (identifier != null) { + numberOrUsername.setText(identifier); + numberOrUsername.setVisibility(View.VISIBLE); + } else { + numberOrUsername.setVisibility(View.GONE); + } + } else { + numberOrUsername.setVisibility(View.GONE); + } + } + } + + private static final class RecipientDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull Recipient oldItem, @NonNull Recipient newItem) { + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull Recipient oldItem, @NonNull Recipient newItem) { + return oldItem.equals(newItem); + } + } + + interface RecipientClickedListener { + void onRecipientClicked(@NonNull Recipient recipient); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java new file mode 100644 index 00000000..eb7f7e7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.blocked; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class BlockedUsersFragment extends Fragment { + + private BlockedUsersViewModel viewModel; + private Listener listener; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof Listener) { + listener = (Listener) context; + } else { + throw new ClassCastException("Expected context to implement Listener"); + } + } + + @Override + public void onDetach() { + super.onDetach(); + + listener = null; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.blocked_users_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + View addUser = view.findViewById(R.id.add_blocked_user_touch_target); + RecyclerView recycler = view.findViewById(R.id.blocked_users_recycler); + View empty = view.findViewById(R.id.no_blocked_users); + BlockedUsersAdapter adapter = new BlockedUsersAdapter(this::handleRecipientClicked); + + recycler.setAdapter(adapter); + + addUser.setOnClickListener(unused -> { + if (listener != null) { + listener.handleAddUserToBlockedList(); + } + }); + + viewModel = ViewModelProviders.of(requireActivity()).get(BlockedUsersViewModel.class); + viewModel.getRecipients().observe(getViewLifecycleOwner(), list -> { + if (list.isEmpty()) { + empty.setVisibility(View.VISIBLE); + } else { + empty.setVisibility(View.GONE); + } + + adapter.submitList(list); + }); + } + + private void handleRecipientClicked(@NonNull Recipient recipient) { + AlertDialog confirmationDialog = new AlertDialog.Builder(requireContext()) + .setTitle(R.string.BlockedUsersActivity__unblock_user) + .setMessage(getString(R.string.BlockedUsersActivity__do_you_want_to_unblock_s, recipient.getDisplayName(requireContext()))) + .setPositiveButton(R.string.BlockedUsersActivity__unblock, (dialog, which) -> { + viewModel.unblock(recipient.getId()); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .setCancelable(true) + .create(); + + confirmationDialog.setOnShowListener(dialog -> { + confirmationDialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(Color.RED); + }); + + confirmationDialog.show(); + } + + interface Listener { + void handleAddUserToBlockedList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java new file mode 100644 index 00000000..f41ccc95 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.blocked; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +class BlockedUsersRepository { + + private static final String TAG = Log.tag(BlockedUsersRepository.class); + + private final Context context; + + BlockedUsersRepository(@NonNull Context context) { + this.context = context; + } + + void getBlocked(@NonNull Consumer> blockedUsers) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + try (RecipientDatabase.RecipientReader reader = db.readerForBlocked(db.getBlocked())) { + int count = reader.getCount(); + if (count == 0) { + blockedUsers.accept(Collections.emptyList()); + } else { + List recipients = new ArrayList<>(); + while (reader.getNext() != null) { + recipients.add(reader.getCurrent()); + } + blockedUsers.accept(recipients); + } + } + }); + } + + void block(@NonNull RecipientId recipientId, @NonNull Runnable success, @NonNull Runnable failure) { + SignalExecutors.BOUNDED.execute(() -> { + try { + RecipientUtil.block(context, Recipient.resolved(recipientId)); + success.run(); + } catch (IOException | GroupChangeFailedException | GroupChangeBusyException e) { + Log.w(TAG, "block: failed to block recipient: ", e); + failure.run(); + } + }); + } + + void createAndBlock(@NonNull String number, @NonNull Runnable success) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientUtil.blockNonGroup(context, Recipient.external(context, number)); + success.run(); + }); + } + + void unblock(@NonNull RecipientId recipientId, @NonNull Runnable success) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientUtil.unblock(context, Recipient.resolved(recipientId)); + success.run(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java new file mode 100644 index 00000000..f2d6ef0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.blocked; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +import java.util.List; +import java.util.Objects; + +public class BlockedUsersViewModel extends ViewModel { + + private final BlockedUsersRepository repository; + private final MutableLiveData> recipients; + private final SingleLiveEvent events = new SingleLiveEvent<>(); + + private BlockedUsersViewModel(@NonNull BlockedUsersRepository repository) { + this.repository = repository; + this.recipients = new MutableLiveData<>(); + + loadRecipients(); + } + + public LiveData> getRecipients() { + return recipients; + } + + public LiveData getEvents() { + return events; + } + + void block(@NonNull RecipientId recipientId) { + repository.block(recipientId, + () -> { + loadRecipients(); + events.postValue(new Event(EventType.BLOCK_SUCCEEDED, Recipient.resolved(recipientId))); + }, + () -> events.postValue(new Event(EventType.BLOCK_FAILED, Recipient.resolved(recipientId)))); + } + + void createAndBlock(@NonNull String number) { + repository.createAndBlock(number, () -> { + loadRecipients(); + events.postValue(new Event(EventType.BLOCK_SUCCEEDED, number)); + }); + } + + void unblock(@NonNull RecipientId recipientId) { + repository.unblock(recipientId, () -> { + loadRecipients(); + events.postValue(new Event(EventType.UNBLOCK_SUCCEEDED, Recipient.resolved(recipientId))); + }); + } + + private void loadRecipients() { + repository.getBlocked(recipients::postValue); + } + + enum EventType { + BLOCK_SUCCEEDED, + BLOCK_FAILED, + UNBLOCK_SUCCEEDED + } + + public static final class Event { + + private final EventType eventType; + private final Recipient recipient; + private final String number; + + private Event(@NonNull EventType eventType, @NonNull Recipient recipient) { + this.eventType = eventType; + this.recipient = recipient; + this.number = null; + } + + private Event(@NonNull EventType eventType, @NonNull String number) { + this.eventType = eventType; + this.recipient = null; + this.number = number; + } + + public @Nullable Recipient getRecipient() { + return recipient; + } + + public @Nullable String getNumber() { + return number; + } + + public @NonNull EventType getEventType() { + return eventType; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final BlockedUsersRepository repository; + + public Factory(@NonNull BlockedUsersRepository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new BlockedUsersViewModel(repository))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java new file mode 100644 index 00000000..e65d1802 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java @@ -0,0 +1,73 @@ +/** + * Source: https://github.com/hsch/blurhash-java + * + * Copyright (c) 2019 Hendrik Schnepel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.thoughtcrime.securesms.blurhash; + +import androidx.annotation.Nullable; + +final class Base83 { + + private static final int MAX_LENGTH = 90; + + private static final char[]ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray(); + + private static int indexOf(char[] a, char key) { + for (int i = 0; i < a.length; i++) { + if (a[i] == key) { + return i; + } + } + return -1; + } + + static void encode(long value, int length, char[] buffer, int offset) { + int exp = 1; + for (int i = 1; i <= length; i++, exp *= 83) { + int digit = (int)(value / exp % 83); + buffer[offset + length - i] = ALPHABET[digit]; + } + } + + static int decode(String value, int fromInclusive, int toExclusive) { + int result = 0; + char[] chars = value.toCharArray(); + for (int i = fromInclusive; i < toExclusive; i++) { + result = result * 83 + indexOf(ALPHABET, chars[i]); + } + return result; + } + + static boolean isValid(@Nullable String value) { + if (value == null) return false; + final int length = value.length(); + + if (length == 0 || length > MAX_LENGTH) return false; + + for (int i = 0; i < length; i++) { + if (indexOf(ALPHABET, value.charAt(i)) == -1) return false; + } + + return true; + } + + private Base83() { + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java new file mode 100644 index 00000000..fd054f03 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.blurhash; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +/** + * A BlurHash is a compact string representation of a blurred image that we can use to show fast + * image previews. + */ +public class BlurHash { + + private final String hash; + + private BlurHash(@NonNull String hash) { + this.hash = hash; + } + + public static @Nullable BlurHash parseOrNull(@Nullable String hash) { + if (Base83.isValid(hash)) { + return new BlurHash(hash); + } + return null; + } + + public @NonNull String getHash() { + return hash; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BlurHash blurHash = (BlurHash) o; + return Objects.equals(hash, blurHash.hash); + } + + @Override + public int hashCode() { + return Objects.hash(hash); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java new file mode 100644 index 00000000..4012c382 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java @@ -0,0 +1,113 @@ +/** + * Source: https://github.com/woltapp/blurhash + * + * Copyright (c) 2018 Wolt Enterprises + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.thoughtcrime.securesms.blurhash; + +import android.graphics.Bitmap; +import android.graphics.Color; + +import androidx.annotation.Nullable; + +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.linearTosRGB; +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.sRGBToLinear; +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.signPow; + +class BlurHashDecoder { + + static @Nullable Bitmap decode(@Nullable String blurHash, int width, int height) { + return decode(blurHash, width, height, 1f); + } + + static @Nullable Bitmap decode(@Nullable String blurHash, int width, int height, double punch) { + + if (blurHash == null || blurHash.length() < 6) { + return null; + } + + int numCompEnc = Base83.decode(blurHash, 0, 1); + int numCompX = (numCompEnc % 9) + 1; + int numCompY = (numCompEnc / 9) + 1; + + if (blurHash.length() != 4 + 2 * numCompX * numCompY) { + return null; + } + + int maxAcEnc = Base83.decode(blurHash, 1, 2); + double maxAc = (maxAcEnc + 1) / 166f; + double[][] colors = new double[numCompX * numCompY][]; + for (int i = 0; i < colors.length; i++) { + if (i == 0) { + int colorEnc = Base83.decode(blurHash, 2, 6); + colors[i] = decodeDc(colorEnc); + } else { + int from = 4 + i * 2; + int colorEnc = Base83.decode(blurHash, from, from + 2); + colors[i] = decodeAc(colorEnc, maxAc * punch); + } + } + + return composeBitmap(width, height, numCompX, numCompY, colors); + } + + private static double[] decodeDc(int colorEnc) { + int r = colorEnc >> 16; + int g = (colorEnc >> 8) & 255; + int b = colorEnc & 255; + return new double[] {sRGBToLinear(r), + sRGBToLinear(g), + sRGBToLinear(b)}; + } + + private static double[] decodeAc(int value, double maxAc) { + int r = value / (19 * 19); + int g = (value / 19) % 19; + int b = value % 19; + return new double[]{ + signPow((r - 9) / 9.0f, 2f) * maxAc, + signPow((g - 9) / 9.0f, 2f) * maxAc, + signPow((b - 9) / 9.0f, 2f) * maxAc + }; + } + + private static Bitmap composeBitmap(int width, int height, int numCompX, int numCompY, double[][] colors) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + + double r = 0f; + double g = 0f; + double b = 0f; + + for (int j = 0; j < numCompY; j++) { + for (int i = 0; i < numCompX; i++) { + double basis = (Math.cos(Math.PI * x * i / width) * Math.cos(Math.PI * y * j / height)); + double[] color = colors[j * numCompX + i]; + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + bitmap.setPixel(x, y, Color.rgb((int) linearTosRGB(r), (int) linearTosRGB(g), (int) linearTosRGB(b))); + } + } + + return bitmap; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java new file mode 100644 index 00000000..60348191 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java @@ -0,0 +1,148 @@ +/** + * Source: https://github.com/hsch/blurhash-java + * + * Copyright (c) 2019 Hendrik Schnepel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package org.thoughtcrime.securesms.blurhash; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.InputStream; + +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.linearTosRGB; +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.max; +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.sRGBToLinear; +import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.signPow; + +public final class BlurHashEncoder { + + private BlurHashEncoder() { + } + + public static @Nullable String encode(InputStream inputStream) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 16; + + Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options); + if (bitmap == null) return null; + + String hash = encode(bitmap); + + bitmap.recycle(); + + return hash; + } + + public static @Nullable String encode(@NonNull Bitmap bitmap) { + return encode(bitmap, 4, 3); + } + + static String encode(Bitmap bitmap, int componentX, int componentY) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int[] pixels = new int[width * height]; + bitmap.getPixels(pixels, 0, width, 0, 0, width, height); + return encode(pixels, width, height, componentX, componentY); + } + + private static String encode(int[] pixels, int width, int height, int componentX, int componentY) { + + if (componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9) { + throw new IllegalArgumentException("Blur hash must have between 1 and 9 components"); + } + if (width * height != pixels.length) { + throw new IllegalArgumentException("Width and height must match the pixels array"); + } + + double[][] factors = new double[componentX * componentY][3]; + for (int j = 0; j < componentY; j++) { + for (int i = 0; i < componentX; i++) { + double normalisation = i == 0 && j == 0 ? 1 : 2; + applyBasisFunction(pixels, width, height, + normalisation, i, j, + factors, j * componentX + i); + } + } + + char[] hash = new char[1 + 1 + 4 + 2 * (factors.length - 1)]; // size flag + max AC + DC + 2 * AC components + + long sizeFlag = componentX - 1 + (componentY - 1) * 9; + Base83.encode(sizeFlag, 1, hash, 0); + + double maximumValue; + if (factors.length > 1) { + double actualMaximumValue = max(factors, 1, factors.length); + double quantisedMaximumValue = Math.floor(Math.max(0, Math.min(82, Math.floor(actualMaximumValue * 166 - 0.5)))); + maximumValue = (quantisedMaximumValue + 1) / 166; + Base83.encode(Math.round(quantisedMaximumValue), 1, hash, 1); + } else { + maximumValue = 1; + Base83.encode(0, 1, hash, 1); + } + + double[] dc = factors[0]; + Base83.encode(encodeDC(dc), 4, hash, 2); + + for (int i = 1; i < factors.length; i++) { + Base83.encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1)); + } + return new String(hash); + } + + private static void applyBasisFunction(int[] pixels, int width, int height, + double normalisation, int i, int j, + double[][] factors, int index) + { + double r = 0, g = 0, b = 0; + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + double basis = normalisation + * Math.cos((Math.PI * i * x) / width) + * Math.cos((Math.PI * j * y) / height); + int pixel = pixels[y * width + x]; + r += basis * sRGBToLinear((pixel >> 16) & 0xff); + g += basis * sRGBToLinear((pixel >> 8) & 0xff); + b += basis * sRGBToLinear( pixel & 0xff); + } + } + double scale = 1.0 / (width * height); + factors[index][0] = r * scale; + factors[index][1] = g * scale; + factors[index][2] = b * scale; + } + + private static long encodeDC(double[] value) { + long r = linearTosRGB(value[0]); + long g = linearTosRGB(value[1]); + long b = linearTosRGB(value[2]); + return (r << 16) + (g << 8) + b; + } + + private static long encodeAC(double[] value, double maximumValue) { + double quantR = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5)))); + double quantG = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5)))); + double quantB = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5)))); + return Math.round(quantR * 19 * 19 + quantG * 19 + quantB); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java new file mode 100644 index 00000000..0dfb8ed7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.blurhash; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; + +public final class BlurHashModelLoader implements ModelLoader { + + private BlurHashModelLoader() {} + + @Override + public LoadData buildLoadData(@NonNull BlurHash blurHash, + int width, + int height, + @NonNull Options options) + { + return new LoadData<>(new ObjectKey(blurHash.getHash()), new BlurDataFetcher(blurHash)); + } + + @Override + public boolean handles(@NonNull BlurHash blurHash) { + return true; + } + + private final class BlurDataFetcher implements DataFetcher { + + private final BlurHash blurHash; + + private BlurDataFetcher(@NonNull BlurHash blurHash) { + this.blurHash = blurHash; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + callback.onDataReady(blurHash); + } + + @Override + public void cleanup() { + } + + @Override + public void cancel() { + } + + @Override + public @NonNull Class getDataClass() { + return BlurHash.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.LOCAL; + } + } + + public static class Factory implements ModelLoaderFactory { + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new BlurHashModelLoader(); + } + + @Override + public void teardown() { + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java new file mode 100644 index 00000000..a4464b93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.blurhash; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; + +import java.io.IOException; + +public class BlurHashResourceDecoder implements ResourceDecoder { + + private static final int MAX_DIMEN = 20; + + @Override + public boolean handles(@NonNull BlurHash source, @NonNull Options options) throws IOException { + return true; + } + + @Override + public @Nullable Resource decode(@NonNull BlurHash source, int width, int height, @NonNull Options options) throws IOException { + final int finalWidth; + final int finalHeight; + + if (width > height) { + finalWidth = Math.min(width, MAX_DIMEN); + finalHeight = (int) (finalWidth * height / (float) width); + } else { + finalHeight = Math.min(height, MAX_DIMEN); + finalWidth = (int) (finalHeight * width / (float) height); + } + + return new SimpleResource<>(BlurHashDecoder.decode(source.getHash(), finalWidth, finalHeight)); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java new file mode 100644 index 00000000..0012c18e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java @@ -0,0 +1,62 @@ +/** + * Source: https://github.com/hsch/blurhash-java + * + * Copyright (c) 2019 Hendrik Schnepel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.thoughtcrime.securesms.blurhash; + +final class BlurHashUtil { + + static double sRGBToLinear(long value) { + double v = value / 255.0; + if (v <= 0.04045) { + return v / 12.92; + } else { + return Math.pow((v + 0.055) / 1.055, 2.4); + } + } + + static long linearTosRGB(double value) { + double v = Math.max(0, Math.min(1, value)); + if (v <= 0.0031308) { + return (long)(v * 12.92 * 255 + 0.5); + } else { + return (long)((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5); + } + } + + static double signPow(double val, double exp) { + return Math.copySign(Math.pow(Math.abs(val), exp), val); + } + + static double max(double[][] values, int from, int endExclusive) { + double result = Double.NEGATIVE_INFINITY; + for (int i = from; i < endExclusive; i++) { + for (int j = 0; j < values[i].length; j++) { + double value = values[i][j]; + if (value > result) { + result = value; + } + } + } + return result; + } + + private BlurHashUtil() { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java new file mode 100644 index 00000000..9372a96d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.color; + +import android.content.Context; +import android.graphics.Color; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.HashMap; +import java.util.Map; + +import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme; + +public enum MaterialColor { + CRIMSON (R.color.conversation_crimson, R.color.conversation_crimson_tint, R.color.conversation_crimson_shade, "red"), + VERMILLION (R.color.conversation_vermillion, R.color.conversation_vermillion_tint, R.color.conversation_vermillion_shade, "orange"), + BURLAP (R.color.conversation_burlap, R.color.conversation_burlap_tint, R.color.conversation_burlap_shade, "brown"), + FOREST (R.color.conversation_forest, R.color.conversation_forest_tint, R.color.conversation_forest_shade, "green"), + WINTERGREEN(R.color.conversation_wintergreen, R.color.conversation_wintergreen_tint, R.color.conversation_wintergreen_shade, "light_green"), + TEAL (R.color.conversation_teal, R.color.conversation_teal_tint, R.color.conversation_teal_shade, "teal"), + BLUE (R.color.conversation_blue, R.color.conversation_blue_tint, R.color.conversation_blue_shade, "blue"), + INDIGO (R.color.conversation_indigo, R.color.conversation_indigo_tint, R.color.conversation_indigo_shade, "indigo"), + VIOLET (R.color.conversation_violet, R.color.conversation_violet_tint, R.color.conversation_violet_shade, "purple"), + PLUM (R.color.conversation_plumb, R.color.conversation_plumb_tint, R.color.conversation_plumb_shade, "pink"), + TAUPE (R.color.conversation_taupe, R.color.conversation_taupe_tint, R.color.conversation_taupe_shade, "blue_grey"), + STEEL (R.color.conversation_steel, R.color.conversation_steel_tint, R.color.conversation_steel_shade, "grey"), + ULTRAMARINE(R.color.conversation_ultramarine, R.color.conversation_ultramarine_tint, R.color.conversation_ultramarine_shade, "ultramarine"), + GROUP (R.color.conversation_group, R.color.conversation_group_tint, R.color.conversation_group_shade, "blue"); + + private static final Map COLOR_MATCHES = new HashMap() {{ + put("red", CRIMSON); + put("deep_orange", CRIMSON); + put("orange", VERMILLION); + put("amber", VERMILLION); + put("brown", BURLAP); + put("yellow", BURLAP); + put("pink", PLUM); + put("purple", VIOLET); + put("deep_purple", VIOLET); + put("indigo", INDIGO); + put("blue", BLUE); + put("light_blue", BLUE); + put("cyan", TEAL); + put("teal", TEAL); + put("green", FOREST); + put("light_green", WINTERGREEN); + put("lime", WINTERGREEN); + put("blue_grey", TAUPE); + put("grey", STEEL); + put("ultramarine", ULTRAMARINE); + put("group_color", GROUP); + }}; + + private final @ColorRes int mainColor; + private final @ColorRes int tintColor; + private final @ColorRes int shadeColor; + + private final String serialized; + + + MaterialColor(@ColorRes int mainColor, @ColorRes int tintColor, @ColorRes int shadeColor, String serialized) { + this.mainColor = mainColor; + this.tintColor = tintColor; + this.shadeColor = shadeColor; + this.serialized = serialized; + } + + public @ColorInt int toNotificationColor(@NonNull Context context) { + final boolean isDark = ThemeUtil.isDarkNotificationTheme(context); + return context.getResources().getColor(isDark ? shadeColor : mainColor); + } + + public @ColorInt int toConversationColor(@NonNull Context context) { + return context.getResources().getColor(mainColor); + } + + public @ColorInt int toAvatarColor(@NonNull Context context) { + return context.getResources().getColor(isDarkTheme(context) ? shadeColor : mainColor); + } + + public @ColorInt int toActionBarColor(@NonNull Context context) { + return context.getResources().getColor(mainColor); + } + + public @ColorInt int toStatusBarColor(@NonNull Context context) { + return context.getResources().getColor(mainColor); + } + + public @ColorRes int toQuoteBarColorResource(@NonNull Context context, boolean outgoing) { + if (outgoing) { + return isDarkTheme(context) ? tintColor : shadeColor ; + } + return R.color.core_white; + } + + public @ColorInt int toQuoteBackgroundColor(@NonNull Context context, boolean outgoing) { + if (outgoing) { + int color = toConversationColor(context); + int alpha = isDarkTheme(context) ? (int) (0.2 * 255) : (int) (0.4 * 255); + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_40 + : R.color.transparent_white_60); + } + + public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) { + if (outgoing) { + int color = toConversationColor(context); + int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255); + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_60 + : R.color.transparent_white_80); + } + + public boolean represents(Context context, int colorValue) { + return context.getResources().getColor(mainColor) == colorValue || + context.getResources().getColor(tintColor) == colorValue || + context.getResources().getColor(shadeColor) == colorValue; + } + + public String serialize() { + return serialized; + } + + public static MaterialColor fromSerialized(String serialized) throws UnknownColorException { + if (COLOR_MATCHES.containsKey(serialized)) { + return COLOR_MATCHES.get(serialized); + } + + throw new UnknownColorException("Unknown color: " + serialized); + } + + public static class UnknownColorException extends Exception { + public UnknownColorException(String message) { + super(message); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java new file mode 100644 index 00000000..f64d7a21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.color; + +import android.content.Context; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MaterialColors { + + public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList( + MaterialColor.PLUM, + MaterialColor.CRIMSON, + MaterialColor.VERMILLION, + MaterialColor.VIOLET, + MaterialColor.INDIGO, + MaterialColor.TAUPE, + MaterialColor.ULTRAMARINE, + MaterialColor.BLUE, + MaterialColor.TEAL, + MaterialColor.FOREST, + MaterialColor.WINTERGREEN, + MaterialColor.BURLAP, + MaterialColor.STEEL + ))); + + public static class MaterialColorList { + + private final List colors; + + private MaterialColorList(List colors) { + this.colors = colors; + } + + public MaterialColor get(int index) { + return colors.get(index); + } + + public int size() { + return colors.size(); + } + + public @Nullable MaterialColor getByColor(Context context, int colorValue) { + for (MaterialColor color : colors) { + if (color.represents(context, colorValue)) { + return color; + } + } + + return null; + } + + public @ColorInt int[] asConversationColorArray(@NonNull Context context) { + int[] results = new int[colors.size()]; + int index = 0; + + for (MaterialColor color : colors) { + results[index++] = color.toConversationColor(context); + } + + return results; + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java new file mode 100644 index 00000000..b02e0894 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatToggleButton; + +public class AccessibleToggleButton extends AppCompatToggleButton { + + private OnCheckedChangeListener listener; + + public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public AccessibleToggleButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AccessibleToggleButton(Context context) { + super(context); + } + + @Override + public void setOnCheckedChangeListener(OnCheckedChangeListener listener) { + super.setOnCheckedChangeListener(listener); + this.listener = listener; + } + + public void setChecked(boolean checked, boolean notifyListener) { + if (!notifyListener) { + super.setOnCheckedChangeListener(null); + } + + super.setChecked(checked); + + if (!notifyListener) { + super.setOnCheckedChangeListener(listener); + } + } + + public OnCheckedChangeListener getOnCheckedChangeListener() { + return this.listener; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java new file mode 100644 index 00000000..7864e26e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.views.Stub; + +import java.util.List; + +public class AlbumThumbnailView extends FrameLayout { + + private @Nullable SlideClickListener thumbnailClickListener; + private @Nullable SlidesClickedListener downloadClickListener; + + private int currentSizeClass; + + private ViewGroup albumCellContainer; + private Stub transferControls; + + private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> { + if (thumbnailClickListener != null) { + thumbnailClickListener.onClick(v, slide); + } + }; + + private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick(); + + public AlbumThumbnailView(@NonNull Context context) { + super(context); + initialize(); + } + + public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.album_thumbnail_view, this); + + albumCellContainer = findViewById(R.id.album_cell_container); + transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); + } + + public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List slides, boolean showControls) { + if (slides.size() < 2) { + throw new IllegalStateException("Provided less than two slides."); + } + + if (showControls) { + transferControls.get().setShowDownloadText(true); + transferControls.get().setSlides(slides); + transferControls.get().setDownloadClickListener(v -> { + if (downloadClickListener != null) { + downloadClickListener.onClick(v, slides); + } + }); + } else { + if (transferControls.resolved()) { + transferControls.get().setVisibility(GONE); + } + } + + int sizeClass = sizeClass(slides.size()); + + if (sizeClass != currentSizeClass) { + inflateLayout(sizeClass); + currentSizeClass = sizeClass; + } + + showSlides(glideRequests, slides); + } + + public void setCellBackgroundColor(@ColorInt int color) { + ViewGroup cellRoot = findViewById(R.id.album_thumbnail_root); + + if (cellRoot != null) { + for (int i = 0; i < cellRoot.getChildCount(); i++) { + cellRoot.getChildAt(i).setBackgroundColor(color); + } + } + } + + public void setThumbnailClickListener(@Nullable SlideClickListener listener) { + thumbnailClickListener = listener; + } + + public void setDownloadClickListener(@Nullable SlidesClickedListener listener) { + downloadClickListener = listener; + } + + private void inflateLayout(int sizeClass) { + albumCellContainer.removeAllViews(); + + switch (sizeClass) { + case 2: + inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer); + break; + case 3: + inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer); + break; + case 4: + inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer); + break; + case 5: + inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer); + break; + default: + inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer); + break; + } + } + + private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List slides) { + setSlide(glideRequests, slides.get(0), R.id.album_cell_1); + setSlide(glideRequests, slides.get(1), R.id.album_cell_2); + + if (slides.size() >= 3) { + setSlide(glideRequests, slides.get(2), R.id.album_cell_3); + } + + if (slides.size() >= 4) { + setSlide(glideRequests, slides.get(3), R.id.album_cell_4); + } + + if (slides.size() >= 5) { + setSlide(glideRequests, slides.get(4), R.id.album_cell_5); + } + + if (slides.size() > 5) { + TextView text = findViewById(R.id.album_cell_overflow_text); + text.setText(getContext().getString(R.string.AlbumThumbnailView_plus, slides.size() - 5)); + } + } + + private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) { + ThumbnailView cell = findViewById(id); + cell.setImageResource(glideRequests, slide, false, false); + cell.setThumbnailClickListener(defaultThumbnailClickListener); + cell.setOnLongClickListener(defaultLongClickListener); + } + + private int sizeClass(int size) { + return Math.min(size, 6); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java new file mode 100644 index 00000000..1c703079 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build.VERSION_CODES; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; + +public class AlertView extends LinearLayout { + + private static final String TAG = AlertView.class.getSimpleName(); + + private ImageView approvalIndicator; + private ImageView failedIndicator; + + public AlertView(Context context) { + this(context, null); + } + + public AlertView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) + public AlertView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(attrs); + } + + private void initialize(AttributeSet attrs) { + inflate(getContext(), R.layout.alert_view, this); + + approvalIndicator = findViewById(R.id.pending_approval_indicator); + failedIndicator = findViewById(R.id.sms_failed_indicator); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.AlertView, 0, 0); + boolean useSmallIcon = typedArray.getBoolean(R.styleable.AlertView_useSmallIcon, false); + typedArray.recycle(); + + if (useSmallIcon) { + int size = getResources().getDimensionPixelOffset(R.dimen.alertview_small_icon_size); + failedIndicator.getLayoutParams().width = size; + failedIndicator.getLayoutParams().height = size; + requestLayout(); + } + } + } + + public void setNone() { + this.setVisibility(View.GONE); + } + + public void setPendingApproval() { + this.setVisibility(View.VISIBLE); + approvalIndicator.setVisibility(View.VISIBLE); + failedIndicator.setVisibility(View.GONE); + } + + public void setFailed() { + this.setVisibility(View.VISIBLE); + approvalIndicator.setVisibility(View.GONE); + failedIndicator.setVisibility(View.VISIBLE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java new file mode 100644 index 00000000..41676c6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class AnimatingToggle extends FrameLayout { + + private View current; + + private final Animation inAnimation; + private final Animation outAnimation; + + public AnimatingToggle(Context context) { + this(context, null); + } + + public AnimatingToggle(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AnimatingToggle(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + this.outAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_out); + this.inAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_in); + this.outAnimation.setInterpolator(new FastOutSlowInInterpolator()); + this.inAnimation.setInterpolator(new FastOutSlowInInterpolator()); + } + + @Override + public void addView(@NonNull View child, int index, ViewGroup.LayoutParams params) { + super.addView(child, index, params); + + if (!isInEditMode()) { + if (getChildCount() == 1) { + current = child; + child.setVisibility(View.VISIBLE); + } else { + child.setVisibility(View.GONE); + } + child.setClickable(false); + } + } + + public void display(@Nullable View view) { + if (view == current) return; + if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE); + if (view != null) ViewUtil.animateIn(view, inAnimation); + + current = view; + } + + public void displayQuick(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + if (view != null) view.setVisibility(View.VISIBLE); + + current = view; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java new file mode 100644 index 00000000..e9062fb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +public class ArcProgressBar extends View { + + private static final int DEFAULT_WIDTH = 10; + private static final float DEFAULT_PROGRESS = 0f; + private static final int DEFAULT_BACKGROUND_COLOR = 0xFF000000; + private static final int DEFAULT_FOREGROUND_COLOR = 0xFFFFFFFF; + private static final float DEFAULT_START_ANGLE = 0f; + private static final float DEFAULT_SWEEP_ANGLE = 360f; + private static final boolean DEFAULT_ROUNDED_ENDS = true; + + private static final String SUPER = "arcprogressbar.super"; + private static final String PROGRESS = "arcprogressbar.progress"; + + private float progress; + private final float width; + private final RectF arcRect = new RectF(); + + private final Paint arcBackgroundPaint; + private final Paint arcForegroundPaint; + private final float arcStartAngle; + private final float arcSweepAngle; + + public ArcProgressBar(@NonNull Context context) { + this(context, null); + } + + public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ArcProgressBar, defStyleAttr, 0); + + width = attributes.getDimensionPixelSize(R.styleable.ArcProgressBar_arcWidth, DEFAULT_WIDTH); + progress = attributes.getFloat(R.styleable.ArcProgressBar_arcProgress, DEFAULT_PROGRESS); + arcBackgroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcBackgroundColor, DEFAULT_BACKGROUND_COLOR)); + arcForegroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcForegroundColor, DEFAULT_FOREGROUND_COLOR)); + arcStartAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcStartAngle, DEFAULT_START_ANGLE); + arcSweepAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcSweepAngle, DEFAULT_SWEEP_ANGLE); + + if (attributes.getBoolean(R.styleable.ArcProgressBar_arcRoundedEnds, DEFAULT_ROUNDED_ENDS)) { + arcForegroundPaint.setStrokeCap(Paint.Cap.ROUND); + + if (arcSweepAngle <= 360f) { + arcBackgroundPaint.setStrokeCap(Paint.Cap.ROUND); + } + } + + attributes.recycle(); + } + + private static Paint createPaint(float width, @ColorInt int color) { + Paint paint = new Paint(); + + paint.setStrokeWidth(width); + paint.setStyle(Paint.Style.STROKE); + paint.setAntiAlias(true); + paint.setColor(color); + + return paint; + } + + public void setProgress(float progress) { + if (this.progress != progress) { + this.progress = progress; + invalidate(); + } + } + + @Override + protected @Nullable Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + + Bundle bundle = new Bundle(); + bundle.putParcelable(SUPER, superState); + bundle.putFloat(PROGRESS, progress); + + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state.getClass() != Bundle.class) throw new IllegalStateException("Expected"); + + Bundle restoreState = (Bundle) state; + + Parcelable superState = restoreState.getParcelable(SUPER); + super.onRestoreInstanceState(superState); + + progress = restoreState.getLong(PROGRESS); + } + + @Override + protected void onDraw(Canvas canvas) { + float halfWidth = width / 2f; + arcRect.set(0 + halfWidth, + 0 + halfWidth, + getWidth() - halfWidth, + getHeight() - halfWidth); + + canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle, false, arcBackgroundPaint); + canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle * Util.clamp(progress, 0f, 1f), false, arcForegroundPaint); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java new file mode 100644 index 00000000..a0d776cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -0,0 +1,479 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Observer; + +import com.airbnb.lottie.LottieAnimationView; +import com.airbnb.lottie.LottieProperty; +import com.airbnb.lottie.SimpleColorFilter; +import com.airbnb.lottie.model.KeyPath; +import com.airbnb.lottie.value.LottieValueCallback; +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.audio.AudioWaveForm; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class AudioView extends FrameLayout { + + private static final String TAG = AudioView.class.getSimpleName(); + + private static final int FORWARDS = 1; + private static final int REVERSE = -1; + + @NonNull private final AnimatingToggle controlToggle; + @NonNull private final View progressAndPlay; + @NonNull private final LottieAnimationView playPauseButton; + @NonNull private final ImageView downloadButton; + @NonNull private final ProgressWheel circleProgress; + @NonNull private final SeekBar seekBar; + private final boolean smallView; + private final boolean autoRewind; + + @Nullable private final TextView duration; + + @ColorInt private final int waveFormPlayedBarsColor; + @ColorInt private final int waveFormUnplayedBarsColor; + @ColorInt private final int waveFormThumbTint; + + @Nullable private SlideClickListener downloadListener; + private int backwardsCounter; + private int lottieDirection; + private boolean isPlaying; + private long durationMillis; + private AudioSlide audioSlide; + private Callbacks callbacks; + + private final Observer playbackStateObserver = this::onPlaybackState; + + public AudioView(Context context) { + this(context, null); + } + + public AudioView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + TypedArray typedArray = null; + try { + typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0); + + smallView = typedArray.getBoolean(R.styleable.AudioView_small, false); + autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false); + + inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this); + + this.controlToggle = findViewById(R.id.control_toggle); + this.playPauseButton = findViewById(R.id.play); + this.progressAndPlay = findViewById(R.id.progress_and_play); + this.downloadButton = findViewById(R.id.download); + this.circleProgress = findViewById(R.id.circle_progress); + this.seekBar = findViewById(R.id.seek); + this.duration = findViewById(R.id.duration); + + lottieDirection = REVERSE; + this.playPauseButton.setOnClickListener(new PlayPauseClickedListener()); + this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener()); + + setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE)); + + this.waveFormPlayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformPlayedBarsColor, Color.WHITE); + this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE); + this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE); + + progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN); + } finally { + if (typedArray != null) { + typedArray.recycle(); + } + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + EventBus.getDefault().unregister(this); + } + + public Observer getPlaybackStateObserver() { + return playbackStateObserver; + } + + public void setAudio(final @NonNull AudioSlide audio, + final @Nullable Callbacks callbacks, + final boolean showControls, + final boolean forceHideDuration) + { + this.callbacks = callbacks; + + if (duration != null) { + duration.setVisibility(View.VISIBLE); + } + + if (seekBar instanceof WaveFormSeekBarView) { + if (audioSlide != null && !Objects.equals(audioSlide.getUri(), audio.getUri())) { + WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar; + waveFormView.setWaveMode(false); + seekBar.setProgress(0); + durationMillis = 0; + } + } + + if (showControls && audio.isPendingDownload()) { + controlToggle.displayQuick(downloadButton); + seekBar.setEnabled(false); + downloadButton.setOnClickListener(new DownloadClickedListener(audio)); + if (circleProgress.isSpinning()) circleProgress.stopSpinning(); + circleProgress.setVisibility(View.GONE); + } else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + controlToggle.displayQuick(progressAndPlay); + seekBar.setEnabled(false); + circleProgress.setVisibility(View.VISIBLE); + circleProgress.spin(); + } else { + seekBar.setEnabled(true); + if (circleProgress.isSpinning()) circleProgress.stopSpinning(); + showPlayButton(); + } + + this.audioSlide = audio; + + if (seekBar instanceof WaveFormSeekBarView) { + WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar; + waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint); + if (android.os.Build.VERSION.SDK_INT >= 23) { + new AudioWaveForm(getContext(), audio).getWaveForm( + data -> { + durationMillis = data.getDuration(TimeUnit.MILLISECONDS); + updateProgress(0, 0); + if (!forceHideDuration && duration != null) { + duration.setVisibility(VISIBLE); + } + waveFormView.setWaveData(data.getWaveForm()); + }, + () -> waveFormView.setWaveMode(false)); + } else { + waveFormView.setWaveMode(false); + if (duration != null) { + duration.setVisibility(GONE); + } + } + } + + if (forceHideDuration && duration != null) { + duration.setVisibility(View.GONE); + } + } + + public void setDownloadClickListener(@Nullable SlideClickListener listener) { + this.downloadListener = listener; + } + + public @Nullable Uri getAudioSlideUri() { + if (audioSlide != null) return audioSlide.getUri(); + else return null; + } + + private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) { + onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration()); + onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset()); + onProgress(voiceNotePlaybackState.getUri(), + (double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(), + voiceNotePlaybackState.getPlayheadPositionMillis()); + } + + private void onDuration(@NonNull Uri uri, long durationMillis) { + if (isTarget(uri)) { + this.durationMillis = durationMillis; + } + } + + private void onStart(@NonNull Uri uri, boolean autoReset) { + if (!isTarget(uri)) { + if (hasAudioUri()) { + onStop(audioSlide.getUri(), autoReset); + } + + return; + } + + if (isPlaying) { + return; + } + + isPlaying = true; + togglePlayToPause(); + } + + private void onStop(@NonNull Uri uri, boolean autoReset) { + if (!isTarget(uri)) { + return; + } + + if (!isPlaying) { + return; + } + + isPlaying = false; + togglePauseToPlay(); + + if (autoReset || autoRewind || seekBar.getProgress() + 5 >= seekBar.getMax()) { + backwardsCounter = 4; + rewind(); + } + } + + private void onProgress(@NonNull Uri uri, double progress, long millis) { + if (!isTarget(uri)) { + return; + } + + int seekProgress = (int) Math.floor(progress * seekBar.getMax()); + + if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { + backwardsCounter = 0; + seekBar.setProgress(seekProgress); + updateProgress((float) progress, millis); + } else { + backwardsCounter++; + } + } + + private boolean isTarget(@NonNull Uri uri) { + return hasAudioUri() && Objects.equals(uri, audioSlide.getUri()); + } + + private boolean hasAudioUri() { + return audioSlide != null && audioSlide.getUri() != null; + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + this.playPauseButton.setFocusable(focusable); + this.seekBar.setFocusable(focusable); + this.seekBar.setFocusableInTouchMode(focusable); + this.downloadButton.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + this.playPauseButton.setClickable(clickable); + this.seekBar.setClickable(clickable); + this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener()); + this.downloadButton.setClickable(clickable); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + this.playPauseButton.setEnabled(enabled); + this.seekBar.setEnabled(enabled); + this.downloadButton.setEnabled(enabled); + } + + private void updateProgress(float progress, long millis) { + if (callbacks != null) { + callbacks.onProgressUpdated(durationMillis, millis); + } + + if (duration != null && durationMillis > 0) { + long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis); + duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60)); + } + + if (smallView) { + circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress); + } + } + + public void setTint(int foregroundTint) { + post(()-> this.playPauseButton.addValueCallback(new KeyPath("**"), + LottieProperty.COLOR_FILTER, + new LottieValueCallback<>(new SimpleColorFilter(foregroundTint)))); + + this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); + this.circleProgress.setBarColor(foregroundTint); + + if (this.duration != null) { + this.duration.setTextColor(foregroundTint); + } + this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); + this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); + } + + public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) { + seekBar.getGlobalVisibleRect(rect); + } + + private double getProgress() { + if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { + return 0; + } else { + return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax(); + } + } + + private void togglePlayToPause() { + startLottieAnimation(FORWARDS); + } + + private void togglePauseToPlay() { + startLottieAnimation(REVERSE); + } + + private void startLottieAnimation(int direction) { + showPlayButton(); + + if (lottieDirection == direction) { + return; + } + lottieDirection = direction; + + playPauseButton.pauseAnimation(); + playPauseButton.setSpeed(direction * 2); + playPauseButton.resumeAnimation(); + } + + private void showPlayButton() { + if (!smallView) { + circleProgress.setVisibility(GONE); + } else if (seekBar.getProgress() == 0) { + circleProgress.setInstantProgress(1); + } + playPauseButton.setVisibility(VISIBLE); + controlToggle.displayQuick(progressAndPlay); + } + + public void stopPlaybackAndReset() { + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + callbacks.onStopAndReset(audioSlide.getUri()); + rewind(); + } + } + + private class PlayPauseClickedListener implements View.OnClickListener { + + @Override + public void onClick(View v) { + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + if (lottieDirection == REVERSE) { + callbacks.onPlay(audioSlide.getUri(), getProgress()); + } else { + callbacks.onPause(audioSlide.getUri()); + } + } + } + } + + private void rewind() { + seekBar.setProgress(0); + updateProgress(0, 0); + } + + private class DownloadClickedListener implements View.OnClickListener { + private final @NonNull AudioSlide slide; + + private DownloadClickedListener(@NonNull AudioSlide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (downloadListener != null) downloadListener.onClick(v, slide); + } + } + + private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener { + + private boolean wasPlaying; + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + } + + @Override + public synchronized void onStartTrackingTouch(SeekBar seekBar) { + if (audioSlide == null || audioSlide.getUri() == null) return; + + wasPlaying = isPlaying; + if (isPlaying) { + if (callbacks != null) { + callbacks.onPause(audioSlide.getUri()); + } + } + } + + @Override + public synchronized void onStopTrackingTouch(SeekBar seekBar) { + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + if (wasPlaying) { + callbacks.onSeekTo(audioSlide.getUri(), getProgress()); + } + } + } + } + + private static class TouchIgnoringListener implements OnTouchListener { + @Override + public boolean onTouch(View v, MotionEvent event) { + return true; + } + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventAsync(final PartProgressEvent event) { + if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) { + circleProgress.setInstantProgress(((float) event.progress) / event.total); + } + } + + public interface Callbacks { + void onPlay(@NonNull Uri audioUri, double progress); + void onPause(@NonNull Uri audioUri); + void onSeekTo(@NonNull Uri audioUri, double progress); + void onStopAndReset(@NonNull Uri audioUri); + void onProgressUpdated(long durationMillis, long playheadMillis); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java new file mode 100644 index 00000000..39a0e36d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.fragment.app.FragmentActivity; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Objects; + +public final class AvatarImageView extends AppCompatImageView { + + private static final int SIZE_LARGE = 1; + private static final int SIZE_SMALL = 2; + + @SuppressWarnings("unused") + private static final String TAG = AvatarImageView.class.getSimpleName(); + + private static final Paint LIGHT_THEME_OUTLINE_PAINT = new Paint(); + private static final Paint DARK_THEME_OUTLINE_PAINT = new Paint(); + + static { + LIGHT_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 0, 0, 0)); + LIGHT_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE); + LIGHT_THEME_OUTLINE_PAINT.setStrokeWidth(1); + LIGHT_THEME_OUTLINE_PAINT.setAntiAlias(true); + + DARK_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 255, 255, 255)); + DARK_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE); + DARK_THEME_OUTLINE_PAINT.setStrokeWidth(1); + DARK_THEME_OUTLINE_PAINT.setAntiAlias(true); + } + + private int size; + private boolean inverted; + private Paint outlinePaint; + private OnClickListener listener; + private Recipient.FallbackPhotoProvider fallbackPhotoProvider; + + private @Nullable RecipientContactPhoto recipientContactPhoto; + private @NonNull Drawable unknownRecipientDrawable; + + public AvatarImageView(Context context) { + super(context); + initialize(context, null); + } + + public AvatarImageView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs); + } + + private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) { + setScaleType(ScaleType.CENTER_CROP); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0); + inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false); + size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE); + typedArray.recycle(); + } + + outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT; + + unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + float width = getWidth() - getPaddingRight() - getPaddingLeft(); + float height = getHeight() - getPaddingBottom() - getPaddingTop(); + float cx = width / 2f; + float cy = height / 2f; + float radius = Math.min(cx, cy) - (outlinePaint.getStrokeWidth() / 2f); + + canvas.translate(getPaddingLeft(), getPaddingTop()); + canvas.drawCircle(cx, cy, radius, outlinePaint); + } + + @Override + public void setOnClickListener(OnClickListener listener) { + this.listener = listener; + super.setOnClickListener(listener); + } + + public void setFallbackPhotoProvider(Recipient.FallbackPhotoProvider fallbackPhotoProvider) { + this.fallbackPhotoProvider = fallbackPhotoProvider; + } + + /** + * Shows self as the actual profile picture. + */ + public void setRecipient(@NonNull Recipient recipient) { + if (recipient.isSelf()) { + setAvatar(GlideApp.with(this), null, false); + AvatarUtil.loadIconIntoImageView(recipient, this); + } else { + setAvatar(GlideApp.with(this), recipient, false); + } + } + + /** + * Shows self as the note to self icon. + */ + public void setAvatar(@Nullable Recipient recipient) { + setAvatar(GlideApp.with(this), recipient, false); + } + + /** + * Shows self as the profile avatar. + */ + public void setAvatarUsingProfile(@Nullable Recipient recipient) { + setAvatar(GlideApp.with(this), recipient, false, true); + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) { + setAvatar(requestManager, recipient, quickContactEnabled, false); + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) { + if (recipient != null) { + RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient, + new ProfileContactPhoto(Recipient.self(), + Recipient.self().getProfileAvatar())) + : new RecipientContactPhoto(recipient); + + if (!photo.equals(recipientContactPhoto)) { + requestManager.clear(this); + recipientContactPhoto = photo; + + Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL + ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider) + : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider); + + if (photo.contactPhoto != null) { + requestManager.load(photo.contactPhoto) + .fallback(fallbackContactPhotoDrawable) + .error(fallbackContactPhotoDrawable) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .into(this); + } else { + setImageDrawable(fallbackContactPhotoDrawable); + } + } + + setAvatarClickHandler(recipient, quickContactEnabled); + } else { + recipientContactPhoto = null; + requestManager.clear(this); + if (fallbackPhotoProvider != null) { + setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName() + .asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted)); + } else { + setImageDrawable(unknownRecipientDrawable); + } + + super.setOnClickListener(listener); + } + } + + private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) { + if (quickContactEnabled) { + super.setOnClickListener(v -> { + Context context = getContext(); + if (recipient.isPushGroup()) { + context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()), + ManageGroupActivity.createTransitionBundle(context, this)); + } else { + if (context instanceof FragmentActivity) { + RecipientBottomSheetDialogFragment.create(recipient.getId(), null) + .show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM"); + } else { + context.startActivity(ManageRecipientActivity.newIntent(context, recipient.getId()), + ManageRecipientActivity.createTransitionBundle(context, this)); + } + } + }); + } else { + super.setOnClickListener(listener); + setClickable(listener != null); + } + } + + public void setImageBytesForGroup(@Nullable byte[] avatarBytes, + @Nullable Recipient.FallbackPhotoProvider fallbackPhotoProvider, + @NonNull MaterialColor color) + { + Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER) + .getPhotoForGroup() + .asDrawable(getContext(), color.toAvatarColor(getContext())); + + GlideApp.with(this) + .load(avatarBytes) + .fallback(fallback) + .error(fallback) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .into(this); + } + + private static class RecipientContactPhoto { + + private final @NonNull Recipient recipient; + private final @Nullable ContactPhoto contactPhoto; + private final boolean ready; + + RecipientContactPhoto(@NonNull Recipient recipient) { + this(recipient, recipient.getContactPhoto()); + } + + RecipientContactPhoto(@NonNull Recipient recipient, @Nullable ContactPhoto contactPhoto) { + this.recipient = recipient; + this.ready = !recipient.isResolving(); + this.contactPhoto = contactPhoto; + } + + public boolean equals(@Nullable RecipientContactPhoto other) { + if (other == null) return false; + + return other.recipient.equals(recipient) && + other.recipient.getColor().equals(recipient.getColor()) && + other.ready == ready && + Objects.equals(other.contactPhoto, contactPhoto); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java new file mode 100644 index 00000000..59f46bc3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.load.resource.bitmap.CenterInside; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; + +public class BorderlessImageView extends FrameLayout { + + private ThumbnailView image; + private View missingShade; + + public BorderlessImageView(@NonNull Context context) { + super(context); + init(); + } + + public BorderlessImageView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + inflate(getContext(), R.layout.sticker_view, this); + + this.image = findViewById(R.id.sticker_thumbnail); + this.missingShade = findViewById(R.id.sticker_missing_shade); + } + + @Override + public void setFocusable(boolean focusable) { + image.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + image.setClickable(clickable); + } + + @Override + public void setOnLongClickListener(@Nullable OnLongClickListener l) { + image.setOnLongClickListener(l); + } + + public void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { + boolean showControls = slide.asAttachment().getUri() == null; + + if (slide.hasSticker()) { + image.setFit(new CenterInside()); + image.setImageResource(glideRequests, slide, showControls, false); + } else { + image.setFit(new CenterCrop()); + image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight()); + } + + missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE); + } + + public void setThumbnailClickListener(@NonNull SlideClickListener listener) { + image.setThumbnailClickListener(listener); + } + + public void setDownloadClickListener(@NonNull SlidesClickedListener listener) { + image.setDownloadClickListener(listener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java new file mode 100644 index 00000000..7284a426 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.LayerDrawable; + +import org.thoughtcrime.securesms.R; + +public class BubbleDrawableBuilder { + private int color; + private int shadowColor; + private boolean hasShadow = true; + private boolean[] corners = new boolean[]{true,true,true,true}; + + protected BubbleDrawableBuilder() { } + + public BubbleDrawableBuilder setColor(int color) { + this.color = color; + return this; + } + + public BubbleDrawableBuilder setShadowColor(int shadowColor) { + this.shadowColor = shadowColor; + return this; + } + + public BubbleDrawableBuilder setHasShadow(boolean hasShadow) { + this.hasShadow = hasShadow; + return this; + } + + public BubbleDrawableBuilder setCorners(boolean[] corners) { + this.corners = corners; + return this; + } + + public Drawable create(Context context) { + final GradientDrawable bubble = new GradientDrawable(); + final int radius = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius); + final float[] radii = cornerBooleansToRadii(corners, radius); + + bubble.setColor(color); + bubble.setCornerRadii(radii); + + if (!hasShadow) { + return bubble; + } else { + final GradientDrawable shadow = new GradientDrawable(); + final int distance = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_shadow_distance); + + shadow.setColor(shadowColor); + shadow.setCornerRadii(radii); + + final LayerDrawable layers = new LayerDrawable(new Drawable[]{shadow, bubble}); + layers.setLayerInset(1, 0, 0, 0, distance); + return layers; + } + } + + private float[] cornerBooleansToRadii(boolean[] corners, int radius) { + if (corners == null || corners.length != 4) { + throw new AssertionError("there are four corners in a rectangle, silly"); + } + + float[] radii = new float[8]; + int i = 0; + for (boolean corner : corners) { + radii[i] = radii[i+1] = corner ? radius : 0; + i += 2; + } + + return radii; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java new file mode 100644 index 00000000..20bd089a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +import org.thoughtcrime.securesms.R; + +public class CircleColorImageView extends AppCompatImageView { + + public CircleColorImageView(Context context) { + this(context, null); + } + + public CircleColorImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircleColorImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + int circleColor = Color.WHITE; + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CircleColorImageView, 0, 0); + circleColor = typedArray.getColor(R.styleable.CircleColorImageView_circleColor, Color.WHITE); + typedArray.recycle(); + } + + Drawable circle = context.getResources().getDrawable(R.drawable.circle_tintable); + circle.setColorFilter(circleColor, PorterDuff.Mode.SRC_IN); + + setBackgroundDrawable(circle); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java new file mode 100644 index 00000000..70a0220e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -0,0 +1,405 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.os.Build; +import android.os.Bundle; +import android.text.Annotation; +import android.text.Editable; +import android.text.InputType; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.text.style.RelativeSizeSpan; +import android.util.AttributeSet; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.mention.MentionDeleter; +import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; +import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.List; + +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; + +public class ComposeText extends EmojiEditText { + + private CharSequence hint; + private SpannableString subHint; + private MentionRendererDelegate mentionRendererDelegate; + private MentionValidatorWatcher mentionValidatorWatcher; + + @Nullable private InputPanel.MediaListener mediaListener; + @Nullable private CursorPositionChangedListener cursorPositionChangedListener; + @Nullable private MentionQueryChangedListener mentionQueryChangedListener; + + public ComposeText(Context context) { + super(context); + initialize(); + } + + public ComposeText(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + /** + * Trims and returns text while preserving potential spans like {@link MentionAnnotation}. + */ + public @NonNull CharSequence getTextTrimmed() { + Editable text = getText(); + if (text == null) { + return ""; + } + return StringUtil.trimSequence(text); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!TextUtils.isEmpty(hint)) { + if (!TextUtils.isEmpty(subHint)) { + setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint)) + .append("\n") + .append(ellipsizeToWidth(subHint))); + } else { + setHint(ellipsizeToWidth(hint)); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + @Override + protected void onSelectionChanged(int selectionStart, int selectionEnd) { + super.onSelectionChanged(selectionStart, selectionEnd); + + if (getText() != null) { + boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd); + if (selectionChanged) { + return; + } + + if (selectionStart == selectionEnd) { + doAfterCursorChange(getText()); + } else { + updateQuery(null); + } + } + + if (cursorPositionChangedListener != null) { + cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (getText() != null && getLayout() != null) { + int checkpoint = canvas.save(); + + // Clip using same logic as TextView drawing + int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop(); + float clipLeft = getCompoundPaddingLeft() + getScrollX(); + float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY(); + float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX(); + float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom()); + + canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom); + canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); + + try { + mentionRendererDelegate.draw(canvas, getText(), getLayout()); + } finally { + canvas.restoreToCount(checkpoint); + } + } + super.onDraw(canvas); + } + + private CharSequence ellipsizeToWidth(CharSequence text) { + return TextUtils.ellipsize(text, + getPaint(), + getWidth() - getPaddingLeft() - getPaddingRight(), + TruncateAt.END); + } + + public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { + this.hint = hint; + + if (subHint != null) { + this.subHint = new SpannableString(subHint); + this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } else { + this.subHint = null; + } + + if (this.subHint != null) { + super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint)) + .append("\n") + .append(ellipsizeToWidth(this.subHint))); + } else { + super.setHint(ellipsizeToWidth(this.hint)); + } + + super.setHint(hint); + } + + public void appendInvite(String invite) { + if (getText() == null) { + return; + } + + if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) { + append(" "); + } + + append(invite); + setSelection(getText().length()); + } + + public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) { + this.cursorPositionChangedListener = listener; + } + + public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) { + this.mentionQueryChangedListener = listener; + } + + public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) { + mentionValidatorWatcher.setMentionValidator(mentionValidator); + } + + private boolean isLandscape() { + return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public void setTransport(TransportOption transport) { + final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext()); + + int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; + int inputType = getInputType(); + + if (isLandscape()) setImeActionLabel(transport.getComposeHint(), EditorInfo.IME_ACTION_SEND); + else setImeActionLabel(null, 0); + + if (useSystemEmoji) { + inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE; + } + + setImeOptions(imeOptions); + setHint(transport.getComposeHint(), + transport.getSimName().isPresent() + ? getContext().getString(R.string.conversation_activity__from_sim_name, transport.getSimName().get()) + : null); + setInputType(inputType); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo editorInfo) { + InputConnection inputConnection = super.onCreateInputConnection(editorInfo); + + if(TextSecurePreferences.isEnterSendsEnabled(getContext())) { + editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + } + + if (Build.VERSION.SDK_INT < 21) return inputConnection; + if (mediaListener == null) return inputConnection; + if (inputConnection == null) return null; + + EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"}); + return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); + } + + public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) { + this.mediaListener = mediaListener; + } + + public boolean hasMentions() { + Editable text = getText(); + if (text != null) { + return !MentionAnnotation.getMentionAnnotations(text).isEmpty(); + } + return false; + } + + public @NonNull List getMentions() { + return MentionAnnotation.getMentionsFromAnnotations(getText()); + } + + private void initialize() { + if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { + setImeOptions(getImeOptions() | 16777216); + } + + mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.conversation_mention_background_color)); + + addTextChangedListener(new MentionDeleter()); + mentionValidatorWatcher = new MentionValidatorWatcher(); + addTextChangedListener(mentionValidatorWatcher); + } + + private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) { + Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class); + for (Annotation annotation : annotations) { + if (MentionAnnotation.isMentionAnnotation(annotation)) { + int spanStart = spanned.getSpanStart(annotation); + int spanEnd = spanned.getSpanEnd(annotation); + + boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd; + boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd; + + if (startInMention || endInMention) { + if (selectionStart == selectionEnd) { + setSelection(spanEnd, spanEnd); + } else { + int newStart = startInMention ? spanStart : selectionStart; + int newEnd = endInMention ? spanEnd : selectionEnd; + setSelection(newStart, newEnd); + } + return true; + } + } + } + return false; + } + + private void doAfterCursorChange(@NonNull Editable text) { + if (enoughToFilter(text)) { + performFiltering(text); + } else { + updateQuery(null); + } + } + + private void performFiltering(@NonNull Editable text) { + int end = getSelectionEnd(); + int start = findQueryStart(text, end); + CharSequence query = text.subSequence(start, end); + updateQuery(query.toString()); + } + + private void updateQuery(@Nullable String query) { + if (mentionQueryChangedListener != null) { + mentionQueryChangedListener.onQueryChanged(query); + } + } + + private boolean enoughToFilter(@NonNull Editable text) { + int end = getSelectionEnd(); + if (end < 0) { + return false; + } + return findQueryStart(text, end) != -1; + } + + public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) { + Editable text = getText(); + if (text == null) { + return; + } + + clearComposingText(); + + int end = getSelectionEnd(); + int start = findQueryStart(text, end) - 1; + + text.replace(start, end, createReplacementToken(displayName, recipientId)); + } + + private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) { + SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER); + if (text instanceof Spanned) { + SpannableString spannableString = new SpannableString(text + " "); + TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0); + builder.append(spannableString); + } else { + builder.append(text).append(" "); + } + + builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return builder; + } + + private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) { + if (inputCursorPosition == 0) { + return -1; + } + + int delimiterSearchIndex = inputCursorPosition - 1; + while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) { + delimiterSearchIndex--; + } + + if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) { + return delimiterSearchIndex + 1; + } + return -1; + } + + private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener { + + private static final String TAG = CommitContentListener.class.getSimpleName(); + + private final InputPanel.MediaListener mediaListener; + + private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) { + this.mediaListener = mediaListener; + } + + @Override + public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { + if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + try { + inputContentInfo.requestPermission(); + } catch (Exception e) { + Log.w(TAG, e); + return false; + } + } + + if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { + mediaListener.onMediaSelected(inputContentInfo.getContentUri(), + inputContentInfo.getDescription().getMimeType(0)); + + return true; + } + + return false; + } + } + + public interface CursorPositionChangedListener { + void onCursorPositionChanged(int start, int end); + } + + public interface MentionQueryChangedListener { + void onQueryChanged(@Nullable String query); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java new file mode 100644 index 00000000..d68058ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.widget.TextViewCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar; + +public final class ContactFilterToolbar extends DarkOverflowToolbar { + private OnFilterChangedListener listener; + + private final EditText searchText; + private final AnimatingToggle toggle; + private final ImageView keyboardToggle; + private final ImageView dialpadToggle; + private final ImageView clearToggle; + private final LinearLayout toggleContainer; + + public ContactFilterToolbar(Context context) { + this(context, null); + } + + public ContactFilterToolbar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + } + + public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.contact_filter_toolbar, this); + + this.searchText = findViewById(R.id.search_view); + this.toggle = findViewById(R.id.button_toggle); + this.keyboardToggle = findViewById(R.id.search_keyboard); + this.dialpadToggle = findViewById(R.id.search_dialpad); + this.clearToggle = findViewById(R.id.search_clear); + this.toggleContainer = findViewById(R.id.toggle_container); + + this.keyboardToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME); + ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0); + displayTogglingView(dialpadToggle); + } + }); + + this.dialpadToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchText.setInputType(InputType.TYPE_CLASS_PHONE); + ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0); + displayTogglingView(keyboardToggle); + } + }); + + this.clearToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchText.setText(""); + + if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle); + else displayTogglingView(keyboardToggle); + } + }); + + this.searchText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (!SearchUtil.isEmpty(searchText)) displayTogglingView(clearToggle); + else if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle); + else if (SearchUtil.isPhoneInput(searchText)) displayTogglingView(keyboardToggle); + notifyListener(); + } + }); + + setLogo(null); + setContentInsetStartWithNavigation(0); + expandTapArea(toggleContainer, dialpadToggle); + applyAttributes(searchText, context, attrs, defStyleAttr); + searchText.requestFocus(); + } + + private void applyAttributes(@NonNull EditText searchText, + @NonNull Context context, + @NonNull AttributeSet attrs, + int defStyle) + { + final TypedArray attributes = context.obtainStyledAttributes(attrs, + R.styleable.ContactFilterToolbar, + defStyle, + 0); + + int styleResource = attributes.getResourceId(R.styleable.ContactFilterToolbar_searchTextStyle, -1); + if (styleResource != -1) { + TextViewCompat.setTextAppearance(searchText, styleResource); + } + if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) { + dialpadToggle.setVisibility(GONE); + } + attributes.recycle(); + } + + public void focusAndShowKeyboard() { + ViewUtil.focusAndShowKeyboard(searchText); + } + + public void clear() { + searchText.setText(""); + notifyListener(); + } + + public void setOnFilterChangedListener(OnFilterChangedListener listener) { + this.listener = listener; + } + + public void setHint(@StringRes int hint) { + searchText.setHint(hint); + } + + private void notifyListener() { + if (listener != null) listener.onFilterChanged(searchText.getText().toString()); + } + + private void displayTogglingView(View view) { + toggle.display(view); + expandTapArea(toggleContainer, view); + } + + private void expandTapArea(final View container, final View child) { + final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area); + + container.post(new Runnable() { + @Override + public void run() { + Rect rect = new Rect(); + child.getHitRect(rect); + + rect.top -= padding; + rect.left -= padding; + rect.right += padding; + rect.bottom += padding; + + container.setTouchDelegate(new TouchDelegate(rect, child)); + } + }); + } + + private static class SearchUtil { + static boolean isTextInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT; + } + + static boolean isPhoneInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE; + } + + public static boolean isEmpty(EditText editText) { + return editText.getText().length() <= 0; + } + } + + public interface OnFilterChangedListener { + void onFilterChanged(String filter); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java new file mode 100644 index 00000000..8c32a173 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import com.google.android.material.tabs.TabLayout; + +import java.util.List; + +/** + * An implementation of {@link TabLayout} that disables taps when the view is disabled. + */ +public class ControllableTabLayout extends TabLayout { + + private List touchables; + + public ControllableTabLayout(Context context) { + super(context); + } + + public ControllableTabLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ControllableTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setEnabled(boolean enabled) { + if (isEnabled() && !enabled) { + touchables = getTouchables(); + } + + for (View touchable : touchables) { + touchable.setClickable(enabled); + } + + super.setEnabled(enabled); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java new file mode 100644 index 00000000..a8abacb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.ViewPager; + +import org.thoughtcrime.securesms.components.viewpager.HackyViewPager; + +/** + * An implementation of {@link ViewPager} that disables swiping when the view is disabled. + */ +public class ControllableViewPager extends HackyViewPager { + + public ControllableViewPager(@NonNull Context context) { + super(context); + } + + public ControllableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + return isEnabled() && super.onTouchEvent(ev); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + return isEnabled() && super.onInterceptTouchEvent(ev); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java new file mode 100644 index 00000000..d5256b4f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -0,0 +1,309 @@ +package org.thoughtcrime.securesms.components; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; +import com.airbnb.lottie.LottieProperty; +import com.airbnb.lottie.model.KeyPath; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat; +import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +public class ConversationItemFooter extends LinearLayout { + + private TextView dateView; + private TextView simView; + private ExpirationTimerView timerView; + private ImageView insecureIndicatorView; + private DeliveryStatusView deliveryStatusView; + private boolean onlyShowSendingStatus; + private View audioSpace; + private TextView audioDuration; + private LottieAnimationView revealDot; + + public ConversationItemFooter(Context context) { + super(context); + init(null); + } + + public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.conversation_item_footer, this); + + dateView = findViewById(R.id.footer_date); + simView = findViewById(R.id.footer_sim_info); + timerView = findViewById(R.id.footer_expiration_timer); + insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); + deliveryStatusView = findViewById(R.id.footer_delivery_status); + audioDuration = findViewById(R.id.footer_audio_duration); + audioSpace = findViewById(R.id.footer_audio_duration_space); + revealDot = findViewById(R.id.footer_revealed_dot); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0); + setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white))); + setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white))); + setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white))); + typedArray.recycle(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + timerView.stopAnimation(); + } + + public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { + presentDate(messageRecord, locale); + presentSimInfo(messageRecord); + presentTimer(messageRecord); + presentInsecureIndicator(messageRecord); + presentDeliveryStatus(messageRecord); + hideAudioDurationViews(); + } + + public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) { + long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis); + audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60)); + } + + public void setTextColor(int color) { + dateView.setTextColor(color); + simView.setTextColor(color); + audioDuration.setTextColor(color); + } + + public void setIconColor(int color) { + timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN); + insecureIndicatorView.setColorFilter(color); + deliveryStatusView.setTint(color); + } + + public void setRevealDotColor(int color) { + revealDot.addValueCallback( + new KeyPath("**"), + LottieProperty.COLOR_FILTER, + frameInfo -> new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) + ); + } + + public void setOnlyShowSendingStatus(boolean onlyShowSending, MessageRecord messageRecord) { + this.onlyShowSendingStatus = onlyShowSending; + presentDeliveryStatus(messageRecord); + } + + public void enableBubbleBackground(@DrawableRes int drawableRes, @Nullable Integer tint) { + setBackgroundResource(drawableRes); + + if (tint != null) { + getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + } else { + getBackground().clearColorFilter(); + } + } + + public void disableBubbleBackground() { + setBackground(null); + } + + private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { + dateView.forceLayout(); + if (messageRecord.isFailed()) { + int errorMsg; + if (messageRecord.hasFailedWithNetworkFailures()) { + errorMsg = R.string.ConversationItem_error_network_not_delivered; + } else if (messageRecord.getRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) { + errorMsg = R.string.ConversationItem_error_partially_not_delivered; + } else { + errorMsg = R.string.ConversationItem_error_not_sent_tap_for_details; + } + + dateView.setText(errorMsg); + } else if (messageRecord.isPendingInsecureSmsFallback()) { + dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted); + } else { + dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); + } + } + + private void presentSimInfo(@NonNull MessageRecord messageRecord) { + SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(getContext()); + + if (messageRecord.isPush() || messageRecord.getSubscriptionId() == -1 || !Permissions.hasAll(getContext(), Manifest.permission.READ_PHONE_STATE) || !subscriptionManager.isMultiSim()) { + simView.setVisibility(View.GONE); + } else { + Optional subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(messageRecord.getSubscriptionId()); + + if (subscriptionInfo.isPresent() && messageRecord.isOutgoing()) { + simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName())); + simView.setVisibility(View.VISIBLE); + } else if (subscriptionInfo.isPresent()) { + simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName())); + simView.setVisibility(View.VISIBLE); + } else { + simView.setVisibility(View.GONE); + } + } + } + + @SuppressLint("StaticFieldLeak") + private void presentTimer(@NonNull final MessageRecord messageRecord) { + if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) { + this.timerView.setVisibility(View.VISIBLE); + this.timerView.setPercentComplete(0); + + if (messageRecord.getExpireStarted() > 0) { + this.timerView.setExpirationTime(messageRecord.getExpireStarted(), + messageRecord.getExpiresIn()); + this.timerView.startAnimation(); + + if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) { + ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule(); + } + } else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) { + SignalExecutors.BOUNDED.execute(() -> { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager(); + long id = messageRecord.getId(); + boolean mms = messageRecord.isMms(); + + if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id); + else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id); + + expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn()); + }); + } + } else { + this.timerView.setVisibility(View.GONE); + } + } + + private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) { + insecureIndicatorView.setVisibility(messageRecord.isSecure() ? View.GONE : View.VISIBLE); + } + + private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) { + if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) { + deliveryStatusView.setNone(); + return; + } + + if (onlyShowSendingStatus) { + if (messageRecord.isOutgoing() && messageRecord.isPending()) { + deliveryStatusView.setPending(); + } else { + deliveryStatusView.setNone(); + } + } else { + if (!messageRecord.isOutgoing()) { + deliveryStatusView.setNone(); + } else if (messageRecord.isPending()) { + deliveryStatusView.setPending(); + } else if (messageRecord.isRemoteRead()) { + deliveryStatusView.setRead(); + } else if (messageRecord.isDelivered()) { + deliveryStatusView.setDelivered(); + } else { + deliveryStatusView.setSent(); + } + } + } + + private void presentAudioDuration(@NonNull MessageRecord messageRecord) { + if (messageRecord.isMms()) { + MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord; + + if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) { + if (messageRecord.isOutgoing()) { + moveAudioViewsForOutgoing(); + } else { + moveAudioViewsForIncoming(); + } + showAudioDurationViews(); + } else { + hideAudioDurationViews(); + } + } else { + hideAudioDurationViews(); + } + } + + private void moveAudioViewsForOutgoing() { + removeView(audioSpace); + removeView(audioDuration); + removeView(revealDot); + addView(audioSpace, 0); + addView(revealDot, 0); + addView(audioDuration, 0); + + int padStart = ViewUtil.dpToPx(60); + int padLeft = ViewUtil.isLtr(this) ? padStart : 0; + int padRight = ViewUtil.isRtl(this) ? padStart : 0; + + audioDuration.setPadding(padLeft, 0, padRight, 0); + } + + private void moveAudioViewsForIncoming() { + removeView(audioSpace); + removeView(audioDuration); + removeView(revealDot); + addView(audioSpace); + addView(revealDot); + addView(audioDuration); + + audioDuration.setPadding(0, 0, 0, 0); + } + + private void showAudioDurationViews() { + audioSpace.setVisibility(View.VISIBLE); + audioDuration.setVisibility(View.VISIBLE); + + if (FeatureFlags.viewedReceipts()) { + revealDot.setVisibility(View.VISIBLE); + } + } + + private void hideAudioDurationViews() { + audioSpace.setVisibility(View.GONE); + audioDuration.setVisibility(View.GONE); + revealDot.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java new file mode 100644 index 00000000..41842104 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -0,0 +1,170 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; + +import java.util.List; + +public class ConversationItemThumbnail extends FrameLayout { + + private ThumbnailView thumbnail; + private AlbumThumbnailView album; + private ImageView shade; + private ConversationItemFooter footer; + private CornerMask cornerMask; + private Outliner outliner; + private Outliner pulseOutliner; + private boolean borderless; + + public ConversationItemThumbnail(Context context) { + super(context); + init(null); + } + + public ConversationItemThumbnail(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.conversation_item_thumbnail, this); + + this.thumbnail = findViewById(R.id.conversation_thumbnail_image); + this.album = findViewById(R.id.conversation_thumbnail_album); + this.shade = findViewById(R.id.conversation_thumbnail_shade); + this.footer = findViewById(R.id.conversation_thumbnail_footer); + this.cornerMask = new CornerMask(this); + this.outliner = new Outliner(); + + outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20)); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0); + thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0), + typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)); + typedArray.recycle(); + } + } + + @SuppressWarnings("SuspiciousNameCombination") + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + if (!borderless) { + cornerMask.mask(canvas); + + if (album.getVisibility() != VISIBLE) { + outliner.draw(canvas); + } + } + + if (pulseOutliner != null) { + pulseOutliner.draw(canvas); + } + } + + public void setPulseOutliner(@NonNull Outliner outliner) { + this.pulseOutliner = outliner; + } + + @Override + public void setFocusable(boolean focusable) { + thumbnail.setFocusable(focusable); + album.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + thumbnail.setClickable(clickable); + album.setClickable(clickable); + } + + @Override + public void setOnLongClickListener(@Nullable OnLongClickListener l) { + thumbnail.setOnLongClickListener(l); + album.setOnLongClickListener(l); + } + + public void showShade(boolean show) { + shade.setVisibility(show ? VISIBLE : GONE); + forceLayout(); + } + + public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { + cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); + outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft); + } + + public void setMinimumThumbnailWidth(int width) { + thumbnail.setMinimumThumbnailWidth(width); + } + + public void setBorderless(boolean borderless) { + this.borderless = borderless; + } + + public ConversationItemFooter getFooter() { + return footer; + } + + @UiThread + public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull List slides, + boolean showControls, boolean isPreview) + { + if (slides.size() == 1) { + thumbnail.setVisibility(VISIBLE); + album.setVisibility(GONE); + + Attachment attachment = slides.get(0).asAttachment(); + thumbnail.setImageResource(glideRequests, slides.get(0), showControls, isPreview, attachment.getWidth(), attachment.getHeight()); + setTouchDelegate(thumbnail.getTouchDelegate()); + } else { + thumbnail.setVisibility(GONE); + album.setVisibility(VISIBLE); + + album.setSlides(glideRequests, slides, showControls); + setTouchDelegate(album.getTouchDelegate()); + } + } + + public void setConversationColor(@ColorInt int color) { + if (album.getVisibility() == VISIBLE) { + album.setCellBackgroundColor(color); + } + } + + public void setThumbnailClickListener(SlideClickListener listener) { + thumbnail.setThumbnailClickListener(listener); + album.setThumbnailClickListener(listener); + } + + public void setDownloadClickListener(SlidesClickedListener listener) { + thumbnail.setDownloadClickListener(listener); + album.setDownloadClickListener(listener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java new file mode 100644 index 00000000..590a24df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public final class ConversationScrollToView extends FrameLayout { + + private final TextView unreadCount; + private final ImageView scrollButton; + + public ConversationScrollToView(@NonNull Context context) { + this(context, null); + } + + public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.conversation_scroll_to, this); + + unreadCount = findViewById(R.id.conversation_scroll_to_count); + scrollButton = findViewById(R.id.conversation_scroll_to_button); + + if (attrs != null) { + TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ConversationScrollToView); + int srcId = array.getResourceId(R.styleable.ConversationScrollToView_cstv_scroll_button_src, 0); + + scrollButton.setImageResource(srcId); + + array.recycle(); + } + } + + @Override + public void setOnClickListener(@Nullable OnClickListener l) { + scrollButton.setOnClickListener(l); + } + + public void setUnreadCount(int unreadCount) { + this.unreadCount.setText(formatUnreadCount(unreadCount)); + this.unreadCount.setVisibility(unreadCount > 0 ? VISIBLE : GONE); + } + + private @NonNull CharSequence formatUnreadCount(int unreadCount) { + return unreadCount > 999 ? "999+" : String.valueOf(unreadCount); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java new file mode 100644 index 00000000..843dcd93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; + +/** + * Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity} + * when the user is searching within a conversation. Shows details about the results and allows the + * user to move between them. + */ +public class ConversationSearchBottomBar extends ConstraintLayout { + + private View searchDown; + private View searchUp; + private TextView searchPositionText; + private View progressWheel; + + private EventListener eventListener; + + + public ConversationSearchBottomBar(Context context) { + super(context); + } + + public ConversationSearchBottomBar(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + this.searchUp = findViewById(R.id.conversation_search_up); + this.searchDown = findViewById(R.id.conversation_search_down); + this.searchPositionText = findViewById(R.id.conversation_search_position); + this.progressWheel = findViewById(R.id.conversation_search_progress_wheel); + } + + public void setData(int position, int count) { + progressWheel.setVisibility(GONE); + + searchUp.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onSearchMoveUpPressed(); + } + }); + + searchDown.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onSearchMoveDownPressed(); + } + }); + + if (count > 0) { + searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count)); + } else { + searchPositionText.setText(R.string.ConversationActivity_no_results); + } + + setViewEnabled(searchUp, position < (count - 1)); + setViewEnabled(searchDown, position > 0); + } + + public void showLoading() { + progressWheel.setVisibility(VISIBLE); + } + + private void setViewEnabled(@NonNull View view, boolean enabled) { + view.setEnabled(enabled); + view.setAlpha(enabled ? 1f : 0.25f); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + } + + public interface EventListener { + void onSearchMoveUpPressed(); + void onSearchMoveDownPressed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java new file mode 100644 index 00000000..8eb881ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +public class ConversationTypingView extends LinearLayout { + + private AvatarImageView avatar; + private View bubble; + private TypingIndicatorView indicator; + + public ConversationTypingView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + avatar = findViewById(R.id.typing_avatar); + bubble = findViewById(R.id.typing_bubble); + indicator = findViewById(R.id.typing_indicator); + } + + public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List typists, boolean isGroupThread) { + if (typists.isEmpty()) { + indicator.stopAnimation(); + return; + } + + Recipient typist = typists.get(0); + bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY); + + if (isGroupThread) { + avatar.setAvatar(glideRequests, typist, true); + avatar.setVisibility(VISIBLE); + } else { + avatar.setVisibility(GONE); + } + + indicator.startAnimation(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java new file mode 100644 index 00000000..2d27539a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.view.View; + +import androidx.annotation.NonNull; + +public class CornerMask { + + private final float[] radii = new float[8]; + private final Paint clearPaint = new Paint(); + private final Path outline = new Path(); + private final Path corners = new Path(); + private final RectF bounds = new RectF(); + + public CornerMask(@NonNull View view) { + view.setLayerType(View.LAYER_TYPE_HARDWARE, null); + + clearPaint.setColor(Color.BLACK); + clearPaint.setStyle(Paint.Style.FILL); + clearPaint.setAntiAlias(true); + clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } + + public void mask(Canvas canvas) { + bounds.left = 0; + bounds.top = 0; + bounds.right = canvas.getWidth(); + bounds.bottom = canvas.getHeight(); + + corners.reset(); + corners.addRoundRect(bounds, radii, Path.Direction.CW); + + // Note: There's a bug in the P beta where most PorterDuff modes aren't working. But CLEAR does. + // So we find and inverse path and use Mode.CLEAR. + // See issue https://issuetracker.google.com/issues/111394085. + outline.reset(); + outline.addRect(bounds, Path.Direction.CW); + outline.op(corners, Path.Op.DIFFERENCE); + canvas.drawPath(outline, clearPaint); + } + + public void setRadius(int radius) { + setRadii(radius, radius, radius, radius); + } + + public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) { + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + } + + public void setTopLeftRadius(int radius) { + radii[0] = radii[1] = radius; + } + + public void setTopRightRadius(int radius) { + radii[2] = radii[3] = radius; + } + + public void setBottomRightRadius(int radius) { + radii[4] = radii[5] = radius; + } + + public void setBottomLeftRadius(int radius) { + radii[6] = radii[7] = radius; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java b/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java new file mode 100644 index 00000000..fc056e44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java @@ -0,0 +1,260 @@ +package org.thoughtcrime.securesms.components; + +import android.app.Dialog; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.View; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.DialogPreference; +import androidx.preference.PreferenceDialogFragmentCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.CustomPreferenceValidator; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.net.URI; +import java.net.URISyntaxException; + + +public class CustomDefaultPreference extends DialogPreference { + + private static final String TAG = CustomDefaultPreference.class.getSimpleName(); + + private final int inputType; + private final String customPreference; + private final String customToggle; + + private CustomPreferenceValidator validator; + private String defaultValue; + + public CustomDefaultPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + int[] attributeNames = new int[]{android.R.attr.inputType, R.attr.custom_pref_toggle}; + TypedArray attributes = context.obtainStyledAttributes(attrs, attributeNames); + + this.inputType = attributes.getInt(0, 0); + this.customPreference = getKey(); + this.customToggle = attributes.getString(1); + this.validator = new CustomDefaultPreferenceDialogFragmentCompat.NullValidator(); + + attributes.recycle(); + + setPersistent(false); + setDialogLayoutResource(R.layout.custom_default_preference_dialog); + } + + public CustomDefaultPreference setValidator(CustomPreferenceValidator validator) { + this.validator = validator; + return this; + } + + public CustomDefaultPreference setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + this.setSummary(getSummary()); + return this; + } + + @Override + public String getSummary() { + if (isCustom()) { + return getContext().getString(R.string.CustomDefaultPreference_using_custom, + getPrettyPrintValue(getCustomValue())); + } else { + return getContext().getString(R.string.CustomDefaultPreference_using_default, + getPrettyPrintValue(getDefaultValue())); + } + } + + private String getPrettyPrintValue(String value) { + if (TextUtils.isEmpty(value)) return getContext().getString(R.string.CustomDefaultPreference_none); + else return value; + } + + private boolean isCustom() { + return TextSecurePreferences.getBooleanPreference(getContext(), customToggle, false); + } + + private void setCustom(boolean custom) { + TextSecurePreferences.setBooleanPreference(getContext(), customToggle, custom); + } + + private String getCustomValue() { + return TextSecurePreferences.getStringPreference(getContext(), customPreference, ""); + } + + private void setCustomValue(String value) { + TextSecurePreferences.setStringPreference(getContext(), customPreference, value); + } + + private String getDefaultValue() { + return defaultValue; + } + + + public static class CustomDefaultPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat { + + private static final String INPUT_TYPE = "input_type"; + + private Spinner spinner; + private EditText customText; + private TextView defaultLabel; + + public static CustomDefaultPreferenceDialogFragmentCompat newInstance(String key) { + CustomDefaultPreferenceDialogFragmentCompat fragment = new CustomDefaultPreferenceDialogFragmentCompat(); + Bundle b = new Bundle(1); + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + @Override + protected void onBindDialogView(@NonNull View view) { + Log.i(TAG, "onBindDialogView"); + super.onBindDialogView(view); + + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + this.spinner = (Spinner) view.findViewById(R.id.default_or_custom); + this.defaultLabel = (TextView) view.findViewById(R.id.default_label); + this.customText = (EditText) view.findViewById(R.id.custom_edit); + + this.customText.setInputType(preference.inputType); + this.customText.addTextChangedListener(new TextValidator()); + this.customText.setText(preference.getCustomValue()); + this.spinner.setOnItemSelectedListener(new SelectionLister()); + this.defaultLabel.setText(preference.getPrettyPrintValue(preference.defaultValue)); + } + + + @Override + public @NonNull Dialog onCreateDialog(Bundle instanceState) { + Dialog dialog = super.onCreateDialog(instanceState); + + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (preference.isCustom()) spinner.setSelection(1, true); + else spinner.setSelection(0, true); + + return dialog; + } + + @Override + public void onDialogClosed(boolean positiveResult) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (positiveResult) { + if (spinner != null) preference.setCustom(spinner.getSelectedItemPosition() == 1); + if (customText != null) preference.setCustomValue(customText.getText().toString()); + + preference.setSummary(preference.getSummary()); + } + } + + interface CustomPreferenceValidator { + public boolean isValid(String value); + } + + private static class NullValidator implements CustomPreferenceValidator { + @Override + public boolean isValid(String value) { + return true; + } + } + + private class TextValidator implements TextWatcher { + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + + if (spinner.getSelectedItemPosition() == 1) { + Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setEnabled(preference.validator.isValid(s.toString())); + } + } + } + + public static class UriValidator implements CustomPreferenceValidator { + @Override + public boolean isValid(String value) { + if (TextUtils.isEmpty(value)) return true; + + try { + new URI(value); + return true; + } catch (URISyntaxException mue) { + return false; + } + } + } + + public static class HostnameValidator implements CustomPreferenceValidator { + @Override + public boolean isValid(String value) { + if (TextUtils.isEmpty(value)) return true; + + try { + URI uri = new URI(null, value, null, null); + return true; + } catch (URISyntaxException mue) { + return false; + } + } + } + + public static class PortValidator implements CustomPreferenceValidator { + @Override + public boolean isValid(String value) { + try { + Integer.parseInt(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + } + + private class SelectionLister implements AdapterView.OnItemSelectedListener { + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); + Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); + + defaultLabel.setVisibility(position == 0 ? View.VISIBLE : View.GONE); + customText.setVisibility(position == 0 ? View.GONE : View.VISIBLE); + positiveButton.setEnabled(position == 0 || preference.validator.isValid(customText.getText().toString())); + } + + @Override + public void onNothingSelected(AdapterView parent) { + defaultLabel.setVisibility(View.VISIBLE); + customText.setVisibility(View.GONE); + } + } + + } + + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java new file mode 100644 index 00000000..fda02a7b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +/** + * Custom styled search view that we can insert into ActionBar menus + */ +public class DarkSearchView extends androidx.appcompat.widget.SearchView { + public DarkSearchView(@NonNull Context context) { + this(context, null); + } + + public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.search_view_style_dark); + } + + public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + + EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text); + searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java new file mode 100644 index 00000000..076791fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.LinearInterpolator; +import android.view.animation.RotateAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; + +public class DeliveryStatusView extends FrameLayout { + + private static final String TAG = DeliveryStatusView.class.getSimpleName(); + + private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + static { + ROTATION_ANIMATION.setInterpolator(new LinearInterpolator()); + ROTATION_ANIMATION.setDuration(1500); + ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE); + } + + private final ImageView pendingIndicator; + private final ImageView sentIndicator; + private final ImageView deliveredIndicator; + private final ImageView readIndicator; + + public DeliveryStatusView(Context context) { + this(context, null); + } + + public DeliveryStatusView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + inflate(context, R.layout.delivery_status_view, this); + + this.deliveredIndicator = findViewById(R.id.delivered_indicator); + this.sentIndicator = findViewById(R.id.sent_indicator); + this.pendingIndicator = findViewById(R.id.pending_indicator); + this.readIndicator = findViewById(R.id.read_indicator); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0); + setTint(typedArray.getColor(R.styleable.DeliveryStatusView_iconColor, getResources().getColor(R.color.core_white))); + typedArray.recycle(); + } + } + + public void setNone() { + this.setVisibility(View.GONE); + } + + public void setPending() { + this.setVisibility(View.VISIBLE); + pendingIndicator.setVisibility(View.VISIBLE); + pendingIndicator.startAnimation(ROTATION_ANIMATION); + sentIndicator.setVisibility(View.GONE); + deliveredIndicator.setVisibility(View.GONE); + readIndicator.setVisibility(View.GONE); + } + + public void setSent() { + this.setVisibility(View.VISIBLE); + pendingIndicator.setVisibility(View.GONE); + pendingIndicator.clearAnimation(); + sentIndicator.setVisibility(View.VISIBLE); + deliveredIndicator.setVisibility(View.GONE); + readIndicator.setVisibility(View.GONE); + } + + public void setDelivered() { + this.setVisibility(View.VISIBLE); + pendingIndicator.setVisibility(View.GONE); + pendingIndicator.clearAnimation(); + sentIndicator.setVisibility(View.GONE); + deliveredIndicator.setVisibility(View.VISIBLE); + readIndicator.setVisibility(View.GONE); + } + + public void setRead() { + this.setVisibility(View.VISIBLE); + pendingIndicator.setVisibility(View.GONE); + pendingIndicator.clearAnimation(); + sentIndicator.setVisibility(View.GONE); + deliveredIndicator.setVisibility(View.GONE); + readIndicator.setVisibility(View.VISIBLE); + } + + public void setTint(int color) { + pendingIndicator.setColorFilter(color); + deliveredIndicator.setColorFilter(color); + sentIndicator.setColorFilter(color); + readIndicator.setColorFilter(color); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java new file mode 100644 index 00000000..8f5e806b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.Util; + +public class DocumentView extends FrameLayout { + + private static final String TAG = DocumentView.class.getSimpleName(); + + private final @NonNull AnimatingToggle controlToggle; + private final @NonNull ImageView downloadButton; + private final @NonNull ProgressWheel downloadProgress; + private final @NonNull View container; + private final @NonNull ViewGroup iconContainer; + private final @NonNull TextView fileName; + private final @NonNull TextView fileSize; + private final @NonNull TextView document; + + private @Nullable SlideClickListener downloadListener; + private @Nullable SlideClickListener viewListener; + private @Nullable Slide documentSlide; + + public DocumentView(@NonNull Context context) { + this(context, null); + } + + public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.document_view, this); + + this.container = findViewById(R.id.document_container); + this.iconContainer = findViewById(R.id.icon_container); + this.controlToggle = findViewById(R.id.control_toggle); + this.downloadButton = findViewById(R.id.download); + this.downloadProgress = findViewById(R.id.download_progress); + this.fileName = findViewById(R.id.file_name); + this.fileSize = findViewById(R.id.file_size); + this.document = findViewById(R.id.document); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DocumentView, 0, 0); + int titleColor = typedArray.getInt(R.styleable.DocumentView_doc_titleColor, Color.BLACK); + int captionColor = typedArray.getInt(R.styleable.DocumentView_doc_captionColor, Color.BLACK); + int downloadTint = typedArray.getInt(R.styleable.DocumentView_doc_downloadButtonTint, Color.WHITE); + typedArray.recycle(); + + fileName.setTextColor(titleColor); + fileSize.setTextColor(captionColor); + downloadButton.setColorFilter(downloadTint, PorterDuff.Mode.MULTIPLY); + downloadProgress.setBarColor(downloadTint); + } + } + + public void setDownloadClickListener(@Nullable SlideClickListener listener) { + this.downloadListener = listener; + } + + public void setDocumentClickListener(@Nullable SlideClickListener listener) { + this.viewListener = listener; + } + + public void setDocument(final @NonNull Slide documentSlide, + final boolean showControls) + { + if (showControls && documentSlide.isPendingDownload()) { + controlToggle.displayQuick(downloadButton); + downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide)); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } else if (showControls && documentSlide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + controlToggle.displayQuick(downloadProgress); + downloadProgress.spin(); + } else { + controlToggle.displayQuick(iconContainer); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } + + this.documentSlide = documentSlide; + + this.fileName.setText(documentSlide.getFileName() + .or(documentSlide.getCaption()) + .or(getContext().getString(R.string.DocumentView_unnamed_file))); + this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize())); + this.document.setText(documentSlide.getFileType(getContext()).or("").toLowerCase()); + this.setOnClickListener(new OpenClickedListener(documentSlide)); + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + this.downloadButton.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + this.downloadButton.setClickable(clickable); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + this.downloadButton.setEnabled(enabled); + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventAsync(final PartProgressEvent event) { + if (documentSlide != null && event.attachment.equals(documentSlide.asAttachment())) { + downloadProgress.setInstantProgress(((float) event.progress) / event.total); + } + } + + private class DownloadClickedListener implements View.OnClickListener { + private final @NonNull Slide slide; + + private DownloadClickedListener(@NonNull Slide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (downloadListener != null) downloadListener.onClick(v, slide); + } + } + + private class OpenClickedListener implements View.OnClickListener { + private final @NonNull Slide slide; + + private OpenClickedListener(@NonNull Slide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) { + viewListener.onClick(v, slide); + } + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java new file mode 100644 index 00000000..d52b8916 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java @@ -0,0 +1,123 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +import java.lang.ref.WeakReference; +import java.util.concurrent.TimeUnit; + +public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView { + + private long startedAt; + private long expiresIn; + + private boolean visible = false; + private boolean stopped = true; + + private final int[] frames = new int[]{ R.drawable.ic_timer_00_12, + R.drawable.ic_timer_05_12, + R.drawable.ic_timer_10_12, + R.drawable.ic_timer_15_12, + R.drawable.ic_timer_20_12, + R.drawable.ic_timer_25_12, + R.drawable.ic_timer_30_12, + R.drawable.ic_timer_35_12, + R.drawable.ic_timer_40_12, + R.drawable.ic_timer_45_12, + R.drawable.ic_timer_50_12, + R.drawable.ic_timer_55_12, + R.drawable.ic_timer_60_12 }; + + public ExpirationTimerView(Context context) { + super(context); + } + + public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setExpirationTime(long startedAt, long expiresIn) { + this.startedAt = startedAt; + this.expiresIn = expiresIn; + setPercentComplete(calculateProgress(this.startedAt, this.expiresIn)); + } + + public void setPercentComplete(float percentage) { + float percentFull = 1 - percentage; + int frame = (int) Math.ceil(percentFull * (frames.length - 1)); + + frame = Math.max(0, Math.min(frame, frames.length - 1)); + setImageResource(frames[frame]); + } + + public void startAnimation() { + synchronized (this) { + visible = true; + if (!stopped) return; + else stopped = false; + } + + Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn)); + } + + public void stopAnimation() { + synchronized (this) { + visible = false; + } + } + + private float calculateProgress(long startedAt, long expiresIn) { + long progressed = System.currentTimeMillis() - startedAt; + float percentComplete = (float)progressed / (float)expiresIn; + + return Math.max(0, Math.min(percentComplete, 1)); + } + + private long calculateAnimationDelay(long startedAt, long expiresIn) { + long progressed = System.currentTimeMillis() - startedAt; + long remaining = expiresIn - progressed; + + if (remaining < TimeUnit.SECONDS.toMillis(30)) { + return 50; + } else { + return 1000; + } + } + + private static class AnimationUpdateRunnable implements Runnable { + + private final WeakReference expirationTimerViewReference; + + private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) { + this.expirationTimerViewReference = new WeakReference<>(expirationTimerView); + } + + @Override + public void run() { + ExpirationTimerView timerView = expirationTimerViewReference.get(); + if (timerView == null) return; + + timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn); + + synchronized (timerView) { + if (!timerView.visible) { + timerView.stopped = true; + return; + } + } + + Util.runOnMainDelayed(this, timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn)); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java new file mode 100644 index 00000000..df6b50db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.style.StyleSpan; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class FromTextView extends EmojiTextView { + + private static final String TAG = FromTextView.class.getSimpleName(); + + public FromTextView(Context context) { + super(context); + } + + public FromTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setText(Recipient recipient) { + setText(recipient, true); + } + + public void setText(Recipient recipient, boolean read) { + setText(recipient, read, null); + } + + public void setText(Recipient recipient, boolean read, @Nullable String suffix) { + String fromString = recipient.getDisplayName(getContext()); + + int typeface; + + if (!read) { + typeface = Typeface.BOLD; + } else { + typeface = Typeface.NORMAL; + } + + SpannableStringBuilder builder = new SpannableStringBuilder(); + + SpannableString fromSpan = new SpannableString(fromString); + fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + + + if (recipient.isSelf()) { + builder.append(getContext().getString(R.string.note_to_self)); + } else { + builder.append(fromSpan); + } + + if (suffix != null) { + builder.append(suffix); + } + + setText(builder); + + if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0); + else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0); + else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java new file mode 100644 index 00000000..c0dd74c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; + +/** + * Base dialog fragment for rendering as a full screen dialog with animation + * transitions. + */ +public abstract class FullScreenDialogFragment extends DialogFragment { + + protected Toolbar toolbar; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen); + } + + @Override + public final @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false); + inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true); + toolbar = view.findViewById(R.id.full_screen_dialog_toolbar); + toolbar.setTitle(getTitle()); + toolbar.setNavigationOnClickListener(v -> onNavigateUp()); + return view; + } + + protected void onNavigateUp() { + dismissAllowingStateLoss(); + } + + protected abstract @StringRes int getTitle(); + + protected abstract @LayoutRes int getDialogLayoutResource(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java new file mode 100644 index 00000000..f68c3ea7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.request.target.BitmapImageViewTarget; + +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; + +public class GlideBitmapListeningTarget extends BitmapImageViewTarget { + + private final SettableFuture loaded; + + public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture loaded) { + super(view); + this.loaded = loaded; + } + + @Override + protected void setResource(@Nullable Bitmap resource) { + super.setResource(resource); + loaded.set(true); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + super.onLoadFailed(errorDrawable); + loaded.set(true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java new file mode 100644 index 00000000..571908b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.drawable.Drawable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.request.target.DrawableImageViewTarget; + +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; + +public class GlideDrawableListeningTarget extends DrawableImageViewTarget { + + private final SettableFuture loaded; + + public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture loaded) { + super(view); + this.loaded = loaded; + } + + @Override + protected void setResource(@Nullable Drawable resource) { + super.setResource(resource); + loaded.set(true); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + super.onLoadFailed(errorDrawable); + loaded.set(true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java new file mode 100644 index 00000000..92d42e89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.widget.LinearLayout; + +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +public class HidingLinearLayout extends LinearLayout { + + public HidingLinearLayout(Context context) { + super(context); + } + + public HidingLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void hide() { + if (!isEnabled() || getVisibility() == GONE) return; + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); + animation.addAnimation(new AlphaAnimation(1, 0)); + animation.setDuration(100); + + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + setVisibility(GONE); + } + }); + + animateWith(animation); + } + + public void show() { + if (!isEnabled() || getVisibility() == VISIBLE) return; + + setVisibility(VISIBLE); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new ScaleAnimation(0.5f, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); + animation.addAnimation(new AlphaAnimation(0, 1)); + animation.setDuration(100); + + animateWith(animation); + } + + private void animateWith(Animation animation) { + animation.setDuration(150); + animation.setInterpolator(new FastOutSlowInInterpolator()); + startAnimation(animation); + } + + public void disable() { + setVisibility(GONE); + setEnabled(false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java b/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java new file mode 100644 index 00000000..d5f58766 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.PorterDuffXfermode; +import android.util.AttributeSet; +import android.view.View; + +import org.thoughtcrime.securesms.R; + +public class HourglassView extends View { + + private final Paint foregroundPaint; + private final Paint backgroundPaint; + private final Paint progressPaint; + + private Bitmap empty; + private Bitmap full; + + private float percentage; + private int offset; + + public HourglassView(Context context) { + this(context, null); + } + + public HourglassView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public HourglassView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + int tint = 0; + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.HourglassView, 0, 0); + this.empty = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_empty, 0)); + this.full = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_full, 0)); + this.percentage = typedArray.getInt(R.styleable.HourglassView_percentage, 50); + this.offset = typedArray.getInt(R.styleable.HourglassView_offset, 0); + tint = typedArray.getColor(R.styleable.HourglassView_tint, 0); + typedArray.recycle(); + } + + this.backgroundPaint = new Paint(); + this.foregroundPaint = new Paint(); + this.progressPaint = new Paint(); + + this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY)); + this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY)); + + this.progressPaint.setColor(getResources().getColor(R.color.black)); + this.progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + + setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } + + @Override + public void onDraw(Canvas canvas) { + float progressHeight = (full.getHeight() - (offset*2)) * (percentage / 100); + + canvas.drawBitmap(full, 0, 0, backgroundPaint); + canvas.drawRect(0, 0, full.getWidth(), offset + progressHeight, progressPaint); + canvas.drawBitmap(empty, 0, 0, foregroundPaint); + } + + public void setPercentage(float percentage) { + this.percentage = percentage; + invalidate(); + } + + public void setTint(int tint) { + this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY)); + this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java new file mode 100644 index 00000000..142ff5cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; +import org.thoughtcrime.securesms.util.ServiceUtil; + +public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener { + private InputView current; + + public InputAwareLayout(Context context) { + this(context, null); + } + + public InputAwareLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InputAwareLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + addOnKeyboardShownListener(this); + } + + @Override public void onKeyboardShown() { + hideAttachedInput(true); + } + + public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) { + if (isKeyboardOpen()) { + hideSoftkey(imeTarget, new Runnable() { + @Override public void run() { + hideAttachedInput(true); + input.show(getKeyboardHeight(), true); + current = input; + } + }); + } else { + if (current != null) current.hide(true); + input.show(getKeyboardHeight(), current != null); + current = input; + } + } + + public InputView getCurrentInput() { + return current; + } + + public void hideCurrentInput(EditText imeTarget) { + if (isKeyboardOpen()) hideSoftkey(imeTarget, null); + else hideAttachedInput(false); + } + + public void hideAttachedInput(boolean instant) { + if (current != null) current.hide(instant); + current = null; + } + + public boolean isInputOpen() { + return (isKeyboardOpen() || (current != null && current.isShowing())); + } + + public void showSoftkey(final EditText inputTarget) { + postOnKeyboardOpen(new Runnable() { + @Override public void run() { + hideAttachedInput(true); + } + }); + inputTarget.post(new Runnable() { + @Override public void run() { + inputTarget.requestFocus(); + ServiceUtil.getInputMethodManager(inputTarget.getContext()).showSoftInput(inputTarget, 0); + } + }); + } + + public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) { + if (runAfterClose != null) postOnKeyboardClose(runAfterClose); + + ServiceUtil.getInputMethodManager(inputTarget.getContext()) + .hideSoftInputFromWindow(inputTarget.getWindowToken(), 0); + } + + public interface InputView { + void show(int height, boolean immediate); + void hide(boolean immediate); + boolean isShowing(); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java new file mode 100644 index 00000000..b937a98b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -0,0 +1,559 @@ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.net.Uri; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.Interpolator; +import android.view.animation.TranslateAnimation; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DimenRes; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; +import org.thoughtcrime.securesms.components.emoji.EmojiToggle; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class InputPanel extends LinearLayout + implements MicrophoneRecorderView.Listener, + KeyboardAwareLinearLayout.OnKeyboardShownListener, + EmojiKeyboardProvider.EmojiEventListener, + ConversationStickerSuggestionAdapter.EventListener +{ + + private static final String TAG = InputPanel.class.getSimpleName(); + + private static final long QUOTE_REVEAL_DURATION_MILLIS = 150; + private static final int FADE_TIME = 150; + + private RecyclerView stickerSuggestion; + private QuoteView quoteView; + private LinkPreviewView linkPreview; + private EmojiToggle mediaKeyboard; + private ComposeText composeText; + private View quickCameraToggle; + private View quickAudioToggle; + private View buttonToggle; + private View recordingContainer; + private View recordLockCancel; + private View composeContainer; + + private MicrophoneRecorderView microphoneRecorderView; + private SlideToCancel slideToCancel; + private RecordTime recordTime; + private ValueAnimator quoteAnimator; + + private @Nullable Listener listener; + private boolean emojiVisible; + + private ConversationStickerSuggestionAdapter stickerSuggestionAdapter; + + public InputPanel(Context context) { + super(context); + } + + public InputPanel(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + View quoteDismiss = findViewById(R.id.quote_dismiss); + + this.composeContainer = findViewById(R.id.compose_bubble); + this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion); + this.quoteView = findViewById(R.id.quote_view); + this.linkPreview = findViewById(R.id.link_preview); + this.mediaKeyboard = findViewById(R.id.emoji_toggle); + this.composeText = findViewById(R.id.embedded_text_editor); + this.quickCameraToggle = findViewById(R.id.quick_camera_toggle); + this.quickAudioToggle = findViewById(R.id.quick_audio_toggle); + this.buttonToggle = findViewById(R.id.button_toggle); + this.recordingContainer = findViewById(R.id.recording_container); + this.recordLockCancel = findViewById(R.id.record_cancel); + this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel)); + this.microphoneRecorderView = findViewById(R.id.recorder_view); + this.microphoneRecorderView.setListener(this); + this.recordTime = new RecordTime(findViewById(R.id.record_time), + findViewById(R.id.microphone), + TimeUnit.HOURS.toSeconds(1), + () -> microphoneRecorderView.cancelAction()); + + this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction()); + + if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) { + mediaKeyboard.setVisibility(View.GONE); + emojiVisible = false; + } else { + mediaKeyboard.setVisibility(View.VISIBLE); + emojiVisible = true; + } + + quoteDismiss.setOnClickListener(v -> clearQuote()); + + linkPreview.setCloseClickedListener(() -> { + if (listener != null) { + listener.onLinkPreviewCanceled(); + } + }); + + stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(GlideApp.with(this), this); + + stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false)); + stickerSuggestion.setAdapter(stickerSuggestionAdapter); + } + + public void setListener(final @NonNull Listener listener) { + this.listener = listener; + + mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle()); + } + + public void setMediaListener(@NonNull MediaListener listener) { + composeText.setMediaListener(listener); + } + + public void setQuote(@NonNull GlideRequests glideRequests, + long id, + @NonNull Recipient author, + @NonNull CharSequence body, + @NonNull SlideDeck attachments) + { + this.quoteView.setQuote(glideRequests, id, author, body, false, attachments); + + int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight() + : 0; + + this.quoteView.setVisibility(VISIBLE); + this.quoteView.measure(0, 0); + + if (quoteAnimator != null) { + quoteAnimator.cancel(); + } + + quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null); + + quoteAnimator.start(); + + if (this.linkPreview.getVisibility() == View.VISIBLE) { + int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius); + this.linkPreview.setCorners(cornerRadius, cornerRadius); + } + } + + public void clearQuote() { + if (quoteAnimator != null) { + quoteAnimator.cancel(); + } + + quoteAnimator = createHeightAnimator(quoteView, quoteView.getMeasuredHeight(), 0, new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + quoteView.dismiss(); + + if (linkPreview.getVisibility() == View.VISIBLE) { + int cornerRadius = readDimen(R.dimen.message_corner_radius); + linkPreview.setCorners(cornerRadius, cornerRadius); + } + } + }); + + quoteAnimator.start(); + } + + private static ValueAnimator createHeightAnimator(@NonNull View view, + int originalHeight, + int finalHeight, + @Nullable AnimationCompleteListener onAnimationComplete) + { + ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight) + .setDuration(QUOTE_REVEAL_DURATION_MILLIS); + + animator.addUpdateListener(animation -> { + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.height = (int) animation.getAnimatedValue(); + view.setLayoutParams(params); + }); + + if (onAnimationComplete != null) { + animator.addListener(onAnimationComplete); + } + + return animator; + } + + public Optional getQuote() { + if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) { + return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions())); + } else { + return Optional.absent(); + } + } + + public void setLinkPreviewLoading() { + this.linkPreview.setVisibility(View.VISIBLE); + this.linkPreview.setLoading(); + } + + public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) { + this.linkPreview.setVisibility(View.VISIBLE); + this.linkPreview.setNoPreview(customError); + } + + public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional preview) { + if (preview.isPresent()) { + this.linkPreview.setVisibility(View.VISIBLE); + this.linkPreview.setLinkPreview(glideRequests, preview.get(), true); + } else { + this.linkPreview.setVisibility(View.GONE); + } + + int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius) + : readDimen(R.dimen.message_corner_radius); + + this.linkPreview.setCorners(cornerRadius, cornerRadius); + } + + public void clickOnComposeInput() { + composeText.performClick(); + } + + public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) { + this.mediaKeyboard.attach(mediaKeyboard); + } + + public void setStickerSuggestions(@NonNull List stickers) { + stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE); + stickerSuggestionAdapter.setStickers(stickers); + } + + public void showMediaKeyboardToggle(boolean show) { + emojiVisible = show; + mediaKeyboard.setVisibility(show ? View.VISIBLE : GONE); + } + + public void setMediaKeyboardToggleMode(boolean isSticker) { + mediaKeyboard.setStickerMode(isSticker); + } + + public boolean isStickerMode() { + return mediaKeyboard.isStickerMode(); + } + + public View getMediaKeyboardToggleAnchorView() { + return mediaKeyboard; + } + + public void setWallpaperEnabled(boolean enabled) { + if (enabled) { + setBackgroundColor(getContext().getResources().getColor(R.color.wallpaper_compose_background)); + composeContainer.setBackgroundResource(R.drawable.compose_background_wallpaper); + } else { + setBackgroundColor(getResources().getColor(R.color.signal_background_primary)); + composeContainer.setBackgroundResource(R.drawable.compose_background); + } + } + + @Override + public void onRecordPermissionRequired() { + if (listener != null) listener.onRecorderPermissionRequired(); + } + + @Override + public void onRecordPressed() { + if (listener != null) listener.onRecorderStarted(); + recordTime.display(); + slideToCancel.display(); + + if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE); + ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE); + buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start(); + } + + @Override + public void onRecordReleased() { + long elapsedTime = onRecordHideEvent(); + + if (listener != null) { + Log.d(TAG, "Elapsed time: " + elapsedTime); + if (elapsedTime > 1000) { + listener.onRecorderFinished(); + } else { + Toast.makeText(getContext(), R.string.InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send, Toast.LENGTH_LONG).show(); + listener.onRecorderCanceled(); + } + } + } + + @Override + public void onRecordMoved(float offsetX, float absoluteX) { + slideToCancel.moveTo(offsetX); + + float position = absoluteX / recordingContainer.getWidth(); + + if (ViewUtil.isLtr(this) && position <= 0.5 || + ViewUtil.isRtl(this) && position >= 0.6) + { + this.microphoneRecorderView.cancelAction(); + } + } + + @Override + public void onRecordCanceled() { + onRecordHideEvent(); + if (listener != null) listener.onRecorderCanceled(); + } + + @Override + public void onRecordLocked() { + slideToCancel.hide(); + recordLockCancel.setVisibility(View.VISIBLE); + buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + if (listener != null) listener.onRecorderLocked(); + } + + public void onPause() { + this.microphoneRecorderView.cancelAction(); + } + + public void setEnabled(boolean enabled) { + composeText.setEnabled(enabled); + mediaKeyboard.setEnabled(enabled); + quickAudioToggle.setEnabled(enabled); + quickCameraToggle.setEnabled(enabled); + } + + private long onRecordHideEvent() { + recordLockCancel.setVisibility(View.GONE); + + ListenableFuture future = slideToCancel.hide(); + long elapsedTime = recordTime.hide(); + + future.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Void result) { + if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME); + ViewUtil.fadeIn(composeText, FADE_TIME); + ViewUtil.fadeIn(quickCameraToggle, FADE_TIME); + ViewUtil.fadeIn(quickAudioToggle, FADE_TIME); + buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + } + }); + + return elapsedTime; + } + + @Override + public void onKeyboardShown() { + mediaKeyboard.setToMedia(); + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + composeText.dispatchKeyEvent(keyEvent); + } + + @Override + public void onEmojiSelected(String emoji) { + composeText.insertEmoji(emoji); + } + + @Override + public void onStickerSuggestionClicked(@NonNull StickerRecord sticker) { + if (listener != null) { + listener.onStickerSuggestionSelected(sticker); + } + } + + private int readDimen(@DimenRes int dimenRes) { + return getResources().getDimensionPixelSize(dimenRes); + } + + public boolean isRecordingInLockedMode() { + return microphoneRecorderView.isRecordingLocked(); + } + + public void releaseRecordingLock() { + microphoneRecorderView.unlockAction(); + } + + public interface Listener { + void onRecorderStarted(); + void onRecorderLocked(); + void onRecorderFinished(); + void onRecorderCanceled(); + void onRecorderPermissionRequired(); + void onEmojiToggle(); + void onLinkPreviewCanceled(); + void onStickerSuggestionSelected(@NonNull StickerRecord sticker); + } + + private static class SlideToCancel { + + private final View slideToCancelView; + + SlideToCancel(View slideToCancelView) { + this.slideToCancelView = slideToCancelView; + } + + public void display() { + ViewUtil.fadeIn(this.slideToCancelView, FADE_TIME); + } + + public ListenableFuture hide() { + final SettableFuture future = new SettableFuture<>(); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, slideToCancelView.getTranslationX(), + Animation.ABSOLUTE, 0, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0)); + animation.addAnimation(new AlphaAnimation(1, 0)); + + animation.setDuration(MicrophoneRecorderView.ANIMATION_DURATION); + animation.setFillBefore(true); + animation.setFillAfter(false); + + slideToCancelView.postDelayed(() -> future.set(null), MicrophoneRecorderView.ANIMATION_DURATION); + slideToCancelView.setVisibility(View.GONE); + slideToCancelView.startAnimation(animation); + + return future; + } + + void moveTo(float offset) { + Animation animation = new TranslateAnimation(Animation.ABSOLUTE, offset, + Animation.ABSOLUTE, offset, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0); + + animation.setDuration(0); + animation.setFillAfter(true); + animation.setFillBefore(true); + + slideToCancelView.startAnimation(animation); + } + } + + private static class RecordTime implements Runnable { + + private final @NonNull TextView recordTimeView; + private final @NonNull View microphone; + private final @NonNull Runnable onLimitHit; + private final long limitSeconds; + private long startTime; + + private RecordTime(@NonNull TextView recordTimeView, @NonNull View microphone, long limitSeconds, @NonNull Runnable onLimitHit) { + this.recordTimeView = recordTimeView; + this.microphone = microphone; + this.limitSeconds = limitSeconds; + this.onLimitHit = onLimitHit; + } + + @MainThread + public void display() { + this.startTime = System.currentTimeMillis(); + this.recordTimeView.setText(DateUtils.formatElapsedTime(0)); + ViewUtil.fadeIn(this.recordTimeView, FADE_TIME); + Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1)); + microphone.setVisibility(View.VISIBLE); + microphone.startAnimation(pulseAnimation()); + } + + @MainThread + public long hide() { + long elapsedTime = System.currentTimeMillis() - startTime; + this.startTime = 0; + ViewUtil.fadeOut(this.recordTimeView, FADE_TIME, View.INVISIBLE); + microphone.clearAnimation(); + ViewUtil.fadeOut(this.microphone, FADE_TIME, View.INVISIBLE); + return elapsedTime; + } + + @Override + @MainThread + public void run() { + long localStartTime = startTime; + if (localStartTime > 0) { + long elapsedTime = System.currentTimeMillis() - localStartTime; + long elapsedSeconds = TimeUnit.MILLISECONDS.toSeconds(elapsedTime); + if (elapsedSeconds >= limitSeconds) { + onLimitHit.run(); + } else { + recordTimeView.setText(DateUtils.formatElapsedTime(elapsedSeconds)); + Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1)); + } + } + } + + private static Animation pulseAnimation() { + AlphaAnimation animation = new AlphaAnimation(0, 1); + + animation.setInterpolator(pulseInterpolator()); + animation.setRepeatCount(Animation.INFINITE); + animation.setDuration(1000); + + return animation; + } + + private static Interpolator pulseInterpolator() { + return input -> { + input *= 5; + if (input > 1) { + input = 4 - input; + } + return Math.max(0, Math.min(1, input)); + }; + } + } + + public interface MediaListener { + void onMediaSelected(@NonNull Uri uri, String contentType); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java new file mode 100644 index 00000000..7153e869 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.Guideline; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class InsetAwareConstraintLayout extends ConstraintLayout { + + public InsetAwareConstraintLayout(@NonNull Context context) { + super(context); + } + + public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected boolean fitSystemWindows(Rect insets) { + Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline); + Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline); + Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline); + Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline); + + if (statusBarGuideline != null) { + statusBarGuideline.setGuidelineBegin(insets.top); + } + + if (navigationBarGuideline != null) { + navigationBarGuideline.setGuidelineEnd(insets.bottom); + } + + if (parentStartGuideline != null) { + if (ViewUtil.isLtr(this)) { + parentStartGuideline.setGuidelineBegin(insets.left); + } else { + parentStartGuideline.setGuidelineBegin(insets.right); + } + } + + if (parentEndGuideline != null) { + if (ViewUtil.isLtr(this)) { + parentEndGuideline.setGuidelineEnd(insets.right); + } else { + parentEndGuideline.setGuidelineEnd(insets.left); + } + } + + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java new file mode 100644 index 00000000..fb492ca1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java @@ -0,0 +1,273 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.view.Surface; +import android.view.View; + +import androidx.appcompat.widget.LinearLayoutCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; + +/** + * LinearLayout that, when a view container, will report back when it thinks a soft keyboard + * has been opened and what its height would be. + */ +public class KeyboardAwareLinearLayout extends LinearLayoutCompat { + private static final String TAG = KeyboardAwareLinearLayout.class.getSimpleName(); + + private final Rect rect = new Rect(); + private final Set hiddenListeners = new HashSet<>(); + private final Set shownListeners = new HashSet<>(); + private final int minKeyboardSize; + private final int minCustomKeyboardSize; + private final int defaultCustomKeyboardSize; + private final int minCustomKeyboardTopMarginPortrait; + private final int minCustomKeyboardTopMarginLandscape; + private final int statusBarHeight; + + private int viewInset; + + private boolean keyboardOpen = false; + private int rotation = -1; + private boolean isFullscreen = false; + + public KeyboardAwareLinearLayout(Context context) { + this(context, null); + } + + public KeyboardAwareLinearLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public KeyboardAwareLinearLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + minKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_keyboard_size); + minCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_size); + defaultCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.default_custom_keyboard_size); + minCustomKeyboardTopMarginPortrait = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); + minCustomKeyboardTopMarginLandscape = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); + statusBarHeight = ViewUtil.getStatusBarHeight(this); + viewInset = getViewInset(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + updateRotation(); + updateKeyboardState(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private void updateRotation() { + int oldRotation = rotation; + rotation = getDeviceRotation(); + if (oldRotation != rotation) { + Log.i(TAG, "rotation changed"); + onKeyboardClose(); + } + } + + private void updateKeyboardState() { + if (viewInset == 0 && Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) viewInset = getViewInset(); + + getWindowVisibleDisplayFrame(rect); + + final int availableHeight = getAvailableHeight(); + final int keyboardHeight = availableHeight - rect.bottom; + + if (keyboardHeight > minKeyboardSize) { + if (getKeyboardHeight() != keyboardHeight) { + if (isLandscape()) { + setKeyboardLandscapeHeight(keyboardHeight); + } else { + setKeyboardPortraitHeight(keyboardHeight); + } + } + if (!keyboardOpen) { + onKeyboardOpen(keyboardHeight); + } + } else if (keyboardOpen) { + onKeyboardClose(); + } + } + + @TargetApi(VERSION_CODES.LOLLIPOP) + private int getViewInset() { + try { + Field attachInfoField = View.class.getDeclaredField("mAttachInfo"); + attachInfoField.setAccessible(true); + Object attachInfo = attachInfoField.get(this); + if (attachInfo != null) { + Field stableInsetsField = attachInfo.getClass().getDeclaredField("mStableInsets"); + stableInsetsField.setAccessible(true); + Rect insets = (Rect)stableInsetsField.get(attachInfo); + if (insets != null) { + return insets.bottom; + } + } + } catch (NoSuchFieldException | IllegalAccessException e) { + // Do nothing + } + return statusBarHeight; + } + + private int getAvailableHeight() { + final int availableHeight = this.getRootView().getHeight() - viewInset; + final int availableWidth = this.getRootView().getWidth(); + + if (isLandscape() && availableHeight > availableWidth) { + //noinspection SuspiciousNameCombination + return availableWidth; + } + + return availableHeight; + } + + protected void onKeyboardOpen(int keyboardHeight) { + Log.i(TAG, "onKeyboardOpen(" + keyboardHeight + ")"); + keyboardOpen = true; + + notifyShownListeners(); + } + + protected void onKeyboardClose() { + Log.i(TAG, "onKeyboardClose()"); + keyboardOpen = false; + notifyHiddenListeners(); + } + + public boolean isKeyboardOpen() { + return keyboardOpen; + } + + public int getKeyboardHeight() { + return isLandscape() ? getKeyboardLandscapeHeight() : getKeyboardPortraitHeight(); + } + + public boolean isLandscape() { + int rotation = getDeviceRotation(); + return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270; + } + private int getDeviceRotation() { + return ServiceUtil.getWindowManager(getContext()).getDefaultDisplay().getRotation(); + } + + private int getKeyboardLandscapeHeight() { + int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) + .getInt("keyboard_height_landscape", defaultCustomKeyboardSize); + return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginLandscape); + } + + private int getKeyboardPortraitHeight() { + int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) + .getInt("keyboard_height_portrait", defaultCustomKeyboardSize); + return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginPortrait); + } + + private void setKeyboardPortraitHeight(int height) { + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit().putInt("keyboard_height_portrait", height).apply(); + } + + private void setKeyboardLandscapeHeight(int height) { + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit().putInt("keyboard_height_landscape", height).apply(); + } + + public void postOnKeyboardClose(final Runnable runnable) { + if (keyboardOpen) { + addOnKeyboardHiddenListener(new OnKeyboardHiddenListener() { + @Override public void onKeyboardHidden() { + removeOnKeyboardHiddenListener(this); + runnable.run(); + } + }); + } else { + runnable.run(); + } + } + + public void postOnKeyboardOpen(final Runnable runnable) { + if (!keyboardOpen) { + addOnKeyboardShownListener(new OnKeyboardShownListener() { + @Override public void onKeyboardShown() { + removeOnKeyboardShownListener(this); + runnable.run(); + } + }); + } else { + runnable.run(); + } + } + + public void addOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { + hiddenListeners.add(listener); + } + + public void removeOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { + hiddenListeners.remove(listener); + } + + public void addOnKeyboardShownListener(OnKeyboardShownListener listener) { + shownListeners.add(listener); + } + + public void removeOnKeyboardShownListener(OnKeyboardShownListener listener) { + shownListeners.remove(listener); + } + + public void setFullscreen(boolean isFullscreen) { + this.isFullscreen = isFullscreen; + } + + private void notifyHiddenListeners() { + final Set listeners = new HashSet<>(hiddenListeners); + for (OnKeyboardHiddenListener listener : listeners) { + listener.onKeyboardHidden(); + } + } + + private void notifyShownListeners() { + final Set listeners = new HashSet<>(shownListeners); + for (OnKeyboardShownListener listener : listeners) { + listener.onKeyboardShown(); + } + } + + public interface OnKeyboardHiddenListener { + void onKeyboardHidden(); + } + + public interface OnKeyboardShownListener { + void onKeyboardShown(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java new file mode 100644 index 00000000..e599d4bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.text.Editable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class LabeledEditText extends FrameLayout implements View.OnFocusChangeListener { + + private TextView label; + private EditText input; + private View border; + private ViewGroup textContainer; + + public LabeledEditText(@NonNull Context context) { + super(context); + init(null); + } + + public LabeledEditText(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.labeled_edit_text, this); + + String labelText = ""; + int backgroundColor = Color.BLACK; + int textLayout = R.layout.labeled_edit_text_default; + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LabeledEditText, 0, 0); + + labelText = typedArray.getString(R.styleable.LabeledEditText_labeledEditText_label); + backgroundColor = typedArray.getColor(R.styleable.LabeledEditText_labeledEditText_background, Color.BLACK); + textLayout = typedArray.getResourceId(R.styleable.LabeledEditText_labeledEditText_textLayout, R.layout.labeled_edit_text_default); + + typedArray.recycle(); + } + + label = findViewById(R.id.label); + border = findViewById(R.id.border); + textContainer = findViewById(R.id.text_container); + + inflate(getContext(), textLayout, textContainer); + input = findViewById(R.id.input); + + label.setText(labelText); + label.setBackgroundColor(backgroundColor); + + if (TextUtils.isEmpty(labelText)) { + label.setVisibility(INVISIBLE); + } + + input.setOnFocusChangeListener(this); + } + + public EditText getInput() { + return input; + } + + public void setText(String text) { + input.setText(text); + } + + public Editable getText() { + return input.getText(); + } + + @Override + public void onFocusChange(View v, boolean hasFocus) { + border.setBackgroundResource(hasFocus ? R.drawable.labeled_edit_text_background_active + : R.drawable.labeled_edit_text_background_inactive); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + input.setEnabled(enabled); + } + + public void focusAndMoveCursorToEndAndOpenKeyboard() { + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(input); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java new file mode 100644 index 00000000..61b5f568 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java @@ -0,0 +1,212 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.Util; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import okhttp3.HttpUrl; + +/** + * The view shown in the compose box or conversation that represents the state of the link preview. + */ +public class LinkPreviewView extends FrameLayout { + + private static final int TYPE_CONVERSATION = 0; + private static final int TYPE_COMPOSE = 1; + + private ViewGroup container; + private OutlinedThumbnailView thumbnail; + private TextView title; + private TextView description; + private TextView site; + private View divider; + private View closeButton; + private View spinner; + private TextView noPreview; + + private int type; + private int defaultRadius; + private CornerMask cornerMask; + private Outliner outliner; + private CloseClickedListener closeClickedListener; + + public LinkPreviewView(Context context) { + super(context); + init(null); + } + + public LinkPreviewView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.link_preview, this); + + container = findViewById(R.id.linkpreview_container); + thumbnail = findViewById(R.id.linkpreview_thumbnail); + title = findViewById(R.id.linkpreview_title); + description = findViewById(R.id.linkpreview_description); + site = findViewById(R.id.linkpreview_site); + divider = findViewById(R.id.linkpreview_divider); + spinner = findViewById(R.id.linkpreview_progress_wheel); + closeButton = findViewById(R.id.linkpreview_close); + noPreview = findViewById(R.id.linkpreview_no_preview); + defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius); + cornerMask = new CornerMask(this); + outliner = new Outliner(); + + outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20)); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0); + type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0); + typedArray.recycle(); + } + + if (type == TYPE_COMPOSE) { + container.setBackgroundColor(Color.TRANSPARENT); + container.setPadding(0, 0, 0, 0); + divider.setVisibility(VISIBLE); + closeButton.setVisibility(VISIBLE); + title.setMaxLines(2); + description.setMaxLines(2); + + closeButton.setOnClickListener(v -> { + if (closeClickedListener != null) { + closeClickedListener.onCloseClicked(); + } + }); + } + + setWillNotDraw(false); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + if (type == TYPE_COMPOSE) return; + + cornerMask.mask(canvas); + outliner.draw(canvas); + } + + public void setLoading() { + title.setVisibility(GONE); + site.setVisibility(GONE); + description.setVisibility(GONE); + thumbnail.setVisibility(GONE); + spinner.setVisibility(VISIBLE); + noPreview.setVisibility(INVISIBLE); + } + + public void setNoPreview(@Nullable LinkPreviewRepository.Error customError) { + title.setVisibility(GONE); + site.setVisibility(GONE); + thumbnail.setVisibility(GONE); + spinner.setVisibility(GONE); + noPreview.setVisibility(VISIBLE); + noPreview.setText(getLinkPreviewErrorString(customError)); + } + + public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) { + spinner.setVisibility(GONE); + noPreview.setVisibility(GONE); + + if (!Util.isEmpty(linkPreview.getTitle())) { + title.setText(linkPreview.getTitle()); + title.setVisibility(VISIBLE); + } else { + title.setVisibility(GONE); + } + + if (!Util.isEmpty(linkPreview.getDescription())) { + description.setText(linkPreview.getDescription()); + description.setVisibility(VISIBLE); + } else { + description.setVisibility(GONE); + } + + String domain = null; + + if (!Util.isEmpty(linkPreview.getUrl())) { + HttpUrl url = HttpUrl.parse(linkPreview.getUrl()); + if (url != null) { + domain = url.topPrivateDomain(); + } + } + + if (domain != null && linkPreview.getDate() > 0) { + site.setText(getContext().getString(R.string.LinkPreviewView_domain_date, domain, formatDate(linkPreview.getDate()))); + site.setVisibility(VISIBLE); + } else if (domain != null) { + site.setText(domain); + site.setVisibility(VISIBLE); + } else if (linkPreview.getDate() > 0) { + site.setText(formatDate(linkPreview.getDate())); + site.setVisibility(VISIBLE); + } else { + site.setVisibility(GONE); + } + + if (showThumbnail && linkPreview.getThumbnail().isPresent()) { + thumbnail.setVisibility(VISIBLE); + thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false); + thumbnail.showDownloadText(false); + } else { + thumbnail.setVisibility(GONE); + } + } + + public void setCorners(int topLeft, int topRight) { + cornerMask.setRadii(topLeft, topRight, 0, 0); + outliner.setRadii(topLeft, topRight, 0, 0); + thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius); + postInvalidate(); + } + + public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) { + this.closeClickedListener = closeClickedListener; + } + + public void setDownloadClickedListener(SlidesClickedListener listener) { + thumbnail.setDownloadClickListener(listener); + } + + private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) { + return customError == LinkPreviewRepository.Error.GROUP_LINK_INACTIVE ? R.string.LinkPreviewView_this_group_link_is_not_active + : R.string.LinkPreviewView_no_link_preview_available; + } + + private static String formatDate(long date) { + DateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()); + return dateFormat.format(date); + } + + public interface CloseClickedListener { + void onCloseClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ListenableHorizontalScrollView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ListenableHorizontalScrollView.java new file mode 100644 index 00000000..ce78f9cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ListenableHorizontalScrollView.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.HorizontalScrollView; + +import androidx.annotation.Nullable; + +/** + * Unfortunately {@link HorizontalScrollView#setOnScrollChangeListener(OnScrollChangeListener)} + * wasn't added until API 23, so now we have to do this ourselves. + */ +public class ListenableHorizontalScrollView extends HorizontalScrollView { + + private OnScrollListener listener; + + public ListenableHorizontalScrollView(Context context) { + super(context); + } + + public ListenableHorizontalScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setOnScrollListener(@Nullable OnScrollListener listener) { + this.listener = listener; + } + + @Override + protected void onScrollChanged(int newLeft, int newTop, int oldLeft, int oldTop) { + if (listener != null) { + listener.onScroll(newLeft, oldLeft); + } + super.onScrollChanged(newLeft, newTop, oldLeft, oldTop); + } + + public interface OnScrollListener { + void onScroll(int newLeft, int oldLeft); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java new file mode 100644 index 00000000..1da8dfdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class MaskView extends View { + + private View target; + private ViewGroup activityContentView; + private Paint maskPaint; + private Rect drawingRect = new Rect(); + private float targetParentTranslationY; + + private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate; + + public MaskView(@NonNull Context context) { + super(context); + } + + public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + setLayerType(LAYER_TYPE_HARDWARE, maskPaint); + + maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + activityContentView = getRootView().findViewById(android.R.id.content); + } + + public void setTarget(@Nullable View target) { + if (this.target != null) { + this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener); + } + + this.target = target; + + if (this.target != null) { + this.target.getViewTreeObserver().addOnDrawListener(onDrawListener); + } + + invalidate(); + } + + public void setTargetParentTranslationY(float targetParentTranslationY) { + this.targetParentTranslationY = targetParentTranslationY; + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + if (target == null || !target.isAttachedToWindow()) { + return; + } + + target.getDrawingRect(drawingRect); + activityContentView.offsetDescendantRectToMyCoords(target, drawingRect); + + drawingRect.top += targetParentTranslationY; + drawingRect.bottom += targetParentTranslationY; + + Bitmap mask = Bitmap.createBitmap(target.getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888); + Canvas maskCanvas = new Canvas(mask); + + target.draw(maskCanvas); + + canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom())); + + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) target.getLayoutParams(); + canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint); + + mask.recycle(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java new file mode 100644 index 00000000..b9135e69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class MaxHeightFrameLayout extends FrameLayout { + + private final int maxHeight; + + public MaxHeightFrameLayout(@NonNull Context context) { + this(context, null); + } + + public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout); + + maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0); + + a.recycle(); + } else { + maxHeight = 0; + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java new file mode 100644 index 00000000..d02650e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.ScrollView; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class MaxHeightScrollView extends ScrollView { + + private int maxHeight = -1; + + public MaxHeightScrollView(Context context) { + super(context); + initialize(null); + } + + public MaxHeightScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView, 0, 0); + + maxHeight = typedArray.getDimensionPixelOffset(R.styleable.MaxHeightScrollView_scrollView_maxHeight, -1); + + typedArray.recycle(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (maxHeight >= 0) { + heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java new file mode 100644 index 00000000..320304bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java @@ -0,0 +1,271 @@ +package org.thoughtcrime.securesms.components; + +import android.Manifest; +import android.content.Context; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.AnticipateOvershootInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.LinearInterpolator; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ViewUtil; + +public final class MicrophoneRecorderView extends FrameLayout implements View.OnTouchListener { + + enum State { + NOT_RUNNING, + RUNNING_HELD, + RUNNING_LOCKED + } + + public static final int ANIMATION_DURATION = 200; + + private FloatingRecordButton floatingRecordButton; + private LockDropTarget lockDropTarget; + private @Nullable Listener listener; + private @NonNull State state = State.NOT_RUNNING; + + public MicrophoneRecorderView(Context context) { + super(context); + } + + public MicrophoneRecorderView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + floatingRecordButton = new FloatingRecordButton(getContext(), findViewById(R.id.quick_audio_fab)); + lockDropTarget = new LockDropTarget (getContext(), findViewById(R.id.lock_drop_target)); + + View recordButton = findViewById(R.id.quick_audio_toggle); + recordButton.setOnTouchListener(this); + } + + public void cancelAction() { + if (state != State.NOT_RUNNING) { + state = State.NOT_RUNNING; + hideUi(); + + if (listener != null) listener.onRecordCanceled(); + } + } + + public boolean isRecordingLocked() { + return state == State.RUNNING_LOCKED; + } + + private void lockAction() { + if (state == State.RUNNING_HELD) { + state = State.RUNNING_LOCKED; + hideUi(); + + if (listener != null) listener.onRecordLocked(); + } + } + + public void unlockAction() { + if (state == State.RUNNING_LOCKED) { + state = State.NOT_RUNNING; + hideUi(); + + if (listener != null) listener.onRecordReleased(); + } + } + + private void hideUi() { + floatingRecordButton.hide(); + lockDropTarget.hide(); + } + + @Override + public boolean onTouch(View v, final MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) { + if (listener != null) listener.onRecordPermissionRequired(); + } else { + state = State.RUNNING_HELD; + floatingRecordButton.display(event.getX(), event.getY()); + lockDropTarget.display(); + if (listener != null) listener.onRecordPressed(); + } + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (this.state == State.RUNNING_HELD) { + state = State.NOT_RUNNING; + hideUi(); + if (listener != null) listener.onRecordReleased(); + } + break; + case MotionEvent.ACTION_MOVE: + if (this.state == State.RUNNING_HELD) { + this.floatingRecordButton.moveTo(event.getX(), event.getY()); + if (listener != null) listener.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX()); + + int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target); + if (floatingRecordButton.lastOffsetY <= dimensionPixelSize) { + lockAction(); + } + } + break; + } + + return false; + } + + public void setListener(@Nullable Listener listener) { + this.listener = listener; + } + + public interface Listener { + void onRecordPressed(); + void onRecordReleased(); + void onRecordCanceled(); + void onRecordLocked(); + void onRecordMoved(float offsetX, float absoluteX); + void onRecordPermissionRequired(); + } + + private static class FloatingRecordButton { + + private final ImageView recordButtonFab; + + private float startPositionX; + private float startPositionY; + private float lastOffsetX; + private float lastOffsetY; + + FloatingRecordButton(Context context, ImageView recordButtonFab) { + this.recordButtonFab = recordButtonFab; + this.recordButtonFab.getBackground().setColorFilter(context.getResources() + .getColor(R.color.red_500), + PorterDuff.Mode.SRC_IN); + } + + void display(float x, float y) { + this.startPositionX = x; + this.startPositionY = y; + + recordButtonFab.setVisibility(View.VISIBLE); + + AnimationSet animation = new AnimationSet(true); + animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, 0)); + + animation.addAnimation(new ScaleAnimation(.5f, 1f, .5f, 1f, + Animation.RELATIVE_TO_SELF, .5f, + Animation.RELATIVE_TO_SELF, .5f)); + + animation.setDuration(ANIMATION_DURATION); + animation.setInterpolator(new OvershootInterpolator()); + + recordButtonFab.startAnimation(animation); + } + + void moveTo(float x, float y) { + lastOffsetX = getXOffset(x); + lastOffsetY = getYOffset(y); + + if (Math.abs(lastOffsetX) > Math.abs(lastOffsetY)) { + lastOffsetY = 0; + } else { + lastOffsetX = 0; + } + + recordButtonFab.setTranslationX(lastOffsetX); + recordButtonFab.setTranslationY(lastOffsetY); + } + + void hide() { + recordButtonFab.setTranslationX(0); + recordButtonFab.setTranslationY(0); + if (recordButtonFab.getVisibility() != VISIBLE) return; + + AnimationSet animation = new AnimationSet(false); + Animation scaleAnimation = new ScaleAnimation(1, 0.5f, 1, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + + Animation translateAnimation = new TranslateAnimation(Animation.ABSOLUTE, lastOffsetX, + Animation.ABSOLUTE, 0, + Animation.ABSOLUTE, lastOffsetY, + Animation.ABSOLUTE, 0); + + scaleAnimation.setInterpolator(new AnticipateOvershootInterpolator(1.5f)); + translateAnimation.setInterpolator(new DecelerateInterpolator()); + animation.addAnimation(scaleAnimation); + animation.addAnimation(translateAnimation); + animation.setDuration(ANIMATION_DURATION); + animation.setInterpolator(new AnticipateOvershootInterpolator(1.5f)); + + recordButtonFab.setVisibility(View.GONE); + recordButtonFab.clearAnimation(); + recordButtonFab.startAnimation(animation); + } + + private float getXOffset(float x) { + return ViewUtil.isLtr(recordButtonFab) ? -Math.max(0, this.startPositionX - x) + : Math.max(0, x - this.startPositionX); + } + + private float getYOffset(float y) { + return Math.min(0, y - this.startPositionY); + } + } + + private static class LockDropTarget { + + private final View lockDropTarget; + private final int dropTargetPosition; + + LockDropTarget(Context context, View lockDropTarget) { + this.lockDropTarget = lockDropTarget; + this.dropTargetPosition = context.getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target); + } + + void display() { + lockDropTarget.setScaleX(1); + lockDropTarget.setScaleY(1); + lockDropTarget.setAlpha(0); + lockDropTarget.setTranslationY(0); + lockDropTarget.setVisibility(VISIBLE); + lockDropTarget.animate() + .setStartDelay(ANIMATION_DURATION * 2) + .setDuration(ANIMATION_DURATION) + .setInterpolator(new DecelerateInterpolator()) + .translationY(dropTargetPosition) + .alpha(1) + .start(); + } + + void hide() { + lockDropTarget.animate() + .setStartDelay(0) + .setDuration(ANIMATION_DURATION) + .setInterpolator(new LinearInterpolator()) + .scaleX(0).scaleY(0) + .start(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java new file mode 100644 index 00000000..407222ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +public class OutlinedThumbnailView extends ThumbnailView { + + private CornerMask cornerMask; + private Outliner outliner; + + public OutlinedThumbnailView(Context context) { + super(context); + init(null); + } + + public OutlinedThumbnailView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + cornerMask = new CornerMask(this); + outliner = new Outliner(); + + outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20)); + + int radius = 0; + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.OutlinedThumbnailView, 0, 0); + radius = typedArray.getDimensionPixelOffset(R.styleable.OutlinedThumbnailView_otv_cornerRadius, 0); + } + + setRadius(radius); + setCorners(radius, radius, radius, radius); + + setWillNotDraw(false); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + cornerMask.mask(canvas); + outliner.draw(canvas); + } + + public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { + cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); + outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft); + postInvalidate(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/Outliner.java b/app/src/main/java/org/thoughtcrime/securesms/components/Outliner.java new file mode 100644 index 00000000..a88e64d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/Outliner.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; + +import androidx.annotation.ColorInt; + +import org.thoughtcrime.securesms.util.ViewUtil; + +public class Outliner { + + private final float[] radii = new float[8]; + private final Path corners = new Path(); + private final RectF bounds = new RectF(); + private final Paint outlinePaint = new Paint(); + { + outlinePaint.setStyle(Paint.Style.STROKE); + outlinePaint.setStrokeWidth(ViewUtil.dpToPx(1)); + outlinePaint.setAntiAlias(true); + } + + public void setColor(@ColorInt int color) { + outlinePaint.setColor(color); + } + + public void setStrokeWidth(float pixels) { + outlinePaint.setStrokeWidth(pixels); + } + + public void setAlpha(int alpha) { + outlinePaint.setAlpha(alpha); + } + + public void draw(Canvas canvas) { + draw(canvas, 0, canvas.getWidth(), canvas.getHeight(), 0); + } + + public void draw(Canvas canvas, int top, int right, int bottom, int left) { + final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2; + + bounds.left = left + halfStrokeWidth; + bounds.top = top + halfStrokeWidth; + bounds.right = right - halfStrokeWidth; + bounds.bottom = bottom - halfStrokeWidth; + + corners.reset(); + corners.addRoundRect(bounds, radii, Path.Direction.CW); + + canvas.drawPath(corners, outlinePaint); + } + + public void setRadius(int radius) { + setRadii(radius, radius, radius, radius); + } + + public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) { + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java new file mode 100644 index 00000000..194d00c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java @@ -0,0 +1,173 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.RecipientsAdapter; +import org.thoughtcrime.securesms.contacts.RecipientsEditor; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; + +import java.util.LinkedList; +import java.util.List; +import java.util.StringTokenizer; + +/** + * Panel component combining both an editable field with a button for + * a list-based contact selector. + * + * @author Moxie Marlinspike + */ +public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver { + private final String TAG = PushRecipientsPanel.class.getSimpleName(); + private RecipientsPanelChangedListener panelChangeListener; + + private RecipientsEditor recipientsText; + private View panel; + + private static final int RECIPIENTS_MAX_LENGTH = 312; + + public PushRecipientsPanel(Context context) { + super(context); + initialize(); + } + + public PushRecipientsPanel(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this)); + } + + public List getRecipients() { + String rawText = recipientsText.getText().toString(); + return getRecipientsFromString(getContext(), rawText); + } + + public void disable() { + recipientsText.setText(""); + panel.setVisibility(View.GONE); + } + + public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) { + this.panelChangeListener = panelChangeListener; + } + + private void initialize() { + LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + inflater.inflate(R.layout.push_recipients_panel, this, true); + + View imageButton = findViewById(R.id.contacts_button); + ((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0; + + panel = findViewById(R.id.recipients_panel); + initRecipientsEditor(); + } + + private void initRecipientsEditor() { + + this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text); + + List recipients = getRecipients(); + + Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this)); + + recipientsText.setAdapter(new RecipientsAdapter(this.getContext())); + recipientsText.populate(recipients); + + recipientsText.setOnFocusChangeListener(new FocusChangedListener()); + recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView adapterView, View view, int i, long l) { + if (panelChangeListener != null) { + panelChangeListener.onRecipientsPanelUpdate(getRecipients()); + } + recipientsText.setText(""); + } + }); + } + + private @NonNull List getRecipientsFromString(Context context, @NonNull String rawText) { + StringTokenizer tokenizer = new StringTokenizer(rawText, ","); + List recipients = new LinkedList<>(); + + while (tokenizer.hasMoreTokens()) { + String token = tokenizer.nextToken().trim(); + + if (!TextUtils.isEmpty(token)) { + if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token))); + else recipients.add(Recipient.external(context, token)); + } + } + + return recipients; + } + + private boolean hasBracketedNumber(String recipient) { + int openBracketIndex = recipient.indexOf('<'); + + return (openBracketIndex != -1) && + (recipient.indexOf('>', openBracketIndex) != -1); + } + + private String parseBracketedNumber(String recipient) { + int begin = recipient.indexOf('<'); + int end = recipient.indexOf('>', begin); + String value = recipient.substring(begin + 1, end); + + return value; + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + recipientsText.populate(getRecipients()); + } + + private class FocusChangedListener implements View.OnFocusChangeListener { + public void onFocusChange(View v, boolean hasFocus) { + if (!hasFocus && (panelChangeListener != null)) { + panelChangeListener.onRecipientsPanelUpdate(getRecipients()); + } + } + } + + public interface RecipientsPanelChangedListener { + public void onRecipientsPanelUpdate(List recipients); + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java new file mode 100644 index 00000000..ab489b84 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -0,0 +1,296 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.os.Build; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.annimon.stream.Stream; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.List; + +public class QuoteView extends FrameLayout implements RecipientForeverObserver { + + private static final String TAG = QuoteView.class.getSimpleName(); + + private static final int MESSAGE_TYPE_PREVIEW = 0; + private static final int MESSAGE_TYPE_OUTGOING = 1; + private static final int MESSAGE_TYPE_INCOMING = 2; + + private ViewGroup mainView; + private ViewGroup footerView; + private TextView authorView; + private TextView bodyView; + private ImageView quoteBarView; + private ImageView thumbnailView; + private View attachmentVideoOverlayView; + private ViewGroup attachmentContainerView; + private TextView attachmentNameView; + private ImageView dismissView; + + private long id; + private LiveRecipient author; + private CharSequence body; + private TextView mediaDescriptionText; + private TextView missingLinkText; + private SlideDeck attachments; + private int messageType; + private int largeCornerRadius; + private int smallCornerRadius; + private CornerMask cornerMask; + + + public QuoteView(Context context) { + super(context); + initialize(null); + } + + public QuoteView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + public QuoteView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(attrs); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.quote_view, this); + + this.mainView = findViewById(R.id.quote_main); + this.footerView = findViewById(R.id.quote_missing_footer); + this.authorView = findViewById(R.id.quote_author); + this.bodyView = findViewById(R.id.quote_text); + this.quoteBarView = findViewById(R.id.quote_bar); + this.thumbnailView = findViewById(R.id.quote_thumbnail); + this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay); + this.attachmentContainerView = findViewById(R.id.quote_attachment_container); + this.attachmentNameView = findViewById(R.id.quote_attachment_name); + this.dismissView = findViewById(R.id.quote_dismiss); + this.mediaDescriptionText = findViewById(R.id.media_type); + this.missingLinkText = findViewById(R.id.quote_missing_text); + this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large); + this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom); + + cornerMask = new CornerMask(this); + cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0); + int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK); + int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK); + messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0); + typedArray.recycle(); + + dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE); + + authorView.setTextColor(primaryColor); + bodyView.setTextColor(primaryColor); + attachmentNameView.setTextColor(primaryColor); + mediaDescriptionText.setTextColor(secondaryColor); + missingLinkText.setTextColor(primaryColor); + + if (messageType == MESSAGE_TYPE_PREVIEW) { + int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview); + cornerMask.setTopLeftRadius(radius); + cornerMask.setTopRightRadius(radius); + } + } + + dismissView.setOnClickListener(view -> setVisibility(GONE)); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + cornerMask.mask(canvas); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (author != null) author.removeForeverObserver(this); + } + + public void setQuote(GlideRequests glideRequests, + long id, + @NonNull Recipient author, + @Nullable CharSequence body, + boolean originalMissing, + @NonNull SlideDeck attachments) + { + if (this.author != null) this.author.removeForeverObserver(this); + + this.id = id; + this.author = author.live(); + this.body = body; + this.attachments = attachments; + + this.author.observeForever(this); + setQuoteAuthor(author); + setQuoteText(body, attachments); + setQuoteAttachment(glideRequests, attachments); + setQuoteMissingFooter(originalMissing); + } + + public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) { + cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius); + cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius); + } + + public void dismiss() { + if (this.author != null) this.author.removeForeverObserver(this); + + this.id = 0; + this.author = null; + this.body = null; + + setVisibility(GONE); + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + setQuoteAuthor(recipient); + } + + private void setQuoteAuthor(@NonNull Recipient author) { + boolean outgoing = messageType != MESSAGE_TYPE_INCOMING; + + authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you) + : author.getDisplayName(getContext())); + + // We use the raw color resource because Android 4.x was struggling with tints here + quoteBarView.setImageResource(author.getColor().toQuoteBarColorResource(getContext(), outgoing)); + mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing)); + } + + private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { + if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) { + bodyView.setVisibility(VISIBLE); + bodyView.setText(body == null ? "" : body); + mediaDescriptionText.setVisibility(GONE); + return; + } + + bodyView.setVisibility(GONE); + mediaDescriptionText.setVisibility(VISIBLE); + + List audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList(); + List documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList(); + List imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList(); + List videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList(); + List stickerSlides = Stream.of(attachments.getSlides()).filter(Slide::hasSticker).limit(1).toList(); + List viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList(); + + // Given that most types have images, we specifically check images last + if (!viewOnceSlides.isEmpty()) { + mediaDescriptionText.setText(R.string.QuoteView_view_once_media); + } else if (!audioSlides.isEmpty()) { + mediaDescriptionText.setText(R.string.QuoteView_audio); + } else if (!documentSlides.isEmpty()) { + mediaDescriptionText.setVisibility(GONE); + } else if (!videoSlides.isEmpty()) { + mediaDescriptionText.setText(R.string.QuoteView_video); + } else if (!stickerSlides.isEmpty()) { + mediaDescriptionText.setText(R.string.QuoteView_sticker); + } else if (!imageSlides.isEmpty()) { + mediaDescriptionText.setText(R.string.QuoteView_photo); + } + } + + private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) { + List imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).limit(1).toList(); + List documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList(); + List viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList(); + + attachmentVideoOverlayView.setVisibility(GONE); + + if (!viewOnceSlides.isEmpty()) { + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(GONE); + } else if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getUri() != null) { + thumbnailView.setVisibility(VISIBLE); + attachmentContainerView.setVisibility(GONE); + dismissView.setBackgroundResource(R.drawable.dismiss_background); + if (imageVideoSlides.get(0).hasVideo()) { + attachmentVideoOverlayView.setVisibility(VISIBLE); + } + glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getUri())) + .centerCrop() + .override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size)) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .into(thumbnailView); + } else if (!documentSlides.isEmpty()){ + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(VISIBLE); + attachmentNameView.setText(documentSlides.get(0).getFileName().or("")); + } else { + thumbnailView.setVisibility(GONE); + attachmentContainerView.setVisibility(GONE); + dismissView.setBackgroundDrawable(null); + } + + if (ThemeUtil.isDarkTheme(getContext())) { + dismissView.setBackgroundResource(R.drawable.circle_alpha); + } + } + + private void setQuoteMissingFooter(boolean missing) { + footerView.setVisibility(missing ? VISIBLE : GONE); + footerView.setBackgroundColor(author.get().getColor().toQuoteFooterColor(getContext(), messageType != MESSAGE_TYPE_INCOMING)); + } + + public long getQuoteId() { + return id; + } + + public Recipient getAuthor() { + return author.get(); + } + + public CharSequence getBody() { + return body; + } + + public List getAttachments() { + return attachments.asAttachments(); + } + + public @NonNull List getMentions() { + return MentionAnnotation.getMentionsFromAnnotations(body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java new file mode 100644 index 00000000..e6533ad9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.components; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.VersionTracker; + +import java.util.concurrent.TimeUnit; + +public class RatingManager { + + private static final int DAYS_SINCE_INSTALL_THRESHOLD = 7; + private static final int DAYS_UNTIL_REPROMPT_THRESHOLD = 4; + + private static final String TAG = RatingManager.class.getSimpleName(); + + public static void showRatingDialogIfNecessary(Context context) { + if (!TextSecurePreferences.isRatingEnabled(context)) return; + + long daysSinceInstall = VersionTracker.getDaysSinceFirstInstalled(context); + long laterTimestamp = TextSecurePreferences.getRatingLaterTimestamp(context); + + if (daysSinceInstall >= DAYS_SINCE_INSTALL_THRESHOLD && + System.currentTimeMillis() >= laterTimestamp) + { + showRatingDialog(context); + } + } + + private static void showRatingDialog(final Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.RatingManager_rate_this_app) + .setMessage(R.string.RatingManager_if_you_enjoy_using_this_app_please_take_a_moment) + .setPositiveButton(R.string.RatingManager_rate_now, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + TextSecurePreferences.setRatingEnabled(context, false); + startPlayStore(context); + } + }) + .setNegativeButton(R.string.RatingManager_no_thanks, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + TextSecurePreferences.setRatingEnabled(context, false); + } + }) + .setNeutralButton(R.string.RatingManager_later, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + long waitUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(DAYS_UNTIL_REPROMPT_THRESHOLD); + TextSecurePreferences.setRatingLaterTimestamp(context, waitUntil); + } + }) + .show(); + } + + private static void startPlayStore(Context context) { + Uri uri = Uri.parse("market://details?id=" + context.getPackageName()); + try { + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } catch (ActivityNotFoundException e) { + Log.w(TAG, e); + Toast.makeText(context, R.string.RatingManager_whoops_the_play_store_app_does_not_appear_to_be_installed, Toast.LENGTH_LONG).show(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java new file mode 100644 index 00000000..7876b020 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.components; + + +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; +import com.bumptech.glide.signature.MediaStoreSignature; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.database.loaders.RecentPhotosLoader; +import org.thoughtcrime.securesms.mms.GlideApp; + +public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.LoaderCallbacks { + + @NonNull private final RecyclerView recyclerView; + @Nullable private OnItemClickedListener listener; + + public RecentPhotoViewRail(Context context) { + this(context, null); + } + + public RecentPhotoViewRail(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RecentPhotoViewRail(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.recent_photo_view, this); + + this.recyclerView = findViewById(R.id.photo_list); + this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); + this.recyclerView.setItemAnimator(new DefaultItemAnimator()); + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.listener = listener; + + if (this.recyclerView.getAdapter() != null) { + ((RecentPhotoAdapter)this.recyclerView.getAdapter()).setListener(listener); + } + } + + @Override + public @NonNull Loader onCreateLoader(int id, Bundle args) { + return new RecentPhotosLoader(getContext()); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor data) { + this.recyclerView.setAdapter(new RecentPhotoAdapter(getContext(), data, RecentPhotosLoader.BASE_URL, listener)); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); + } + + private static class RecentPhotoAdapter extends CursorRecyclerViewAdapter { + + @SuppressWarnings("unused") + private static final String TAG = RecentPhotoAdapter.class.getSimpleName(); + + @NonNull private final Uri baseUri; + @Nullable private OnItemClickedListener clickedListener; + + private RecentPhotoAdapter(@NonNull Context context, @NonNull Cursor cursor, @NonNull Uri baseUri, @Nullable OnItemClickedListener listener) { + super(context, cursor); + this.baseUri = baseUri; + this.clickedListener = listener; + } + + @Override + public RecentPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.recent_photo_view_item, parent, false); + + return new RecentPhotoViewHolder(itemView); + } + + @Override + public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { + viewHolder.imageView.setImageDrawable(null); + + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); + long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); + long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); + String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); + String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID)); + int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); + long size = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE)); + int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); + int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); + + final Uri uri = ContentUris.withAppendedId(RecentPhotosLoader.BASE_URL, rowId); + + Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); + + GlideApp.with(getContext().getApplicationContext()) + .load(uri) + .signature(signature) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(viewHolder.imageView); + + viewHolder.imageView.setOnClickListener(v -> { + if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height, size); + }); + + } + + @TargetApi(16) + @SuppressWarnings("SuspiciousNameCombination") + private String getWidthColumn(int orientation) { + if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.WIDTH; + else return MediaStore.Images.ImageColumns.HEIGHT; + } + + @TargetApi(16) + @SuppressWarnings("SuspiciousNameCombination") + private String getHeightColumn(int orientation) { + if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.HEIGHT; + else return MediaStore.Images.ImageColumns.WIDTH; + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.clickedListener = listener; + } + + static class RecentPhotoViewHolder extends RecyclerView.ViewHolder { + + ImageView imageView; + + RecentPhotoViewHolder(View itemView) { + super(itemView); + + this.imageView = itemView.findViewById(R.id.thumbnail); + } + } + } + + public interface OnItemClickedListener { + void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java b/app/src/main/java/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java new file mode 100644 index 00000000..e027315f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RecyclerViewFastScroller.java @@ -0,0 +1,204 @@ +/** + * Modified version of + * https://github.com/AndroidDeveloperLB/LollipopContactsRecyclerViewFastScroller + * + * Their license: + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +public final class RecyclerViewFastScroller extends LinearLayout { + private static final int BUBBLE_ANIMATION_DURATION = 100; + private static final int TRACK_SNAP_RANGE = 5; + + @NonNull private final TextView bubble; + @NonNull private final View handle; + @Nullable private RecyclerView recyclerView; + + private int height; + private ObjectAnimator currentAnimator; + + private final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + if (handle.isSelected()) return; + final int offset = recyclerView.computeVerticalScrollOffset(); + final int range = recyclerView.computeVerticalScrollRange(); + final int extent = recyclerView.computeVerticalScrollExtent(); + final int offsetRange = Math.max(range - extent, 1); + setBubbleAndHandlePosition((float) Util.clamp(offset, 0, offsetRange) / offsetRange); + } + }; + + public interface FastScrollAdapter { + CharSequence getBubbleText(int position); + } + + public RecyclerViewFastScroller(final Context context) { + this(context, null); + } + + public RecyclerViewFastScroller(final Context context, final AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + setClipChildren(false); + setScrollContainer(true); + inflate(context, R.layout.recycler_view_fast_scroller, this); + bubble = findViewById(R.id.fastscroller_bubble); + handle = findViewById(R.id.fastscroller_handle); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + height = h; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + final int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (event.getX() < handle.getX() - handle.getPaddingLeft() || + event.getY() < handle.getY() - handle.getPaddingTop() || + event.getY() > handle.getY() + handle.getHeight() + handle.getPaddingBottom()) + { + return false; + } + if (currentAnimator != null) { + currentAnimator.cancel(); + } + if (bubble.getVisibility() != VISIBLE) { + showBubble(); + } + handle.setSelected(true); + case MotionEvent.ACTION_MOVE: + final float y = event.getY(); + setBubbleAndHandlePosition(y / height); + setRecyclerViewPosition(y); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + handle.setSelected(false); + hideBubble(); + return true; + } + return super.onTouchEvent(event); + } + + public void setRecyclerView(final @Nullable RecyclerView recyclerView) { + if (this.recyclerView != null) { + this.recyclerView.removeOnScrollListener(onScrollListener); + } + this.recyclerView = recyclerView; + if (recyclerView != null) { + recyclerView.addOnScrollListener(onScrollListener); + recyclerView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + recyclerView.getViewTreeObserver().removeOnPreDrawListener(this); + if (handle.isSelected()) return true; + final int verticalScrollOffset = recyclerView.computeVerticalScrollOffset(); + final int verticalScrollRange = recyclerView.computeVerticalScrollRange(); + float proportion = (float)verticalScrollOffset / ((float)verticalScrollRange - height); + setBubbleAndHandlePosition(height * proportion); + return true; + } + }); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (recyclerView != null) + recyclerView.removeOnScrollListener(onScrollListener); + } + + private void setRecyclerViewPosition(float y) { + if (recyclerView != null) { + final int itemCount = recyclerView.getAdapter().getItemCount(); + float proportion; + if (handle.getY() == 0) { + proportion = 0f; + } else if (handle.getY() + handle.getHeight() >= height - TRACK_SNAP_RANGE) { + proportion = 1f; + } else { + proportion = y / (float)height; + } + + final int targetPos = Util.clamp((int)(proportion * (float)itemCount), 0, itemCount - 1); + ((LinearLayoutManager) recyclerView.getLayoutManager()).scrollToPositionWithOffset(targetPos, 0); + final CharSequence bubbleText = ((FastScrollAdapter) recyclerView.getAdapter()).getBubbleText(targetPos); + bubble.setText(bubbleText); + } + } + + private void setBubbleAndHandlePosition(float y) { + final int handleHeight = handle.getHeight(); + final int bubbleHeight = bubble.getHeight(); + final int handleY = Util.clamp((int)((height - handleHeight) * y), 0, height - handleHeight); + handle.setY(handleY); + bubble.setY(Util.clamp(handleY - bubbleHeight - bubble.getPaddingBottom() + handleHeight, + 0, + height - bubbleHeight)); + } + + private void showBubble() { + bubble.setVisibility(VISIBLE); + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.start(); + } + + private void hideBubble() { + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + }); + currentAnimator.start(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java b/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java new file mode 100644 index 00000000..713b63b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class RemovableEditableMediaView extends FrameLayout { + + private final @NonNull ImageView remove; + private final @NonNull ImageView edit; + + private final int removeSize; + private final int editSize; + + private @Nullable View current; + + public RemovableEditableMediaView(Context context) { + this(context, null); + } + + public RemovableEditableMediaView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RemovableEditableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); + this.edit = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_edit_button, this, false); + + this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); + this.editSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_edit_button_size); + + this.remove.setVisibility(View.GONE); + this.edit.setVisibility(View.GONE); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.addView(remove); + this.addView(edit); + } + + public void display(@Nullable View view, boolean editable) { + edit.setVisibility(editable ? View.VISIBLE : View.GONE); + + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + + if (view != null) { + view.setPadding(view.getPaddingLeft(), removeSize / 2, removeSize / 2, view.getPaddingRight()); + edit.setPadding(0, 0, removeSize / 2, 0); + + view.setVisibility(View.VISIBLE); + remove.setVisibility(View.VISIBLE); + } else { + remove.setVisibility(View.GONE); + edit.setVisibility(View.GONE); + } + + current = view; + } + + public void setRemoveClickListener(View.OnClickListener listener) { + this.remove.setOnClickListener(listener); + } + + public void setEditClickListener(View.OnClickListener listener) { + this.edit.setOnClickListener(listener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java b/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java new file mode 100644 index 00000000..7a9a4aac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import androidx.appcompat.widget.AppCompatImageButton; + +public class RepeatableImageKey extends AppCompatImageButton { + + private KeyEventListener listener; + + public RepeatableImageKey(Context context) { + super(context); + init(); + } + + public RepeatableImageKey(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public RepeatableImageKey(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setOnClickListener(new RepeaterClickListener()); + setOnTouchListener(new RepeaterTouchListener()); + } + + public void setOnKeyEventListener(KeyEventListener listener) { + this.listener = listener; + } + + private void notifyListener() { + if (this.listener != null) this.listener.onKeyEvent(); + } + + private class RepeaterClickListener implements OnClickListener { + @Override public void onClick(View v) { + notifyListener(); + } + } + + private class Repeater implements Runnable { + @Override + public void run() { + notifyListener(); + postDelayed(this, ViewConfiguration.getKeyRepeatDelay()); + } + } + + private class RepeaterTouchListener implements OnTouchListener { + private final Repeater repeater; + + RepeaterTouchListener() { + this.repeater = new Repeater(); + } + + @Override + public boolean onTouch(View view, MotionEvent motionEvent) { + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_DOWN: + view.postDelayed(repeater, ViewConfiguration.getKeyRepeatTimeout()); + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + return false; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + view.removeCallbacks(repeater); + return false; + default: + return false; + } + } + } + + public interface KeyEventListener { + void onKeyEvent(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java new file mode 100644 index 00000000..75bbbf9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.components; + + +import android.animation.Animator; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; + +public class SearchToolbar extends LinearLayout { + + private float x, y; + private MenuItem searchItem; + private SearchListener listener; + + public SearchToolbar(Context context) { + super(context); + initialize(); + } + + public SearchToolbar(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.search_toolbar, this); + setOrientation(VERTICAL); + + Toolbar toolbar = findViewById(R.id.toolbar); + + Drawable drawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_arrow_left_24); + toolbar.setNavigationIcon(drawable); + toolbar.setCollapseIcon(drawable); + toolbar.inflateMenu(R.menu.conversation_list_search); + + this.searchItem = toolbar.getMenu().findItem(R.id.action_filter_search); + SearchView searchView = (SearchView) searchItem.getActionView(); + EditText searchText = searchView.findViewById(R.id.search_src_text); + + searchView.setSubmitButtonEnabled(false); + + if (searchText != null) searchText.setHint(R.string.SearchToolbar_search); + else searchView.setQueryHint(getResources().getString(R.string.SearchToolbar_search)); + + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + if (listener != null) listener.onSearchTextChange(query); + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + return onQueryTextSubmit(newText); + } + }); + + searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + hide(); + return true; + } + }); + + toolbar.setNavigationOnClickListener(v -> hide()); + } + + @MainThread + public void display(float x, float y) { + if (getVisibility() != View.VISIBLE) { + this.x = x; + this.y = y; + + searchItem.expandActionView(); + + if (Build.VERSION.SDK_INT >= 21) { + Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth()); + animator.setDuration(400); + + setVisibility(View.VISIBLE); + animator.start(); + } else { + setVisibility(View.VISIBLE); + } + } + } + + public void collapse() { + searchItem.collapseActionView(); + } + + @MainThread + private void hide() { + if (getVisibility() == View.VISIBLE) { + + + if (listener != null) listener.onSearchClosed(); + + if (Build.VERSION.SDK_INT >= 21) { + Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0); + animator.setDuration(400); + animator.addListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + setVisibility(View.INVISIBLE); + } + }); + animator.start(); + } else { + setVisibility(View.INVISIBLE); + } + } + } + + public boolean isVisible() { + return getVisibility() == View.VISIBLE; + } + + @MainThread + public void setListener(SearchListener listener) { + this.listener = listener; + } + + public interface SearchListener { + void onSearchTextChange(String text); + void onSearchClosed(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java new file mode 100644 index 00000000..dc8d3779 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +/** + * Custom styled search view that we can insert into ActionBar menus + */ +public class SearchView extends androidx.appcompat.widget.SearchView { + public SearchView(@NonNull Context context) { + this(context, null); + } + + public SearchView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.search_view_style); + } + + public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java new file mode 100644 index 00000000..0b7aa857 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; + +/** + * Selection aware {@link EmojiEditText}. This view allows the developer to provide an + * {@link OnSelectionChangedListener} that will be notified when the selection is changed. + */ +public class SelectionAwareEmojiEditText extends EmojiEditText { + + private OnSelectionChangedListener onSelectionChangedListener; + + public SelectionAwareEmojiEditText(Context context) { + super(context); + } + + public SelectionAwareEmojiEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SelectionAwareEmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setOnSelectionChangedListener(@Nullable OnSelectionChangedListener onSelectionChangedListener) { + this.onSelectionChangedListener = onSelectionChangedListener; + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (onSelectionChangedListener != null) { + onSelectionChangedListener.onSelectionChanged(selStart, selEnd); + } + super.onSelectionChanged(selStart, selEnd); + } + + public interface OnSelectionChangedListener { + void onSelectionChanged(int selStart, int selEnd); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java new file mode 100644 index 00000000..ea85921f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageButton; + +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.TransportOptions.OnTransportChangedListener; +import org.thoughtcrime.securesms.TransportOptionsPopup; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +public class SendButton extends AppCompatImageButton + implements TransportOptions.OnTransportChangedListener, + TransportOptionsPopup.SelectedListener, + View.OnLongClickListener +{ + + private final TransportOptions transportOptions; + + private Optional transportOptionsPopup = Optional.absent(); + + @SuppressWarnings("unused") + public SendButton(Context context) { + super(context); + this.transportOptions = initializeTransportOptions(false); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + @SuppressWarnings("unused") + public SendButton(Context context, AttributeSet attrs) { + super(context, attrs); + this.transportOptions = initializeTransportOptions(false); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + @SuppressWarnings("unused") + public SendButton(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + this.transportOptions = initializeTransportOptions(false); + ViewUtil.mirrorIfRtl(this, getContext()); + } + + private TransportOptions initializeTransportOptions(boolean media) { + if (isInEditMode()) return null; + + TransportOptions transportOptions = new TransportOptions(getContext(), media); + transportOptions.addOnTransportChangedListener(this); + + setOnLongClickListener(this); + + return transportOptions; + } + + private TransportOptionsPopup getTransportOptionsPopup() { + if (!transportOptionsPopup.isPresent()) { + transportOptionsPopup = Optional.of(new TransportOptionsPopup(getContext(), this, this)); + } + return transportOptionsPopup.get(); + } + + public boolean isManualSelection() { + return transportOptions.isManualSelection(); + } + + public void addOnTransportChangedListener(OnTransportChangedListener listener) { + transportOptions.addOnTransportChangedListener(listener); + } + + public TransportOption getSelectedTransport() { + return transportOptions.getSelectedTransport(); + } + + public void resetAvailableTransports(boolean isMediaMessage) { + transportOptions.reset(isMediaMessage); + } + + public void disableTransport(TransportOption.Type type) { + transportOptions.disableTransport(type); + } + + public void setDefaultTransport(TransportOption.Type type) { + transportOptions.setDefaultTransport(type); + } + + public void setTransport(@NonNull TransportOption option) { + transportOptions.setSelectedTransport(option); + } + + public void setDefaultSubscriptionId(Optional subscriptionId) { + transportOptions.setDefaultSubscriptionId(subscriptionId); + } + + @Override + public void onSelected(TransportOption option) { + transportOptions.setSelectedTransport(option); + getTransportOptionsPopup().dismiss(); + } + + @Override + public void onChange(TransportOption newTransport, boolean isManualSelection) { + setImageResource(newTransport.getDrawable()); + setContentDescription(newTransport.getDescription()); + } + + @Override + public boolean onLongClick(View v) { + if (isEnabled() && transportOptions.getEnabledTransports().size() > 1) { + getTransportOptionsPopup().display(transportOptions.getEnabledTransports()); + return true; + } + + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java new file mode 100644 index 00000000..20055c05 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import org.thoughtcrime.securesms.R; + +public class ShapeScrim extends View { + + private enum ShapeType { + CIRCLE, SQUARE + } + + private final Paint eraser; + private final ShapeType shape; + private final float radius; + + private Bitmap scrim; + private Canvas scrimCanvas; + + public ShapeScrim(Context context) { + this(context, null); + } + + public ShapeScrim(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ShapeScrim, 0, 0); + String shapeName = typedArray.getString(R.styleable.ShapeScrim_shape); + + if ("square".equalsIgnoreCase(shapeName)) this.shape = ShapeType.SQUARE; + else if ("circle".equalsIgnoreCase(shapeName)) this.shape = ShapeType.CIRCLE; + else this.shape = ShapeType.SQUARE; + + this.radius = typedArray.getFloat(R.styleable.ShapeScrim_radius, 0.4f); + + typedArray.recycle(); + } else { + this.shape = ShapeType.SQUARE; + this.radius = 0.4f; + } + + this.eraser = new Paint(); + this.eraser.setColor(0xFFFFFFFF); + this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } + + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight(); + float drawRadius = shortDimension * radius; + + if (scrimCanvas == null) { + scrim = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); + scrimCanvas = new Canvas(scrim); + } + + scrim.eraseColor(Color.TRANSPARENT); + scrimCanvas.drawColor(Color.parseColor("#55BDBDBD")); + + if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser); + else drawSquare(scrimCanvas, drawRadius, eraser); + + canvas.drawBitmap(scrim, 0, 0, null); + } + + @Override + public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldHeight, oldHeight); + + if (width != oldWidth || height != oldHeight) { + scrim = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + scrimCanvas = new Canvas(scrim); + } + } + + private void drawCircle(Canvas canvas, float radius, Paint eraser) { + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, eraser); + } + + private void drawSquare(Canvas canvas, float radius, Paint eraser) { + float left = (getWidth() / 2 ) - radius; + float top = (getHeight() / 2) - radius; + float right = left + (radius * 2); + float bottom = top + (radius * 2); + + RectF square = new RectF(left, top, right, bottom); + + canvas.drawRoundRect(square, 25, 25, eraser); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java b/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java new file mode 100644 index 00000000..34ff46fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.annimon.stream.Stream; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class SharedContactView extends LinearLayout implements RecipientForeverObserver { + + private ImageView avatarView; + private TextView nameView; + private TextView numberView; + private TextView actionButtonView; + private ConversationItemFooter footer; + + private Contact contact; + private Locale locale; + private GlideRequests glideRequests; + private EventListener eventListener; + private CornerMask cornerMask; + private int bigCornerRadius; + private int smallCornerRadius; + + private final Map activeRecipients = new HashMap<>(); + + public SharedContactView(Context context) { + super(context); + initialize(null); + } + + public SharedContactView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + public SharedContactView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(attrs); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public SharedContactView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.shared_contact_view, this); + + avatarView = findViewById(R.id.contact_avatar); + nameView = findViewById(R.id.contact_name); + numberView = findViewById(R.id.contact_number); + actionButtonView = findViewById(R.id.contact_action_button); + footer = findViewById(R.id.contact_footer); + + cornerMask = new CornerMask(this); + bigCornerRadius = getResources().getDimensionPixelOffset(R.dimen.message_corner_radius); + smallCornerRadius = getResources().getDimensionPixelOffset(R.dimen.message_corner_collapse_radius); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.SharedContactView, 0, 0); + int titleColor = typedArray.getInt(R.styleable.SharedContactView_contact_titleColor, Color.BLACK); + int captionColor = typedArray.getInt(R.styleable.SharedContactView_contact_captionColor, Color.BLACK); + int iconColor = typedArray.getInt(R.styleable.SharedContactView_contact_footerIconColor, Color.BLACK); + float footerAlpha = typedArray.getFloat(R.styleable.SharedContactView_contact_footerAlpha, 1); + typedArray.recycle(); + + nameView.setTextColor(titleColor); + numberView.setTextColor(captionColor); + footer.setTextColor(captionColor); + footer.setIconColor(iconColor); + footer.setAlpha(footerAlpha); + } + } + + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + cornerMask.mask(canvas); + } + + public void setContact(@NonNull Contact contact, @NonNull GlideRequests glideRequests, @NonNull Locale locale) { + this.glideRequests = glideRequests; + this.locale = locale; + this.contact = contact; + + Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this)); + this.activeRecipients.clear(); + + presentContact(contact); + presentAvatar(contact.getAvatarAttachment() != null ? contact.getAvatarAttachment().getUri() : null); + presentActionButtons(ContactUtil.getRecipients(getContext(), contact)); + + for (LiveRecipient recipient : activeRecipients.values()) { + recipient.observeForever(this); + } + } + + public void setSingularStyle() { + cornerMask.setBottomLeftRadius(bigCornerRadius); + cornerMask.setBottomRightRadius(bigCornerRadius); + } + + public void setClusteredIncomingStyle() { + cornerMask.setBottomLeftRadius(smallCornerRadius); + cornerMask.setBottomRightRadius(bigCornerRadius); + } + + public void setClusteredOutgoingStyle() { + cornerMask.setBottomLeftRadius(bigCornerRadius); + cornerMask.setBottomRightRadius(smallCornerRadius); + } + + public void setEventListener(@NonNull EventListener eventListener) { + this.eventListener = eventListener; + } + + public @NonNull View getAvatarView() { + return avatarView; + } + + public ConversationItemFooter getFooter() { + return footer; + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + presentActionButtons(Collections.singletonList(recipient.getId())); + } + + private void presentContact(@Nullable Contact contact) { + if (contact != null) { + nameView.setText(ContactUtil.getDisplayName(contact)); + numberView.setText(ContactUtil.getDisplayNumber(contact, locale)); + } else { + nameView.setText(""); + numberView.setText(""); + } + } + + private void presentAvatar(@Nullable Uri uri) { + if (uri != null) { + glideRequests.load(new DecryptableUri(uri)) + .fallback(R.drawable.ic_contact_picture) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .dontAnimate() + .into(avatarView); + } else { + glideRequests.load(R.drawable.ic_contact_picture) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(avatarView); + } + } + + private void presentActionButtons(@NonNull List recipients) { + for (RecipientId recipientId : recipients) { + activeRecipients.put(recipientId, Recipient.live(recipientId)); + } + + List pushUsers = new ArrayList<>(recipients.size()); + List systemUsers = new ArrayList<>(recipients.size()); + + for (LiveRecipient recipient : activeRecipients.values()) { + if (recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { + pushUsers.add(recipient.get()); + } else if (recipient.get().isSystemContact()) { + systemUsers.add(recipient.get()); + } + } + + if (!pushUsers.isEmpty()) { + actionButtonView.setText(R.string.SharedContactView_message); + actionButtonView.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onMessageClicked(pushUsers); + } + }); + } else if (!systemUsers.isEmpty()) { + actionButtonView.setText(R.string.SharedContactView_invite_to_signal); + actionButtonView.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onInviteClicked(systemUsers); + } + }); + } else { + actionButtonView.setText(R.string.SharedContactView_add_to_contacts); + actionButtonView.setOnClickListener(v -> { + if (eventListener != null && contact != null) { + eventListener.onAddToContactsClicked(contact); + } + }); + } + } + + public interface EventListener { + void onAddToContactsClicked(@NonNull Contact contact); + void onInviteClicked(@NonNull List choices); + void onMessageClicked(@NonNull List choices); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java new file mode 100644 index 00000000..f2cfe5f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build.VERSION_CODES; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.R; + +public class SquareFrameLayout extends FrameLayout { + + private final boolean squareHeight; + + @SuppressWarnings("unused") + public SquareFrameLayout(Context context) { + this(context, null); + } + + @SuppressWarnings("unused") + public SquareFrameLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) @SuppressWarnings("unused") + public SquareFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SquareFrameLayout, 0, 0); + this.squareHeight = typedArray.getBoolean(R.styleable.SquareFrameLayout_square_height, false); + typedArray.recycle(); + } else { + this.squareHeight = false; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + //noinspection SuspiciousNameCombination + if (squareHeight) super.onMeasure(heightMeasureSpec, heightMeasureSpec); + else super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SquareImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/SquareImageView.java new file mode 100644 index 00000000..b3304a16 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SquareImageView.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +public class SquareImageView extends AppCompatImageView { + public SquareImageView(Context context) { + super(context); + } + + public SquareImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SquareImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + //noinspection SuspiciousNameCombination + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SquareLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/SquareLinearLayout.java new file mode 100644 index 00000000..ea24fb7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SquareLinearLayout.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build.VERSION_CODES; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +public class SquareLinearLayout extends LinearLayout { + @SuppressWarnings("unused") + public SquareLinearLayout(Context context) { + super(context); + } + + @SuppressWarnings("unused") + public SquareLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(VERSION_CODES.HONEYCOMB) @SuppressWarnings("unused") + public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(VERSION_CODES.LOLLIPOP) @SuppressWarnings("unused") + public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + //noinspection SuspiciousNameCombination + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java new file mode 100644 index 00000000..0fd2a232 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; + +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.R; + +public class SwitchPreferenceCompat extends CheckBoxPreference { + + private Preference.OnPreferenceClickListener listener; + + public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setLayoutRes(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setLayoutRes(); + } + + public SwitchPreferenceCompat(Context context, AttributeSet attrs) { + super(context, attrs); + setLayoutRes(); + } + + public SwitchPreferenceCompat(Context context) { + super(context); + setLayoutRes(); + } + + private void setLayoutRes() { + setWidgetLayoutResource(R.layout.switch_compat_preference); + } + + @Override + public void setOnPreferenceClickListener(Preference.OnPreferenceClickListener listener) { + this.listener = listener; + } + + @Override + protected void onClick() { + if (listener == null || !listener.onPreferenceClick(this)) { + super.onClick(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java new file mode 100644 index 00000000..f90f05a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; +import android.database.Cursor; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class ThreadPhotoRailView extends FrameLayout { + + @NonNull private final RecyclerView recyclerView; + @Nullable private OnItemClickedListener listener; + + public ThreadPhotoRailView(Context context) { + this(context, null); + } + + public ThreadPhotoRailView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ThreadPhotoRailView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.recipient_preference_photo_rail, this); + + this.recyclerView = findViewById(R.id.photo_list); + this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); + this.recyclerView.setItemAnimator(new DefaultItemAnimator()); + this.recyclerView.setNestedScrollingEnabled(false); + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.listener = listener; + + if (this.recyclerView.getAdapter() != null) { + ((ThreadPhotoRailAdapter)this.recyclerView.getAdapter()).setListener(listener); + } + } + + public void setCursor(@NonNull GlideRequests glideRequests, @Nullable Cursor cursor) { + this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), glideRequests, cursor, this.listener)); + } + + private static class ThreadPhotoRailAdapter extends CursorRecyclerViewAdapter { + + @SuppressWarnings("unused") + private static final String TAG = ThreadPhotoRailAdapter.class.getSimpleName(); + + @NonNull private final GlideRequests glideRequests; + + @Nullable private OnItemClickedListener clickedListener; + + private ThreadPhotoRailAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @Nullable Cursor cursor, + @Nullable OnItemClickedListener listener) + { + super(context, cursor); + this.glideRequests = glideRequests; + this.clickedListener = listener; + } + + @Override + public ThreadPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.recipient_preference_photo_rail_item, parent, false); + + return new ThreadPhotoViewHolder(itemView); + } + + @Override + public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) { + ThumbnailView imageView = viewHolder.imageView; + MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor); + Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment()); + + if (slide != null) { + imageView.setImageResource(glideRequests, slide, false, false); + } + + imageView.setOnClickListener(v -> { + if (clickedListener != null) clickedListener.onItemClicked(mediaRecord); + }); + } + + public void setListener(@Nullable OnItemClickedListener listener) { + this.clickedListener = listener; + } + + static class ThreadPhotoViewHolder extends RecyclerView.ViewHolder { + + ThumbnailView imageView; + + ThreadPhotoViewHolder(View itemView) { + super(itemView); + + this.imageView = itemView.findViewById(R.id.thumbnail); + } + } + } + + public interface OnItemClickedListener { + void onItemClicked(MediaDatabase.MediaRecord mediaRecord); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java new file mode 100644 index 00000000..0debb78a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -0,0 +1,518 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; + +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.load.resource.bitmap.FitCenter; +import com.bumptech.glide.load.resource.bitmap.RoundedCorners; +import com.bumptech.glide.request.RequestOptions; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; + +public class ThumbnailView extends FrameLayout { + + private static final String TAG = ThumbnailView.class.getSimpleName(); + private static final int WIDTH = 0; + private static final int HEIGHT = 1; + private static final int MIN_WIDTH = 0; + private static final int MAX_WIDTH = 1; + private static final int MIN_HEIGHT = 2; + private static final int MAX_HEIGHT = 3; + + private ImageView image; + private ImageView blurhash; + private View playOverlay; + private View captionIcon; + private OnClickListener parentClickListener; + + private final int[] dimens = new int[2]; + private final int[] bounds = new int[4]; + private final int[] measureDimens = new int[2]; + + private Optional transferControls = Optional.absent(); + private SlideClickListener thumbnailClickListener = null; + private SlidesClickedListener downloadClickListener = null; + private Slide slide = null; + private BitmapTransformation fit = new CenterCrop(); + + private int radius; + + public ThumbnailView(Context context) { + this(context, null); + } + + public ThumbnailView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + inflate(context, R.layout.thumbnail_view, this); + + this.image = findViewById(R.id.thumbnail_image); + this.blurhash = findViewById(R.id.thumbnail_blurhash); + this.playOverlay = findViewById(R.id.play_overlay); + this.captionIcon = findViewById(R.id.thumbnail_caption_icon); + super.setOnClickListener(new ThumbnailClickDispatcher()); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0); + bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0); + bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0); + bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); + bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); + radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius)); + fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop(); + typedArray.recycle(); + } else { + radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius); + } + } + + @Override + protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) { + fillTargetDimensions(measureDimens, dimens, bounds); + if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) { + super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec); + return; + } + + int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight(); + int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom(); + + super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + float playOverlayScale = 1; + float captionIconScale = 1; + int playOverlayWidth = playOverlay.getLayoutParams().width; + + if (playOverlayWidth * 2 > getWidth()) { + playOverlayScale /= 2; + captionIconScale = 0; + } + + playOverlay.setScaleX(playOverlayScale); + playOverlay.setScaleY(playOverlayScale); + + captionIcon.setScaleX(captionIconScale); + captionIcon.setScaleY(captionIconScale); + } + + public void setMinimumThumbnailWidth(int width) { + bounds[MIN_WIDTH] = width; + invalidate(); + } + + @SuppressWarnings("SuspiciousNameCombination") + private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) { + int dimensFilledCount = getNonZeroCount(dimens); + int boundsFilledCount = getNonZeroCount(bounds); + boolean dimensAreInvalid = dimensFilledCount > 0 && dimensFilledCount < dimens.length; + + if (dimensAreInvalid) { + Log.w(TAG, String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %d x %d", dimens[WIDTH], dimens[HEIGHT])); + } + + if (dimensAreInvalid || dimensFilledCount == 0 || boundsFilledCount == 0) { + targetDimens[WIDTH] = 0; + targetDimens[HEIGHT] = 0; + return; + } + + double naturalWidth = dimens[WIDTH]; + double naturalHeight = dimens[HEIGHT]; + + int minWidth = bounds[MIN_WIDTH]; + int maxWidth = bounds[MAX_WIDTH]; + int minHeight = bounds[MIN_HEIGHT]; + int maxHeight = bounds[MAX_HEIGHT]; + + if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) { + throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]", + minWidth, maxWidth, minHeight, maxHeight)); + } + + double measuredWidth = naturalWidth; + double measuredHeight = naturalHeight; + + boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth; + boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight; + + if (!widthInBounds || !heightInBounds) { + double minWidthRatio = naturalWidth / minWidth; + double maxWidthRatio = naturalWidth / maxWidth; + double minHeightRatio = naturalHeight / minHeight; + double maxHeightRatio = naturalHeight / maxHeight; + + if (maxWidthRatio > 1 || maxHeightRatio > 1) { + if (maxWidthRatio >= maxHeightRatio) { + measuredWidth /= maxWidthRatio; + measuredHeight /= maxWidthRatio; + } else { + measuredWidth /= maxHeightRatio; + measuredHeight /= maxHeightRatio; + } + + measuredWidth = Math.max(measuredWidth, minWidth); + measuredHeight = Math.max(measuredHeight, minHeight); + + } else if (minWidthRatio < 1 || minHeightRatio < 1) { + if (minWidthRatio <= minHeightRatio) { + measuredWidth /= minWidthRatio; + measuredHeight /= minWidthRatio; + } else { + measuredWidth /= minHeightRatio; + measuredHeight /= minHeightRatio; + } + + measuredWidth = Math.min(measuredWidth, maxWidth); + measuredHeight = Math.min(measuredHeight, maxHeight); + } + } + + targetDimens[WIDTH] = (int) measuredWidth; + targetDimens[HEIGHT] = (int) measuredHeight; + } + + private int getNonZeroCount(int[] vals) { + int count = 0; + for (int val : vals) { + if (val > 0) { + count++; + } + } + return count; + } + + @Override + public void setOnClickListener(OnClickListener l) { + parentClickListener = l; + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + if (transferControls.isPresent()) transferControls.get().setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + if (transferControls.isPresent()) transferControls.get().setClickable(clickable); + } + + private TransferControlView getTransferControls() { + if (!transferControls.isPresent()) { + transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub)); + } + return transferControls.get(); + } + + public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) { + bounds[MIN_WIDTH] = minWidth; + bounds[MAX_WIDTH] = maxWidth; + bounds[MIN_HEIGHT] = minHeight; + bounds[MAX_HEIGHT] = maxHeight; + + forceLayout(); + } + + @UiThread + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, + boolean showControls, boolean isPreview) + { + return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0); + } + + @UiThread + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, + boolean showControls, boolean isPreview, + int naturalWidth, int naturalHeight) + { + if (showControls) { + getTransferControls().setSlide(slide); + getTransferControls().setDownloadClickListener(new DownloadClickDispatcher()); + } else if (transferControls.isPresent()) { + getTransferControls().setVisibility(View.GONE); + } + + if (slide.getUri() != null && slide.hasPlayOverlay() && + (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE || isPreview)) + { + this.playOverlay.setVisibility(View.VISIBLE); + } else { + this.playOverlay.setVisibility(View.GONE); + } + + if (Util.equals(slide, this.slide)) { + Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getUri()); + return new SettableFuture<>(false); + } + + if (this.slide != null && this.slide.getFastPreflightId() != null && + (!slide.hasVideo() || Util.equals(this.slide.getUri(), slide.getUri())) && + Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId())) + { + Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId()); + this.slide = slide; + return new SettableFuture<>(false); + } + + Log.i(TAG, "loading part with id " + slide.asAttachment().getUri() + + ", progress " + slide.getTransferState() + ", fast preflight id: " + + slide.asAttachment().getFastPreflightId()); + + BlurHash previousBlurhash = this.slide != null ? this.slide.getPlaceholderBlur() : null; + + this.slide = slide; + + this.captionIcon.setVisibility(slide.getCaption().isPresent() ? VISIBLE : GONE); + + dimens[WIDTH] = naturalWidth; + dimens[HEIGHT] = naturalHeight; + + invalidate(); + + SettableFuture result = new SettableFuture<>(); + boolean resultHandled = false; + + if (slide.hasPlaceholder() && (previousBlurhash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurhash))) { + buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(blurhash, result)); + resultHandled = true; + } else if (!slide.hasPlaceholder()) { + glideRequests.clear(blurhash); + blurhash.setImageDrawable(null); + } + + if (slide.getUri() != null) { + if (!MediaUtil.isJpegType(slide.getContentType()) && !MediaUtil.isVideoType(slide.getContentType())) { + SettableFuture thumbnailFuture = new SettableFuture<>(); + thumbnailFuture.deferTo(result); + thumbnailFuture.addListener(new BlurhashClearListener(glideRequests, blurhash)); + } + + buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result)); + resultHandled = true; + } else { + glideRequests.clear(image); + image.setImageDrawable(null); + } + + if (!resultHandled) { + result.set(false); + } + + return result; + } + + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { + return setImageResource(glideRequests, uri, 0, 0); + } + + public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height) { + SettableFuture future = new SettableFuture<>(); + + if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); + + GlideRequest request = glideRequests.load(new DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(withCrossFade()); + + if (width > 0 && height > 0) { + request = request.override(width, height); + } + + if (radius > 0) { + request = request.transforms(new CenterCrop(), new RoundedCorners(radius)); + } else { + request = request.transforms(new CenterCrop()); + } + + request.into(new GlideDrawableListeningTarget(image, future)); + blurhash.setImageDrawable(null); + + return future; + } + + public void setThumbnailClickListener(SlideClickListener listener) { + this.thumbnailClickListener = listener; + } + + public void setDownloadClickListener(SlidesClickedListener listener) { + this.downloadClickListener = listener; + } + + public void clear(GlideRequests glideRequests) { + glideRequests.clear(image); + + if (transferControls.isPresent()) { + getTransferControls().clear(); + } + + slide = null; + } + + public void showDownloadText(boolean showDownloadText) { + getTransferControls().setShowDownloadText(showDownloadText); + } + + public void showProgressSpinner() { + getTransferControls().showProgressSpinner(); + } + + public void setFit(@NonNull BitmapTransformation fit) { + this.fit = fit; + } + + protected void setRadius(int radius) { + this.radius = radius; + } + + private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { + GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getUri())) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .transition(withCrossFade()), fit); + + if (slide.isInProgress()) return request; + else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); + } + + private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { + GlideRequest bitmap = glideRequests.asBitmap(); + BlurHash placeholderBlur = slide.getPlaceholderBlur(); + + if (placeholderBlur != null) { + bitmap = bitmap.load(placeholderBlur); + } else { + bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme())); + } + + return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE), new CenterCrop()); + } + + private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) { + int[] size = new int[2]; + fillTargetDimensions(size, dimens, bounds); + if (size[WIDTH] == 0 && size[HEIGHT] == 0) { + size[WIDTH] = getDefaultWidth(); + size[HEIGHT] = getDefaultHeight(); + } + + request = request.override(size[WIDTH], size[HEIGHT]); + + if (radius > 0) { + return request.transforms(fitting, new RoundedCorners(radius)); + } else { + return request.transforms(fitting); + } + } + + private int getDefaultWidth() { + ViewGroup.LayoutParams params = getLayoutParams(); + if (params != null) { + return Math.max(params.width, 0); + } + return 0; + } + + private int getDefaultHeight() { + ViewGroup.LayoutParams params = getLayoutParams(); + if (params != null) { + return Math.max(params.height, 0); + } + return 0; + } + + private class ThumbnailClickDispatcher implements View.OnClickListener { + @Override + public void onClick(View view) { + if (thumbnailClickListener != null && + slide != null && + slide.asAttachment().getUri() != null && + slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) + { + thumbnailClickListener.onClick(view, slide); + } else if (parentClickListener != null) { + parentClickListener.onClick(view); + } + } + } + + private class DownloadClickDispatcher implements View.OnClickListener { + @Override + public void onClick(View view) { + Log.i(TAG, "onClick() for download button"); + if (downloadClickListener != null && slide != null) { + downloadClickListener.onClick(view, Collections.singletonList(slide)); + } else { + Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener)); + } + } + } + + private static class BlurhashClearListener implements ListenableFuture.Listener { + + private final GlideRequests glideRequests; + private final ImageView blurhash; + + private BlurhashClearListener(@NonNull GlideRequests glideRequests, @NonNull ImageView blurhash) { + this.glideRequests = glideRequests; + this.blurhash = blurhash; + } + + @Override + public void onSuccess(Boolean result) { + glideRequests.clear(blurhash); + blurhash.setImageDrawable(null); + } + + @Override + public void onFailure(ExecutionException e) { + glideRequests.clear(blurhash); + blurhash.setImageDrawable(null); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TooltipPopup.java b/app/src/main/java/org/thoughtcrime/securesms/components/TooltipPopup.java new file mode 100644 index 00000000..3959780b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TooltipPopup.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.os.Build; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; + +/** + * Class for creating simple tooltips to show throughout the app. Utilizes a popup window so you + * don't have to worry about view hierarchies or anything. + */ +public class TooltipPopup extends PopupWindow { + + public static final int POSITION_ABOVE = 0; + public static final int POSITION_BELOW = 1; + public static final int POSITION_START = 2; + public static final int POSITION_END = 3; + + private static final int POSITION_LEFT = 4; + private static final int POSITION_RIGHT = 5; + + private final View anchor; + private final ImageView arrow; + private final int position; + + public static Builder forTarget(@NonNull View anchor) { + return new Builder(anchor); + } + + private TooltipPopup(@NonNull View anchor, + int rawPosition, + @NonNull String text, + @ColorInt int backgroundTint, + @ColorInt int textColor, + @Nullable Object iconGlideModel, + @Nullable OnDismissListener dismissListener) + { + super(LayoutInflater.from(anchor.getContext()).inflate(R.layout.tooltip, null), + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + + this.anchor = anchor; + this.position = getRtlPosition(anchor.getContext(), rawPosition); + + switch (rawPosition) { + case POSITION_ABOVE: arrow = getContentView().findViewById(R.id.tooltip_arrow_bottom); break; + case POSITION_BELOW: arrow = getContentView().findViewById(R.id.tooltip_arrow_top); break; + case POSITION_START: arrow = getContentView().findViewById(R.id.tooltip_arrow_end); break; + case POSITION_END: arrow = getContentView().findViewById(R.id.tooltip_arrow_start); break; + default: throw new AssertionError("Invalid position!"); + } + + arrow.setVisibility(View.VISIBLE); + + TextView textView = getContentView().findViewById(R.id.tooltip_text); + textView.setText(text); + + if (textColor != 0) { + textView.setTextColor(textColor); + } + + View bubble = getContentView().findViewById(R.id.tooltip_bubble); + + if (backgroundTint == 0) { + bubble.getBackground().setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY); + arrow.setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY); + } else { + bubble.getBackground().setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY); + arrow.setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY); + } + + if (iconGlideModel != null) { + ImageView iconView = getContentView().findViewById(R.id.tooltip_icon); + iconView.setVisibility(View.VISIBLE); + GlideApp.with(anchor.getContext()).load(iconGlideModel).into(iconView); + } + + if (Build.VERSION.SDK_INT >= 21) { + setElevation(10); + } + + getContentView().setOnClickListener(v -> dismiss()); + + setOnDismissListener(dismissListener); + setBackgroundDrawable(null); + setOutsideTouchable(true); + } + + private void show() { + if (anchor.getWidth() == 0 && anchor.getHeight() == 0) { + anchor.post(this::show); + return; + } + + getContentView().measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + + int tooltipSpacing = anchor.getContext().getResources().getDimensionPixelOffset(R.dimen.tooltip_popup_margin); + + int xoffset; + int yoffset; + + switch (position) { + case POSITION_ABOVE: + xoffset = 0; + yoffset = -(anchor.getHeight() + getContentView().getMeasuredHeight() + tooltipSpacing); + onLayout(() -> setArrowHorizontalPosition(arrow, anchor)); + break; + case POSITION_BELOW: + xoffset = 0; + yoffset = tooltipSpacing; + onLayout(() -> setArrowHorizontalPosition(arrow, anchor)); + break; + case POSITION_LEFT: + xoffset = -getContentView().getMeasuredWidth() - tooltipSpacing; + yoffset = -(getContentView().getMeasuredHeight()/2 + anchor.getHeight()/2); + onLayout(() -> setArrowVerticalPosition(arrow, anchor)); + break; + case POSITION_RIGHT: + xoffset = anchor.getWidth() + tooltipSpacing; + yoffset = -(getContentView().getMeasuredHeight()/2 + anchor.getHeight()/2); + onLayout(() -> setArrowVerticalPosition(arrow, anchor)); + break; + default: + throw new AssertionError("Invalid tooltip position!"); + } + + showAsDropDown(anchor, xoffset, yoffset); + } + + private void onLayout(@NonNull Runnable runnable) { + getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getContentView().getViewTreeObserver().removeOnGlobalLayoutListener(this); + runnable.run(); + } + }); + } + + private static void setArrowHorizontalPosition(@NonNull View arrow, @NonNull View anchor) { + int arrowCenterX = getAbsolutePosition(arrow)[0] + arrow.getWidth()/2; + int anchorCenterX = getAbsolutePosition(anchor)[0] + anchor.getWidth()/2; + + arrow.setTranslationX(anchorCenterX - arrowCenterX); + } + + private static void setArrowVerticalPosition(@NonNull View arrow, @NonNull View anchor) { + int arrowCenterY = getAbsolutePosition(arrow)[1] + arrow.getHeight()/2; + int anchorCenterY = getAbsolutePosition(anchor)[1] + anchor.getHeight()/2; + + arrow.setTranslationY(anchorCenterY - arrowCenterY); + } + + private static int[] getAbsolutePosition(@NonNull View view) { + int[] position = new int[2]; + view.getLocationOnScreen(position); + return position; + } + + private static int getRtlPosition(@NonNull Context context, int position) { + if (position == POSITION_ABOVE || position == POSITION_BELOW) { + return position; + } else if (context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + return position == POSITION_START ? POSITION_RIGHT : POSITION_LEFT; + } else { + return position == POSITION_START ? POSITION_LEFT : POSITION_RIGHT; + } + } + + public static class Builder { + + private final View anchor; + + private int backgroundTint; + private int textColor; + private int textResId; + private Object iconGlideModel; + private OnDismissListener dismissListener; + + private Builder(@NonNull View anchor) { + this.anchor = anchor; + } + + public Builder setBackgroundTint(@ColorInt int color) { + this.backgroundTint = color; + return this; + } + + public Builder setTextColor(@ColorInt int color) { + this.textColor = color; + return this; + } + + public Builder setText(@StringRes int stringResId) { + this.textResId = stringResId; + return this; + } + + public Builder setIconGlideModel(Object model) { + this.iconGlideModel = model; + return this; + } + + public Builder setOnDismissListener(OnDismissListener dismissListener) { + this.dismissListener = dismissListener; + return this; + } + + public TooltipPopup show(int position) { + String text = anchor.getContext().getString(textResId); + TooltipPopup tooltip = new TooltipPopup(anchor, position, text, backgroundTint, textColor, iconGlideModel, dismissListener); + + tooltip.show(); + + return tooltip; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java new file mode 100644 index 00000000..a86ebf03 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java @@ -0,0 +1,257 @@ +package org.thoughtcrime.securesms.components; + +import android.animation.LayoutTransition; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Stream; +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.mms.Slide; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class TransferControlView extends FrameLayout { + + private static final int UPLOAD_TASK_WEIGHT = 1; + + /** + * A weighting compared to {@link #UPLOAD_TASK_WEIGHT} + */ + private static final int COMPRESSION_TASK_WEIGHT = 3; + + @Nullable private List slides; + @Nullable private View current; + + private final ProgressWheel progressWheel; + private final View downloadDetails; + private final TextView downloadDetailsText; + + private final Map networkProgress; + private final Map compresssionProgress; + + public TransferControlView(Context context) { + this(context, null); + } + + public TransferControlView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.transfer_controls_view, this); + + setLongClickable(false); + setBackground(ContextCompat.getDrawable(context, R.drawable.transfer_controls_background)); + setVisibility(GONE); + setLayoutTransition(new LayoutTransition()); + + this.networkProgress = new HashMap<>(); + this.compresssionProgress = new HashMap<>(); + + this.progressWheel = findViewById(R.id.progress_wheel); + this.downloadDetails = findViewById(R.id.download_details); + this.downloadDetailsText = findViewById(R.id.download_details_text); + } + + @Override + public void setFocusable(boolean focusable) { + super.setFocusable(focusable); + downloadDetails.setFocusable(focusable); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(clickable); + downloadDetails.setClickable(clickable); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + EventBus.getDefault().unregister(this); + } + + public void setSlide(final @NonNull Slide slides) { + setSlides(Collections.singletonList(slides)); + } + + public void setSlides(final @NonNull List slides) { + if (slides.isEmpty()) { + throw new IllegalArgumentException("Must provide at least one slide."); + } + + this.slides = slides; + + if (!isUpdateToExistingSet(slides)) { + networkProgress.clear(); + compresssionProgress.clear(); + Stream.of(slides).forEach(s -> networkProgress.put(s.asAttachment(), 0f)); + } + + for (Slide slide : slides) { + if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + networkProgress.put(slide.asAttachment(), 1f); + } + } + + switch (getTransferState(slides)) { + case AttachmentDatabase.TRANSFER_PROGRESS_STARTED: + showProgressSpinner(calculateProgress(networkProgress, compresssionProgress)); + break; + case AttachmentDatabase.TRANSFER_PROGRESS_PENDING: + case AttachmentDatabase.TRANSFER_PROGRESS_FAILED: + downloadDetailsText.setText(getDownloadText(this.slides)); + display(downloadDetails); + break; + default: + display(null); + break; + } + } + + public void showProgressSpinner() { + showProgressSpinner(calculateProgress(networkProgress, compresssionProgress)); + } + + public void showProgressSpinner(float progress) { + if (progress == 0) { + progressWheel.spin(); + } else { + progressWheel.setInstantProgress(progress); + } + + display(progressWheel); + } + + public void setDownloadClickListener(final @Nullable OnClickListener listener) { + downloadDetails.setOnClickListener(listener); + } + + public void clear() { + clearAnimation(); + setVisibility(GONE); + if (current != null) { + current.clearAnimation(); + current.setVisibility(GONE); + } + current = null; + slides = null; + } + + public void setShowDownloadText(boolean showDownloadText) { + downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE); + forceLayout(); + } + + private boolean isUpdateToExistingSet(@NonNull List slides) { + if (slides.size() != networkProgress.size()) { + return false; + } + + for (Slide slide : slides) { + if (!networkProgress.containsKey(slide.asAttachment())) { + return false; + } + } + + return true; + } + + private int getTransferState(@NonNull List slides) { + int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE; + for (Slide slide : slides) { + if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + transferState = slide.getTransferState(); + } else { + transferState = Math.max(transferState, slide.getTransferState()); + } + } + return transferState; + } + + private String getDownloadText(@NonNull List slides) { + if (slides.size() == 1) { + return slides.get(0).getContentDescription(); + } else { + int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE ? count + 1 : count); + return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount); + } + } + + private void display(@Nullable final View view) { + if (current == view) { + return; + } + + if (current != null) { + current.setVisibility(GONE); + } + + if (view != null) { + view.setVisibility(VISIBLE); + setVisibility(VISIBLE); + } else { + setVisibility(GONE); + } + + current = view; + } + + private static float calculateProgress(@NonNull Map uploadDownloadProgress, Map compresssionProgress) { + float totalDownloadProgress = 0; + float totalCompressionProgress = 0; + + for (float progress : uploadDownloadProgress.values()) { + totalDownloadProgress += progress; + } + + for (float progress : compresssionProgress.values()) { + totalCompressionProgress += progress; + } + + float weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress; + float weightedTotal = UPLOAD_TASK_WEIGHT * uploadDownloadProgress.size() + COMPRESSION_TASK_WEIGHT * compresssionProgress.size(); + + return weightedProgress / weightedTotal; + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventAsync(final PartProgressEvent event) { + if (networkProgress.containsKey(event.attachment)) { + float proportionCompleted = ((float) event.progress) / event.total; + + if (event.type == PartProgressEvent.Type.COMPRESSION) { + compresssionProgress.put(event.attachment, proportionCompleted); + } else { + networkProgress.put(event.attachment, proportionCompleted); + } + + progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java new file mode 100644 index 00000000..5221d783 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java @@ -0,0 +1,123 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class TypingIndicatorView extends LinearLayout { + + private static final long DURATION = 300; + private static final long PRE_DELAY = 500; + private static final long POST_DELAY = 500; + private static final long CYCLE_DURATION = 1500; + private static final long DOT_DURATION = 600; + private static final float MIN_ALPHA = 0.4f; + private static final float MIN_SCALE = 0.75f; + + private boolean isActive; + private long startTime; + + private View dot1; + private View dot2; + private View dot3; + + public TypingIndicatorView(Context context) { + super(context); + initialize(null); + } + + public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.typing_indicator_view, this); + + setWillNotDraw(false); + + dot1 = findViewById(R.id.typing_dot1); + dot2 = findViewById(R.id.typing_dot2); + dot3 = findViewById(R.id.typing_dot3); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0); + int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE); + typedArray.recycle(); + + dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (!isActive) { + super.onDraw(canvas); + return; + } + + long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION; + + render(dot1, timeInCycle, 0); + render(dot2, timeInCycle, 150); + render(dot3, timeInCycle, 300); + + super.onDraw(canvas); + postInvalidate(); + } + + private void render(View dot, long timeInCycle, long start) { + long end = start + DOT_DURATION; + long peak = start + (DOT_DURATION / 2); + + if (timeInCycle < start || timeInCycle > end) { + renderDefault(dot); + } else if (timeInCycle < peak) { + renderFadeIn(dot, timeInCycle, start); + } else { + renderFadeOut(dot, timeInCycle, peak); + } + } + + private void renderDefault(View dot) { + dot.setAlpha(MIN_ALPHA); + dot.setScaleX(MIN_SCALE); + dot.setScaleY(MIN_SCALE); + } + + private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) { + float percent = (float) (timeInCycle - fadeInStart) / 300; + dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent); + dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent); + dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent); + } + + private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) { + float percent = (float) (timeInCycle - fadeOutStart) / 300; + dot.setAlpha(1 - (1 - MIN_ALPHA) * percent); + dot.setScaleX(1 - (1 - MIN_SCALE) * percent); + dot.setScaleY(1 - (1 - MIN_SCALE) * percent); + } + + public void startAnimation() { + isActive = true; + startTime = System.currentTimeMillis(); + + postInvalidate(); + } + + public void stopAnimation() { + isActive = false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java new file mode 100644 index 00000000..433456eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@SuppressLint("UseSparseArrays") +public class TypingStatusRepository { + + private static final String TAG = TypingStatusRepository.class.getSimpleName(); + + private static final long RECIPIENT_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(15); + + private final Map> typistMap; + private final Map timers; + private final Map> notifiers; + private final MutableLiveData> threadsNotifier; + + public TypingStatusRepository() { + this.typistMap = new HashMap<>(); + this.timers = new HashMap<>(); + this.notifiers = new HashMap<>(); + this.threadsNotifier = new MutableLiveData<>(); + } + + public synchronized void onTypingStarted(@NonNull Context context, long threadId, @NonNull Recipient author, int device) { + if (author.isSelf()) { + return; + } + + Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); + Typist typist = new Typist(author, device, threadId); + + if (!typists.contains(typist)) { + typists.add(typist); + typistMap.put(threadId, typists); + notifyThread(threadId, typists, false); + } + + Runnable timer = timers.get(typist); + if (timer != null) { + Util.cancelRunnableOnMain(timer); + } + + timer = () -> onTypingStopped(context, threadId, author, device, false); + Util.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT); + timers.put(typist, timer); + } + + public synchronized void onTypingStopped(@NonNull Context context, long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) { + if (author.isSelf()) { + return; + } + + Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); + Typist typist = new Typist(author, device, threadId); + + if (typists.contains(typist)) { + typists.remove(typist); + notifyThread(threadId, typists, isReplacedByIncomingMessage); + } + + if (typists.isEmpty()) { + typistMap.remove(threadId); + } + + Runnable timer = timers.get(typist); + if (timer != null) { + Util.cancelRunnableOnMain(timer); + timers.remove(typist); + } + } + + public synchronized LiveData getTypists(long threadId) { + MutableLiveData notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>()); + notifiers.put(threadId, notifier); + return notifier; + } + + public synchronized LiveData> getTypingThreads() { + return threadsNotifier; + } + + public synchronized void clear() { + TypingState empty = new TypingState(Collections.emptyList(), false); + for (MutableLiveData notifier : notifiers.values()) { + notifier.postValue(empty); + } + + notifiers.clear(); + typistMap.clear(); + timers.clear(); + + threadsNotifier.postValue(Collections.emptySet()); + } + + private void notifyThread(long threadId, @NonNull Set typists, boolean isReplacedByIncomingMessage) { + Log.d(TAG, "notifyThread() threadId: " + threadId + " typists: " + typists.size() + " isReplaced: " + isReplacedByIncomingMessage); + + MutableLiveData notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>()); + notifiers.put(threadId, notifier); + + Set uniqueTypists = new LinkedHashSet<>(); + for (Typist typist : typists) { + uniqueTypists.add(typist.getAuthor()); + } + + notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage)); + + Set activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet()); + threadsNotifier.postValue(activeThreads); + } + + public static class TypingState { + private final List typists; + private final boolean replacedByIncomingMessage; + + public TypingState(List typists, boolean replacedByIncomingMessage) { + this.typists = typists; + this.replacedByIncomingMessage = replacedByIncomingMessage; + } + + public List getTypists() { + return typists; + } + + public boolean isReplacedByIncomingMessage() { + return replacedByIncomingMessage; + } + } + + private static class Typist { + private final Recipient author; + private final int device; + private final long threadId; + + private Typist(@NonNull Recipient author, int device, long threadId) { + this.author = author; + this.device = device; + this.threadId = threadId; + } + + public Recipient getAuthor() { + return author; + } + + public int getDevice() { + return device; + } + + public long getThreadId() { + return threadId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Typist typist = (Typist) o; + + if (device != typist.device) return false; + if (threadId != typist.threadId) return false; + return author.equals(typist.author); + } + + @Override + public int hashCode() { + int result = author.hashCode(); + result = 31 * result + device; + result = 31 * result + (int) (threadId ^ (threadId >>> 32)); + return result; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java new file mode 100644 index 00000000..5dab5b8e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.TypingSendJob; +import org.thoughtcrime.securesms.util.Util; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@SuppressLint("UseSparseArrays") +public class TypingStatusSender { + + private static final String TAG = TypingStatusSender.class.getSimpleName(); + + private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10); + private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3); + + private final Map selfTypingTimers; + + public TypingStatusSender() { + this.selfTypingTimers = new HashMap<>(); + } + + public synchronized void onTypingStarted(long threadId) { + TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair()); + selfTypingTimers.put(threadId, pair); + + if (pair.getStart() == null) { + sendTyping(threadId, true); + + Runnable start = new StartRunnable(threadId); + Util.runOnMainDelayed(start, REFRESH_TYPING_TIMEOUT); + pair.setStart(start); + } + + if (pair.getStop() != null) { + Util.cancelRunnableOnMain(pair.getStop()); + } + + Runnable stop = () -> onTypingStopped(threadId, true); + Util.runOnMainDelayed(stop, PAUSE_TYPING_TIMEOUT); + pair.setStop(stop); + } + + public synchronized void onTypingStopped(long threadId) { + onTypingStopped(threadId, false); + } + + public synchronized void onTypingStoppedWithNotify(long threadId) { + onTypingStopped(threadId, true); + } + + private synchronized void onTypingStopped(long threadId, boolean notify) { + TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair()); + selfTypingTimers.put(threadId, pair); + + if (pair.getStart() != null) { + Util.cancelRunnableOnMain(pair.getStart()); + + if (notify) { + sendTyping(threadId, false); + } + } + + if (pair.getStop() != null) { + Util.cancelRunnableOnMain(pair.getStop()); + } + + pair.setStart(null); + pair.setStop(null); + } + + private void sendTyping(long threadId, boolean typingStarted) { + ApplicationDependencies.getJobManager().add(new TypingSendJob(threadId, typingStarted)); + } + + private class StartRunnable implements Runnable { + + private final long threadId; + + private StartRunnable(long threadId) { + this.threadId = threadId; + } + + @Override + public void run() { + sendTyping(threadId, true); + Util.runOnMainDelayed(this, REFRESH_TYPING_TIMEOUT); + } + } + + private static class TimerPair { + private Runnable start; + private Runnable stop; + + public Runnable getStart() { + return start; + } + + public void setStart(Runnable start) { + this.start = start; + } + + public Runnable getStop() { + return stop; + } + + public void setStop(Runnable stop) { + this.stop = stop; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/WaveFormSeekBarView.java b/app/src/main/java/org/thoughtcrime/securesms/components/WaveFormSeekBarView.java new file mode 100644 index 00000000..e2df93b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/WaveFormSeekBarView.java @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.animation.Interpolator; +import android.view.animation.OvershootInterpolator; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.appcompat.widget.AppCompatSeekBar; + +import org.thoughtcrime.securesms.R; + +import java.util.Arrays; + +public final class WaveFormSeekBarView extends AppCompatSeekBar { + + private static final int ANIM_DURATION = 450; + private static final int ANIM_BAR_OFF_SET_DURATION = 12; + + private final Interpolator overshoot = new OvershootInterpolator(); + private final Paint paint = new Paint(); + private float[] data = new float[0]; + private long dataSetTime; + private Drawable progressDrawable; + private boolean waveMode; + + @ColorInt private int playedBarColor = 0xffffffff; + @ColorInt private int unplayedBarColor = 0x7fffffff; + @Px private int barWidth; + + public WaveFormSeekBarView(Context context) { + super(context); + init(); + } + + public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setWillNotDraw(false); + + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setAntiAlias(true); + + progressDrawable = super.getProgressDrawable(); + + if (isInEditMode()) { + setWaveData(sinusoidalExampleData()); + dataSetTime = 0; + } + + barWidth = getResources().getDimensionPixelSize(R.dimen.wave_form_bar_width); + } + + public void setColors(@ColorInt int playedBarColor, @ColorInt int unplayedBarColor, @ColorInt int thumbTint) { + this.playedBarColor = playedBarColor; + this.unplayedBarColor = unplayedBarColor; + + getThumb().setColorFilter(thumbTint, PorterDuff.Mode.SRC_IN); + + invalidate(); + } + + @Override + public void setProgressDrawable(Drawable progressDrawable) { + this.progressDrawable = progressDrawable; + if (!waveMode) { + super.setProgressDrawable(progressDrawable); + } + } + + @Override + public Drawable getProgressDrawable() { + return progressDrawable; + } + + public void setWaveData(@NonNull float[] data) { + if (!Arrays.equals(data, this.data)) { + this.data = data; + this.dataSetTime = System.currentTimeMillis(); + } + setWaveMode(data.length > 0); + } + + public void setWaveMode(boolean waveMode) { + this.waveMode = waveMode; + super.setProgressDrawable(this.waveMode ? null : progressDrawable); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + if (waveMode) { + drawWave(canvas); + } + super.onDraw(canvas); + } + + private void drawWave(Canvas canvas) { + paint.setStrokeWidth(barWidth); + + int usableHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + int usableWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + float midpoint = usableHeight / 2f; + float maxHeight = usableHeight / 2f - barWidth; + float barGap = (usableWidth - data.length * barWidth) / (float) (data.length - 1); + + boolean hasMoreFrames = false; + + canvas.save(); + canvas.translate(getPaddingLeft(), getPaddingTop()); + + if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) { + canvas.scale(-1, 1, usableWidth / 2f, usableHeight / 2f); + } + + for (int bar = 0; bar < data.length; bar++) { + float x = bar * (barWidth + barGap) + barWidth / 2f; + float y = data[bar] * maxHeight; + float progress = x / usableWidth; + + paint.setColor(progress * getMax() < getProgress() ? playedBarColor : unplayedBarColor); + + long time = System.currentTimeMillis() - bar * ANIM_BAR_OFF_SET_DURATION - dataSetTime; + float timeX = Math.max(0, Math.min(1, time / (float) ANIM_DURATION)); + float interpolatedTime = overshoot.getInterpolation(timeX); + float interpolatedY = y * interpolatedTime; + + canvas.drawLine(x, midpoint - interpolatedY, x, midpoint + interpolatedY, paint); + + if (time < ANIM_DURATION) { + hasMoreFrames = true; + } + } + + canvas.restore(); + + if (hasMoreFrames) { + invalidate(); + } + } + + private static float[] sinusoidalExampleData() { + float[] data = new float[21]; + for (int i = 0; i < data.length; i++) { + data[i] = (float) Math.sin(i / (float) (data.length - 1) * 2 * Math.PI); + } + return data; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java new file mode 100644 index 00000000..d0b790e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.Target; +import com.davemorrissey.labs.subscaleview.ImageSource; +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView; +import com.davemorrissey.labs.subscaleview.decoder.DecoderFactory; +import com.github.chrisbanes.photoview.PhotoView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.subsampling.AttachmentBitmapDecoder; +import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.io.InputStream; + + +public class ZoomingImageView extends FrameLayout { + + private static final String TAG = ZoomingImageView.class.getSimpleName(); + + private static final int ZOOM_TRANSITION_DURATION = 300; + + private static final float ZOOM_LEVEL_MIN = 1.0f; + + private static final float LARGE_IMAGES_ZOOM_LEVEL_MID = 1.5f; + private static final float LARGE_IMAGES_ZOOM_LEVEL_MAX = 2.0f; + + private static final float SMALL_IMAGES_ZOOM_LEVEL_MID = 3.0f; + private static final float SMALL_IMAGES_ZOOM_LEVEL_MAX = 8.0f; + + private final PhotoView photoView; + private final SubsamplingScaleImageView subsamplingImageView; + + public ZoomingImageView(Context context) { + this(context, null); + } + + public ZoomingImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.zooming_image_view, this); + + this.photoView = findViewById(R.id.image_view); + this.subsamplingImageView = findViewById(R.id.subsampling_image_view); + + this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF); + + this.photoView.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION); + this.photoView.setScaleLevels(ZOOM_LEVEL_MIN, SMALL_IMAGES_ZOOM_LEVEL_MID, SMALL_IMAGES_ZOOM_LEVEL_MAX); + + this.subsamplingImageView.setDoubleTapZoomDuration(ZOOM_TRANSITION_DURATION); + this.subsamplingImageView.setDoubleTapZoomScale(LARGE_IMAGES_ZOOM_LEVEL_MID); + this.subsamplingImageView.setMaxScale(LARGE_IMAGES_ZOOM_LEVEL_MAX); + + this.photoView.setOnClickListener(v -> ZoomingImageView.this.callOnClick()); + this.subsamplingImageView.setOnClickListener(v -> ZoomingImageView.this.callOnClick()); + } + + @SuppressLint("StaticFieldLeak") + public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType) + { + final Context context = getContext(); + final int maxTextureSize = BitmapUtil.getMaxTextureSize(); + + Log.i(TAG, "Max texture size: " + maxTextureSize); + + SimpleTask.run(ViewUtil.getActivityLifecycle(this), () -> { + if (MediaUtil.isGif(contentType)) return null; + + try { + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + return BitmapUtil.getDimensions(inputStream); + } catch (IOException | BitmapDecodingException e) { + Log.w(TAG, e); + return null; + } + }, dimensions -> { + Log.i(TAG, "Dimensions: " + (dimensions == null ? "(null)" : dimensions.first + ", " + dimensions.second)); + + if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { + Log.i(TAG, "Loading in standard image view..."); + setImageViewUri(glideRequests, uri); + } else { + Log.i(TAG, "Loading in subsampling image view..."); + setSubsamplingImageViewUri(uri); + } + }); + } + + private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { + photoView.setVisibility(View.VISIBLE); + subsamplingImageView.setVisibility(View.GONE); + + glideRequests.load(new DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontTransform() + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .into(photoView); + } + + private void setSubsamplingImageViewUri(@NonNull Uri uri) { + subsamplingImageView.setBitmapDecoderFactory(new AttachmentBitmapDecoderFactory()); + subsamplingImageView.setRegionDecoderFactory(new AttachmentRegionDecoderFactory()); + + subsamplingImageView.setVisibility(View.VISIBLE); + photoView.setVisibility(View.GONE); + + subsamplingImageView.setImage(ImageSource.uri(uri)); + } + + public void cleanup() { + photoView.setImageDrawable(null); + subsamplingImageView.recycle(); + } + + private static class AttachmentBitmapDecoderFactory implements DecoderFactory { + @Override + public AttachmentBitmapDecoder make() throws IllegalAccessException, InstantiationException { + return new AttachmentBitmapDecoder(); + } + } + + private static class AttachmentRegionDecoderFactory implements DecoderFactory { + @Override + public AttachmentRegionDecoder make() throws IllegalAccessException, InstantiationException { + return new AttachmentRegionDecoder(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java new file mode 100644 index 00000000..7a991eae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.components.camera; + +import android.content.Context; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +public class CameraSurfaceView extends SurfaceView implements SurfaceHolder.Callback { + private boolean ready; + + @SuppressWarnings("deprecation") + public CameraSurfaceView(Context context) { + super(context); + getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); + getHolder().addCallback(this); + } + + public boolean isReady() { + return ready; + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + ready = true; + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + ready = false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java new file mode 100644 index 00000000..d72db818 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.components.camera; + +import android.app.Activity; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.hardware.Camera.Parameters; +import android.hardware.Camera.Size; +import android.util.DisplayMetrics; +import android.view.Surface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +@SuppressWarnings("deprecation") +public class CameraUtils { + private static final String TAG = CameraUtils.class.getSimpleName(); + /* + * modified from: https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java + */ + public static @Nullable Size getPreferredPreviewSize(int displayOrientation, + int width, + int height, + @NonNull Parameters parameters) { + final int targetWidth = displayOrientation % 180 == 90 ? height : width; + final int targetHeight = displayOrientation % 180 == 90 ? width : height; + final double targetRatio = (double) targetWidth / targetHeight; + + Log.d(TAG, String.format(Locale.US, + "getPreferredPreviewSize(%d, %d, %d) -> target %dx%d, AR %.02f", + displayOrientation, width, height, + targetWidth, targetHeight, targetRatio)); + + List sizes = parameters.getSupportedPreviewSizes(); + List ideals = new LinkedList<>(); + List bigEnough = new LinkedList<>(); + + for (Size size : sizes) { + Log.d(TAG, String.format(Locale.US, " %dx%d (%.02f)", size.width, size.height, (float)size.width / size.height)); + + if (size.height == size.width * targetRatio && size.height >= targetHeight && size.width >= targetWidth) { + ideals.add(size); + Log.d(TAG, " (ideal ratio)"); + } else if (size.width >= targetWidth && size.height >= targetHeight) { + bigEnough.add(size); + Log.d(TAG, " (good size, suboptimal ratio)"); + } + } + + if (!ideals.isEmpty()) return Collections.min(ideals, new AreaComparator()); + else if (!bigEnough.isEmpty()) return Collections.min(bigEnough, new AspectRatioComparator(targetRatio)); + else return Collections.max(sizes, new AreaComparator()); + } + + // based on + // http://developer.android.com/reference/android/hardware/Camera.html#setDisplayOrientation(int) + // and http://stackoverflow.com/a/10383164/115145 + public static int getCameraDisplayOrientation(@NonNull Activity activity, + @NonNull CameraInfo info) + { + int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); + int degrees = 0; + DisplayMetrics dm = new DisplayMetrics(); + + activity.getWindowManager().getDefaultDisplay().getMetrics(dm); + + switch (rotation) { + case Surface.ROTATION_0: degrees = 0; break; + case Surface.ROTATION_90: degrees = 90; break; + case Surface.ROTATION_180: degrees = 180; break; + case Surface.ROTATION_270: degrees = 270; break; + } + + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + return (360 - ((info.orientation + degrees) % 360)) % 360; + } else { + return (info.orientation - degrees + 360) % 360; + } + } + + private static class AreaComparator implements Comparator { + @Override + public int compare(Size lhs, Size rhs) { + return Long.signum(lhs.width * lhs.height - rhs.width * rhs.height); + } + } + + private static class AspectRatioComparator extends AreaComparator { + private final double target; + public AspectRatioComparator(double target) { + this.target = target; + } + + @Override + public int compare(Size lhs, Size rhs) { + final double lhsDiff = Math.abs(target - (double) lhs.width / lhs.height); + final double rhsDiff = Math.abs(target - (double) rhs.width / rhs.height); + if (lhsDiff < rhsDiff) return -1; + else if (lhsDiff > rhsDiff) return 1; + else return super.compare(lhs, rhs); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java new file mode 100644 index 00000000..3c5b1729 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java @@ -0,0 +1,607 @@ +/*** + Copyright (c) 2013-2014 CommonsWare, LLC + Portions Copyright (C) 2007 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +package org.thoughtcrime.securesms.components.camera; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Rect; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.hardware.Camera.Parameters; +import android.hardware.Camera.Size; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Build.VERSION; +import android.util.AttributeSet; +import android.view.OrientationEventListener; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +@SuppressWarnings("deprecation") +public class CameraView extends ViewGroup { + private static final String TAG = CameraView.class.getSimpleName(); + + private final CameraSurfaceView surface; + private final OnOrientationChange onOrientationChange; + + private volatile Optional camera = Optional.absent(); + private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK; + private volatile int displayOrientation = -1; + + private @NonNull State state = State.PAUSED; + private @Nullable Size previewSize; + private @NonNull List listeners = Collections.synchronizedList(new LinkedList()); + private int outputOrientation = -1; + + public CameraView(Context context) { + this(context, null); + } + + public CameraView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CameraView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setBackgroundColor(Color.BLACK); + + if (attrs != null) { + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CameraView); + int camera = typedArray.getInt(R.styleable.CameraView_camera, -1); + + if (camera != -1) cameraId = camera; + else if (isMultiCamera()) cameraId = TextSecurePreferences.getDirectCaptureCameraId(context); + + typedArray.recycle(); + } + + surface = new CameraSurfaceView(getContext()); + onOrientationChange = new OnOrientationChange(context.getApplicationContext()); + addView(surface); + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + public void onResume() { + if (state != State.PAUSED) return; + state = State.RESUMED; + Log.i(TAG, "onResume() queued"); + enqueueTask(new SerialAsyncTask() { + @Override + protected + @Nullable + Void onRunBackground() { + try { + long openStartMillis = System.currentTimeMillis(); + camera = Optional.fromNullable(Camera.open(cameraId)); + Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms"); + synchronized (CameraView.this) { + CameraView.this.notifyAll(); + } + if (camera.isPresent()) onCameraReady(camera.get()); + } catch (Exception e) { + Log.w(TAG, e); + } + return null; + } + + @Override + protected void onPostMain(Void avoid) { + if (!camera.isPresent()) { + Log.w(TAG, "tried to open camera but got null"); + for (CameraViewListener listener : listeners) { + listener.onCameraFail(); + } + return; + } + + if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + onOrientationChange.enable(); + } + Log.i(TAG, "onResume() completed"); + } + }); + } + + public void onPause() { + if (state == State.PAUSED) return; + state = State.PAUSED; + Log.i(TAG, "onPause() queued"); + + enqueueTask(new SerialAsyncTask() { + private Optional cameraToDestroy; + + @Override + protected void onPreMain() { + cameraToDestroy = camera; + camera = Optional.absent(); + } + + @Override + protected Void onRunBackground() { + if (cameraToDestroy.isPresent()) { + try { + stopPreview(); + cameraToDestroy.get().setPreviewCallback(null); + cameraToDestroy.get().release(); + Log.w(TAG, "released old camera instance"); + } catch (Exception e) { + Log.w(TAG, e); + } + } + return null; + } + + @Override protected void onPostMain(Void avoid) { + onOrientationChange.disable(); + displayOrientation = -1; + outputOrientation = -1; + removeView(surface); + addView(surface); + Log.i(TAG, "onPause() completed"); + } + }); + + for (CameraViewListener listener : listeners) { + listener.onCameraStop(); + } + } + + public boolean isStarted() { + return state != State.PAUSED; + } + + @SuppressWarnings("SuspiciousNameCombination") + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int width = r - l; + final int height = b - t; + final int previewWidth; + final int previewHeight; + + if (camera.isPresent() && previewSize != null) { + if (displayOrientation == 90 || displayOrientation == 270) { + previewWidth = previewSize.height; + previewHeight = previewSize.width; + } else { + previewWidth = previewSize.width; + previewHeight = previewSize.height; + } + } else { + previewWidth = width; + previewHeight = height; + } + + if (previewHeight == 0 || previewWidth == 0) { + Log.w(TAG, "skipping layout due to zero-width/height preview size"); + return; + } + + if (width * previewHeight > height * previewWidth) { + final int scaledChildHeight = previewHeight * width / previewWidth; + surface.layout(0, (height - scaledChildHeight) / 2, width, (height + scaledChildHeight) / 2); + } else { + final int scaledChildWidth = previewWidth * height / previewHeight; + surface.layout((width - scaledChildWidth) / 2, 0, (width + scaledChildWidth) / 2, height); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + Log.i(TAG, "onSizeChanged(" + oldw + "x" + oldh + " -> " + w + "x" + h + ")"); + super.onSizeChanged(w, h, oldw, oldh); + if (camera.isPresent()) startPreview(camera.get().getParameters()); + } + + public void addListener(@NonNull CameraViewListener listener) { + listeners.add(listener); + } + + public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) { + enqueueTask(new PostInitializationTask() { + @Override + protected void onPostMain(Void avoid) { + if (camera.isPresent()) { + camera.get().setPreviewCallback(new Camera.PreviewCallback() { + @Override + public void onPreviewFrame(byte[] data, Camera camera) { + if (!CameraView.this.camera.isPresent()) { + return; + } + + final int rotation = getCameraPictureOrientation(); + final Size previewSize = camera.getParameters().getPreviewSize(); + if (data != null) { + previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation)); + } + } + }); + } + } + }); + } + + public boolean isMultiCamera() { + return Camera.getNumberOfCameras() > 1; + } + + public boolean isRearCamera() { + return cameraId == CameraInfo.CAMERA_FACING_BACK; + } + + public void flipCamera() { + if (Camera.getNumberOfCameras() > 1) { + cameraId = cameraId == CameraInfo.CAMERA_FACING_BACK + ? CameraInfo.CAMERA_FACING_FRONT + : CameraInfo.CAMERA_FACING_BACK; + onPause(); + onResume(); + TextSecurePreferences.setDirectCaptureCameraId(getContext(), cameraId); + } + } + + @TargetApi(14) + private void onCameraReady(final @NonNull Camera camera) { + final Parameters parameters = camera.getParameters(); + + if (VERSION.SDK_INT >= 14) { + parameters.setRecordingHint(true); + final List focusModes = parameters.getSupportedFocusModes(); + if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } else if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { + parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); + } + } + + displayOrientation = CameraUtils.getCameraDisplayOrientation(getActivity(), getCameraInfo()); + camera.setDisplayOrientation(displayOrientation); + camera.setParameters(parameters); + enqueueTask(new PostInitializationTask() { + @Override + protected Void onRunBackground() { + try { + camera.setPreviewDisplay(surface.getHolder()); + startPreview(parameters); + } catch (Exception e) { + Log.w(TAG, "couldn't set preview display", e); + } + return null; + } + }); + } + + private void startPreview(final @NonNull Parameters parameters) { + if (this.camera.isPresent()) { + try { + final Camera camera = this.camera.get(); + final Size preferredPreviewSize = getPreferredPreviewSize(parameters); + + if (preferredPreviewSize != null && !parameters.getPreviewSize().equals(preferredPreviewSize)) { + Log.i(TAG, "starting preview with size " + preferredPreviewSize.width + "x" + preferredPreviewSize.height); + if (state == State.ACTIVE) stopPreview(); + previewSize = preferredPreviewSize; + parameters.setPreviewSize(preferredPreviewSize.width, preferredPreviewSize.height); + camera.setParameters(parameters); + } else { + previewSize = parameters.getPreviewSize(); + } + long previewStartMillis = System.currentTimeMillis(); + camera.startPreview(); + Log.i(TAG, "camera.startPreview() -> " + (System.currentTimeMillis() - previewStartMillis) + "ms"); + state = State.ACTIVE; + Util.runOnMain(new Runnable() { + @Override + public void run() { + requestLayout(); + for (CameraViewListener listener : listeners) { + listener.onCameraStart(); + } + } + }); + } catch (Exception e) { + Log.w(TAG, e); + } + } + } + + private void stopPreview() { + if (camera.isPresent()) { + try { + camera.get().stopPreview(); + state = State.RESUMED; + } catch (Exception e) { + Log.w(TAG, e); + } + } + } + + + private Size getPreferredPreviewSize(@NonNull Parameters parameters) { + return CameraUtils.getPreferredPreviewSize(displayOrientation, + getMeasuredWidth(), + getMeasuredHeight(), + parameters); + } + + private int getCameraPictureOrientation() { + if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + outputOrientation = getCameraPictureRotation(getActivity().getWindowManager() + .getDefaultDisplay() + .getOrientation()); + } else if (getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT) { + outputOrientation = (360 - displayOrientation) % 360; + } else { + outputOrientation = displayOrientation; + } + + return outputOrientation; + } + + // https://github.com/signalapp/Signal-Android/issues/4715 + private boolean isTroublemaker() { + return getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT && + "JWR66Y".equals(Build.DISPLAY) && + "yakju".equals(Build.PRODUCT); + } + + private @NonNull CameraInfo getCameraInfo() { + final CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(cameraId, info); + return info; + } + + // XXX this sucks + private Activity getActivity() { + return (Activity)getContext(); + } + + public int getCameraPictureRotation(int orientation) { + final CameraInfo info = getCameraInfo(); + final int rotation; + + orientation = (orientation + 45) / 90 * 90; + + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + rotation = (info.orientation - orientation + 360) % 360; + } else { + rotation = (info.orientation + orientation) % 360; + } + + return rotation; + } + + private class OnOrientationChange extends OrientationEventListener { + public OnOrientationChange(Context context) { + super(context); + disable(); + } + + @Override + public void onOrientationChanged(int orientation) { + if (camera.isPresent() && orientation != ORIENTATION_UNKNOWN) { + int newOutputOrientation = getCameraPictureRotation(orientation); + + if (newOutputOrientation != outputOrientation) { + outputOrientation = newOutputOrientation; + + Camera.Parameters params = camera.get().getParameters(); + + params.setRotation(outputOrientation); + + try { + camera.get().setParameters(params); + } + catch (Exception e) { + Log.e(TAG, "Exception updating camera parameters in orientation change", e); + } + } + } + } + } + + public void takePicture(final Rect previewRect) { + if (!camera.isPresent() || camera.get().getParameters() == null) { + Log.w(TAG, "camera not in capture-ready state"); + return; + } + + camera.get().setOneShotPreviewCallback(new Camera.PreviewCallback() { + @Override + public void onPreviewFrame(byte[] data, final Camera camera) { + final int rotation = getCameraPictureOrientation(); + final Size previewSize = camera.getParameters().getPreviewSize(); + final Rect croppingRect = getCroppedRect(previewSize, previewRect, rotation); + + Log.i(TAG, "previewSize: " + previewSize.width + "x" + previewSize.height); + Log.i(TAG, "data bytes: " + data.length); + Log.i(TAG, "previewFormat: " + camera.getParameters().getPreviewFormat()); + Log.i(TAG, "croppingRect: " + croppingRect.toString()); + Log.i(TAG, "rotation: " + rotation); + new CaptureTask(previewSize, rotation, croppingRect).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data); + } + }); + } + + private Rect getCroppedRect(Size cameraPreviewSize, Rect visibleRect, int rotation) { + final int previewWidth = cameraPreviewSize.width; + final int previewHeight = cameraPreviewSize.height; + + if (rotation % 180 > 0) rotateRect(visibleRect); + + float scale = (float) previewWidth / visibleRect.width(); + if (visibleRect.height() * scale > previewHeight) { + scale = (float) previewHeight / visibleRect.height(); + } + final float newWidth = visibleRect.width() * scale; + final float newHeight = visibleRect.height() * scale; + final float centerX = (VERSION.SDK_INT < 14 || isTroublemaker()) ? previewWidth - newWidth / 2 : previewWidth / 2; + final float centerY = previewHeight / 2; + + visibleRect.set((int) (centerX - newWidth / 2), + (int) (centerY - newHeight / 2), + (int) (centerX + newWidth / 2), + (int) (centerY + newHeight / 2)); + + if (rotation % 180 > 0) rotateRect(visibleRect); + return visibleRect; + } + + @SuppressWarnings("SuspiciousNameCombination") + private void rotateRect(Rect rect) { + rect.set(rect.top, rect.left, rect.bottom, rect.right); + } + + private void enqueueTask(SerialAsyncTask job) { + AsyncTask.SERIAL_EXECUTOR.execute(job); + } + + public static abstract class SerialAsyncTask implements Runnable { + + @Override + public final void run() { + if (!onWait()) { + Log.w(TAG, "skipping task, preconditions not met in onWait()"); + return; + } + + Util.runOnMainSync(this::onPreMain); + final Result result = onRunBackground(); + Util.runOnMainSync(() -> onPostMain(result)); + } + + protected boolean onWait() { return true; } + protected void onPreMain() {} + protected Result onRunBackground() { return null; } + protected void onPostMain(Result result) {} + } + + private abstract class PostInitializationTask extends SerialAsyncTask { + @Override protected boolean onWait() { + synchronized (CameraView.this) { + if (!camera.isPresent()) { + return false; + } + while (getMeasuredHeight() <= 0 || getMeasuredWidth() <= 0 || !surface.isReady()) { + Log.i(TAG, String.format("waiting. surface ready? %s", surface.isReady())); + Util.wait(CameraView.this, 0); + } + return true; + } + } + } + + private class CaptureTask extends AsyncTask { + private final Size previewSize; + private final int rotation; + private final Rect croppingRect; + + public CaptureTask(Size previewSize, int rotation, Rect croppingRect) { + this.previewSize = previewSize; + this.rotation = rotation; + this.croppingRect = croppingRect; + } + + @Override + protected byte[] doInBackground(byte[]... params) { + final byte[] data = params[0]; + try { + return BitmapUtil.createFromNV21(data, + previewSize.width, + previewSize.height, + rotation, + croppingRect, + cameraId == CameraInfo.CAMERA_FACING_FRONT); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + protected void onPostExecute(byte[] imageBytes) { + if (imageBytes != null) { + for (CameraViewListener listener : listeners) { + listener.onImageCapture(imageBytes); + } + } + } + } + + private static class PreconditionsNotMetException extends Exception {} + + public interface CameraViewListener { + void onImageCapture(@NonNull final byte[] imageBytes); + void onCameraFail(); + void onCameraStart(); + void onCameraStop(); + } + + public interface PreviewCallback { + void onPreviewFrame(@NonNull PreviewFrame frame); + } + + public static class PreviewFrame { + private final @NonNull byte[] data; + private final int width; + private final int height; + private final int orientation; + + private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) { + this.data = data; + this.width = width; + this.height = height; + this.orientation = orientation; + } + + public @NonNull byte[] getData() { + return data; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getOrientation() { + return orientation; + } + } + + private enum State { + PAUSED, RESUMED, ACTIVE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AnimatingImageSpan.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AnimatingImageSpan.java new file mode 100644 index 00000000..d75af0ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AnimatingImageSpan.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Drawable.Callback; +import android.text.style.ImageSpan; + +public class AnimatingImageSpan extends ImageSpan { + public AnimatingImageSpan(Drawable drawable, Callback callback) { + super(drawable, ALIGN_BOTTOM); + drawable.setCallback(callback); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AsciiEmojiView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AsciiEmojiView.java new file mode 100644 index 00000000..81f84b7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/AsciiEmojiView.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +public class AsciiEmojiView extends View { + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + + private String emoji; + + public AsciiEmojiView(Context context) { + super(context); + } + + public AsciiEmojiView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public void setEmoji(String emoji) { + this.emoji = emoji; + } + + @Override + protected void onDraw(Canvas canvas) { + if (TextUtils.isEmpty(emoji)) { + return; + } + + float targetFontSize = 0.75f * getHeight() - getPaddingTop() - getPaddingBottom(); + + paint.setTextSize(targetFontSize); + paint.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_primary)); + paint.setTextAlign(Paint.Align.CENTER); + + int xPos = (getWidth() / 2); + int yPos = (int) ((getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)); + + float overflow = paint.measureText(emoji) / (getWidth() - getPaddingLeft() - getPaddingRight()); + if (overflow > 1f) { + paint.setTextSize(targetFontSize / overflow); + yPos = (int) ((getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)); + } + canvas.drawText(emoji, xPos, yPos, paint); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + //noinspection SuspiciousNameCombination + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java new file mode 100644 index 00000000..28c4e9f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.components.emoji; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedList; +import java.util.List; + +public class CompositeEmojiPageModel implements EmojiPageModel { + @AttrRes private final int iconAttr; + @NonNull private final EmojiPageModel[] models; + + public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull EmojiPageModel... models) { + this.iconAttr = iconAttr; + this.models = models; + } + + public int getIconAttr() { + return iconAttr; + } + + @Override + public @NonNull List getEmoji() { + List emojis = new LinkedList<>(); + for (EmojiPageModel model : models) { + emojis.addAll(model.getEmoji()); + } + return emojis; + } + + @Override + public @NonNull List getDisplayEmoji() { + List emojis = new LinkedList<>(); + for (EmojiPageModel model : models) { + emojis.addAll(model.getDisplayEmoji()); + } + return emojis; + } + + @Override + public boolean hasSpriteMap() { + return false; + } + + @Override + public @Nullable String getSprite() { + return null; + } + + @Override + public boolean isDynamic() { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java new file mode 100644 index 00000000..52d56cb4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.components.emoji; + +import java.util.Arrays; +import java.util.List; + +public class Emoji { + + private final List variations; + + public Emoji(String... variations) { + this.variations = Arrays.asList(variations); + } + + public String getValue() { + return variations.get(0); + } + + public List getVariations() { + return variations; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java new file mode 100644 index 00000000..617c830e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.text.InputFilter; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + + +public class EmojiEditText extends AppCompatEditText { + private static final String TAG = EmojiEditText.class.getSimpleName(); + + public EmojiEditText(Context context) { + this(context, null); + } + + public EmojiEditText(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.editTextStyle); + } + + public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); + boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); + a.recycle(); + + if (forceCustom || !TextSecurePreferences.isSystemEmojiPreferred(getContext())) { + setFilters(appendEmojiFilter(this.getFilters())); + } + } + + public void insertEmoji(String emoji) { + final int start = getSelectionStart(); + final int end = getSelectionEnd(); + + getText().replace(Math.min(start, end), Math.max(start, end), emoji); + setSelection(start + emoji.length()); + } + + @Override + public void invalidateDrawable(@NonNull Drawable drawable) { + if (drawable instanceof EmojiDrawable) invalidate(); + else super.invalidateDrawable(drawable); + } + + private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) { + InputFilter[] result; + + if (originalFilters != null) { + result = new InputFilter[originalFilters.length + 1]; + System.arraycopy(originalFilters, 0, result, 1, originalFilters.length); + } else { + result = new InputFilter[1]; + } + + result[0] = new EmojiFilter(this); + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java new file mode 100644 index 00000000..2b32d5e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.text.InputFilter; +import android.text.Spannable; +import android.text.Spanned; +import android.text.TextUtils; +import android.widget.TextView; + +public class EmojiFilter implements InputFilter { + private TextView view; + + public EmojiFilter(TextView view) { + this.view = view; + } + + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) + { + char[] v = new char[end - start]; + TextUtils.getChars(source, start, end, v, 0); + + Spannable emojified = EmojiProvider.getInstance(view.getContext()).emojify(new String(v), view); + + if (source instanceof Spanned && emojified != null) { + TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0); + } + + return emojified; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java new file mode 100644 index 00000000..c953b3d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +public class EmojiImageView extends AppCompatImageView { + public EmojiImageView(Context context) { + super(context); + } + + public EmojiImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setImageEmoji(CharSequence emoji) { + setImageDrawable(EmojiProvider.getInstance(getContext()).getEmojiDrawable(emoji)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java new file mode 100644 index 00000000..e1c22d2c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java @@ -0,0 +1,176 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.PagerAdapter; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.ResUtil; + +import java.util.LinkedList; +import java.util.List; + +/** + * A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}. + */ +public class EmojiKeyboardProvider implements MediaKeyboardProvider, + MediaKeyboardProvider.TabIconProvider, + MediaKeyboardProvider.BackspaceObserver, + VariationSelectorListener +{ + private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + + public static final String RECENT_STORAGE_KEY = "pref_recent_emoji2"; + + private final Context context; + private final List models; + private final RecentEmojiPageModel recentModel; + private final EmojiPagerAdapter emojiPagerAdapter; + private final EmojiEventListener emojiEventListener; + + private Controller controller; + private int currentPosition; + + public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) { + this.context = context; + this.emojiEventListener = emojiEventListener; + this.models = new LinkedList<>(); + this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY); + this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() { + @Override + public void onEmojiSelected(String emoji) { + recentModel.onCodePointSelected(emoji); + SignalStore.emojiValues().setPreferredVariation(emoji); + + if (emojiEventListener != null) { + emojiEventListener.onEmojiSelected(emoji); + } + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + if (emojiEventListener != null) { + emojiEventListener.onKeyEvent(keyEvent); + } + } + }, this); + + models.add(recentModel); + models.addAll(EmojiPages.DISPLAY_PAGES); + + currentPosition = recentModel.getEmoji().size() > 0 ? 0 : 1; + } + + @Override + public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) { + presenter.present(this, emojiPagerAdapter, this, this, null, null, currentPosition); + } + + @Override + public void setCurrentPosition(int currentPosition) { + this.currentPosition = currentPosition; + } + + @Override + public void setController(@Nullable Controller controller) { + this.controller = controller; + } + + @Override + public int getProviderIconView(boolean selected) { + if (selected) { + return R.layout.emoji_keyboard_icon_selected; + } else { + return R.layout.emoji_keyboard_icon; + } + } + + @Override + public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) { + Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr()); + imageView.setImageDrawable(drawable); + } + + @Override + public void onBackspaceClicked() { + if (emojiEventListener != null) { + emojiEventListener.onKeyEvent(DELETE_KEY_EVENT); + } + } + + @Override + public void onVariationSelectorStateChanged(boolean open) { + if (controller != null) { + controller.setViewPagerEnabled(!open); + } + } + + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof EmojiKeyboardProvider; + } + + private static class EmojiPagerAdapter extends PagerAdapter { + private Context context; + private List pages; + private EmojiEventListener emojiSelectionListener; + private VariationSelectorListener variationSelectorListener; + + public EmojiPagerAdapter(@NonNull Context context, + @NonNull List pages, + @NonNull EmojiEventListener emojiSelectionListener, + @NonNull VariationSelectorListener variationSelectorListener) + { + super(); + this.context = context; + this.pages = pages; + this.emojiSelectionListener = emojiSelectionListener; + this.variationSelectorListener = variationSelectorListener; + } + + @Override + public int getCount() { + return pages.size(); + } + + @Override + public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { + EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true); + page.setModel(pages.get(position)); + container.addView(page); + return page; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + container.removeView((View)object); + } + + @Override + public void setPrimaryItem(ViewGroup container, int position, Object object) { + EmojiPageView current = (EmojiPageView) object; + current.onSelected(); + super.setPrimaryItem(container, position, object); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + } + + public interface EmojiEventListener { + void onEmojiSelected(String emoji); + void onKeyEvent(KeyEvent keyEvent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageModel.java new file mode 100644 index 00000000..4c4d5775 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageModel.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.components.emoji; + +import java.util.List; + +public interface EmojiPageModel { + int getIconAttr(); + List getEmoji(); + List getDisplayEmoji(); + boolean hasSpriteMap(); + String getSprite(); + boolean isDynamic(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java new file mode 100644 index 00000000..fe1fac90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener; +import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener; + +public class EmojiPageView extends FrameLayout implements VariationSelectorListener { + private static final String TAG = EmojiPageView.class.getSimpleName(); + + private EmojiPageModel model; + private EmojiPageViewGridAdapter adapter; + private RecyclerView recyclerView; + private GridLayoutManager layoutManager; + private RecyclerView.OnItemTouchListener scrollDisabler; + private VariationSelectorListener variationSelectorListener; + private EmojiVariationSelectorPopup popup; + + public EmojiPageView(@NonNull Context context, + @NonNull EmojiEventListener emojiSelectionListener, + @NonNull VariationSelectorListener variationSelectorListener, + boolean allowVariations) + { + super(context); + final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true); + + this.variationSelectorListener = variationSelectorListener; + + recyclerView = view.findViewById(R.id.emoji); + layoutManager = new GridLayoutManager(context, 8); + scrollDisabler = new ScrollDisabler(); + popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener); + adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context), + popup, + emojiSelectionListener, + this, + allowVariations); + + recyclerView.setLayoutManager(layoutManager); + recyclerView.setAdapter(adapter); + } + + public void onSelected() { + if (model.isDynamic() && adapter != null) { + adapter.notifyDataSetChanged(); + } + } + + public void setModel(EmojiPageModel model) { + this.model = model; + adapter.setEmoji(model.getDisplayEmoji()); + } + + @Override + protected void onVisibilityChanged(@NonNull View changedView, int visibility) { + if (visibility != VISIBLE) { + popup.dismiss(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width); + layoutManager.setSpanCount(Math.max(w / idealWidth, 1)); + } + + @Override + public void onVariationSelectorStateChanged(boolean open) { + if (open) { + recyclerView.addOnItemTouchListener(scrollDisabler); + } else { + post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler)); + } + + if (variationSelectorListener != null) { + variationSelectorListener.onVariationSelectorStateChanged(open); + } + } + + public void setRecyclerNestedScrollingEnabled(boolean enabled) { + recyclerView.setNestedScrollingEnabled(enabled); + } + + private static class ScrollDisabler implements RecyclerView.OnItemTouchListener { + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { + return true; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean b) { } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java new file mode 100644 index 00000000..d8475cb7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener; + +import java.util.ArrayList; +import java.util.List; + +public class EmojiPageViewGridAdapter extends RecyclerView.Adapter implements PopupWindow.OnDismissListener { + + private final List emojiList; + private final EmojiProvider emojiProvider; + private final EmojiVariationSelectorPopup popup; + private final VariationSelectorListener variationSelectorListener; + private final EmojiEventListener emojiEventListener; + private final boolean allowVariations; + + public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider, + @NonNull EmojiVariationSelectorPopup popup, + @NonNull EmojiEventListener emojiEventListener, + @NonNull VariationSelectorListener variationSelectorListener, + boolean allowVariations) + { + this.emojiList = new ArrayList<>(); + this.emojiProvider = emojiProvider; + this.popup = popup; + this.emojiEventListener = emojiEventListener; + this.variationSelectorListener = variationSelectorListener; + this.allowVariations = allowVariations; + + popup.setOnDismissListener(this); + } + + @NonNull + @Override + public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull EmojiViewHolder viewHolder, int i) { + Emoji emoji = emojiList.get(i); + + Drawable drawable = emojiProvider.getEmojiDrawable(emoji.getValue()); + + if (drawable != null) { + viewHolder.textView.setVisibility(View.GONE); + viewHolder.imageView.setVisibility(View.VISIBLE); + + viewHolder.imageView.setImageDrawable(drawable); + } else { + viewHolder.textView.setVisibility(View.VISIBLE); + viewHolder.imageView.setVisibility(View.GONE); + + viewHolder.textView.setEmoji(emoji.getValue()); + } + + viewHolder.itemView.setOnClickListener(v -> { + emojiEventListener.onEmojiSelected(emoji.getValue()); + }); + + if (allowVariations && emoji.getVariations().size() > 1) { + viewHolder.itemView.setOnLongClickListener(v -> { + popup.dismiss(); + popup.setVariations(emoji.getVariations()); + popup.showAsDropDown(viewHolder.itemView, 0, -(2 * viewHolder.itemView.getHeight())); + variationSelectorListener.onVariationSelectorStateChanged(true); + return true; + }); + viewHolder.hintCorner.setVisibility(View.VISIBLE); + } else { + viewHolder.itemView.setOnLongClickListener(null); + viewHolder.hintCorner.setVisibility(View.GONE); + } + } + + @Override + public int getItemCount() { + return emojiList.size(); + } + + public void setEmoji(@NonNull List emojiList) { + this.emojiList.clear(); + this.emojiList.addAll(emojiList); + notifyDataSetChanged(); + } + + @Override + public void onDismiss() { + variationSelectorListener.onVariationSelectorStateChanged(false); + } + + static class EmojiViewHolder extends RecyclerView.ViewHolder { + + private final ImageView imageView; + private final AsciiEmojiView textView; + private final ImageView hintCorner; + + public EmojiViewHolder(@NonNull View itemView) { + super(itemView); + this.imageView = itemView.findViewById(R.id.emoji_image); + this.textView = itemView.findViewById(R.id.emoji_text); + this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint); + } + } + + public interface VariationSelectorListener { + void onVariationSelectorStateChanged(boolean open); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java new file mode 100644 index 00000000..cd8ff723 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java @@ -0,0 +1,310 @@ +package org.thoughtcrime.securesms.components.emoji; + +import org.thoughtcrime.securesms.R; +import org.whispersystems.libsignal.util.Pair; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +/** + * IMPORTANT: The code in this class is generated by a script! Do not edit by hand! + */ +class EmojiPages { + private static final EmojiPageModel PAGE_PLACES = new StaticEmojiPageModel(R.attr.emoji_category_places, new Emoji[] { + new Emoji("\ud83c\udf0d"), new Emoji("\ud83c\udf0e"), new Emoji("\ud83c\udf0f"), new Emoji("\ud83c\udf10"), new Emoji("\ud83d\uddfa\ufe0f"), new Emoji("\ud83d\uddfe"), new Emoji("\ud83e\udded"), new Emoji("\ud83c\udfd4\ufe0f"), new Emoji("\u26f0\ufe0f"), new Emoji("\ud83c\udf0b"), new Emoji("\ud83d\uddfb"), new Emoji("\ud83c\udfd5\ufe0f"), new Emoji("\ud83c\udfd6\ufe0f"), new Emoji("\ud83c\udfdc\ufe0f"), new Emoji("\ud83c\udfdd\ufe0f"), new Emoji("\ud83c\udfde\ufe0f"), new Emoji("\ud83c\udfdf\ufe0f"), new Emoji("\ud83c\udfdb\ufe0f"), new Emoji("\ud83c\udfd7\ufe0f"), new Emoji("\ud83e\uddf1"), new Emoji("\ud83e\udea8"), new Emoji("\ud83e\udeb5"), new Emoji("\ud83d\uded6"), new Emoji("\ud83c\udfd8\ufe0f"), new Emoji("\ud83c\udfda\ufe0f"), new Emoji("\ud83c\udfe0"), new Emoji("\ud83c\udfe1"), new Emoji("\ud83c\udfe2"), new Emoji("\ud83c\udfe3"), new Emoji("\ud83c\udfe4"), new Emoji("\ud83c\udfe5"), new Emoji("\ud83c\udfe6"), new Emoji("\ud83c\udfe8"), new Emoji("\ud83c\udfe9"), new Emoji("\ud83c\udfea"), new Emoji("\ud83c\udfeb"), new Emoji("\ud83c\udfec"), new Emoji("\ud83c\udfed"), new Emoji("\ud83c\udfef"), new Emoji("\ud83c\udff0"), new Emoji("\ud83d\udc92"), new Emoji("\ud83d\uddfc"), new Emoji("\ud83d\uddfd"), new Emoji("\u26ea"), new Emoji("\ud83d\udd4c"), new Emoji("\ud83d\uded5"), new Emoji("\ud83d\udd4d"), new Emoji("\u26e9\ufe0f"), new Emoji("\ud83d\udd4b"), new Emoji("\u26f2"), new Emoji("\u26fa"), new Emoji("\ud83c\udf01"), new Emoji("\ud83c\udf03"), new Emoji("\ud83c\udfd9\ufe0f"), new Emoji("\ud83c\udf04"), new Emoji("\ud83c\udf05"), new Emoji("\ud83c\udf06"), new Emoji("\ud83c\udf07"), new Emoji("\ud83c\udf09"), new Emoji("\u2668\ufe0f"), new Emoji("\ud83c\udfa0"), new Emoji("\ud83c\udfa1"), new Emoji("\ud83c\udfa2"), new Emoji("\ud83d\udc88"), new Emoji("\ud83c\udfaa"), new Emoji("\ud83d\ude82"), new Emoji("\ud83d\ude83"), new Emoji("\ud83d\ude84"), new Emoji("\ud83d\ude85"), new Emoji("\ud83d\ude86"), new Emoji("\ud83d\ude87"), new Emoji("\ud83d\ude88"), new Emoji("\ud83d\ude89"), new Emoji("\ud83d\ude8a"), new Emoji("\ud83d\ude9d"), new Emoji("\ud83d\ude9e"), new Emoji("\ud83d\ude8b"), new Emoji("\ud83d\ude8c"), new Emoji("\ud83d\ude8d"), new Emoji("\ud83d\ude8e"), new Emoji("\ud83d\ude90"), new Emoji("\ud83d\ude91"), new Emoji("\ud83d\ude92"), new Emoji("\ud83d\ude93"), new Emoji("\ud83d\ude94"), new Emoji("\ud83d\ude95"), new Emoji("\ud83d\ude96"), new Emoji("\ud83d\ude97"), new Emoji("\ud83d\ude98"), new Emoji("\ud83d\ude99"), new Emoji("\ud83d\udefb"), new Emoji("\ud83d\ude9a"), new Emoji("\ud83d\ude9b"), new Emoji("\ud83d\ude9c"), new Emoji("\ud83c\udfce\ufe0f"), new Emoji("\ud83c\udfcd\ufe0f"), new Emoji("\ud83d\udef5"), new Emoji("\ud83e\uddbd"), new Emoji("\ud83e\uddbc"), new Emoji("\ud83d\udefa"), new Emoji("\ud83d\udeb2"), new Emoji("\ud83d\udef4"), new Emoji("\ud83d\udef9"), new Emoji("\ud83d\udefc"), new Emoji("\ud83d\ude8f"), new Emoji("\ud83d\udee3\ufe0f"), new Emoji("\ud83d\udee4\ufe0f"), new Emoji("\ud83d\udee2\ufe0f"), new Emoji("\u26fd"), new Emoji("\ud83d\udea8"), new Emoji("\ud83d\udea5"), new Emoji("\ud83d\udea6"), new Emoji("\ud83d\uded1"), new Emoji("\ud83d\udea7"), new Emoji("\u2693"), new Emoji("\u26f5"), new Emoji("\ud83d\udef6"), new Emoji("\ud83d\udea4"), new Emoji("\ud83d\udef3\ufe0f"), new Emoji("\u26f4\ufe0f"), new Emoji("\ud83d\udee5\ufe0f"), new Emoji("\ud83d\udea2"), new Emoji("\u2708\ufe0f"), new Emoji("\ud83d\udee9\ufe0f"), new Emoji("\ud83d\udeeb"), new Emoji("\ud83d\udeec"), new Emoji("\ud83e\ude82"), new Emoji("\ud83d\udcba"), new Emoji("\ud83d\ude81"), new Emoji("\ud83d\ude9f"), new Emoji("\ud83d\udea0"), new Emoji("\ud83d\udea1"), new Emoji("\ud83d\udef0\ufe0f"), new Emoji("\ud83d\ude80"), new Emoji("\ud83d\udef8"), new Emoji("\ud83d\udece\ufe0f"), new Emoji("\ud83e\uddf3"), new Emoji("\u231b"), new Emoji("\u23f3"), new Emoji("\u231a"), new Emoji("\u23f0"), new Emoji("\u23f1\ufe0f"), new Emoji("\u23f2\ufe0f"), new Emoji("\ud83d\udd70\ufe0f"), new Emoji("\ud83d\udd5b"), new Emoji("\ud83d\udd67"), new Emoji("\ud83d\udd50"), new Emoji("\ud83d\udd5c"), new Emoji("\ud83d\udd51"), new Emoji("\ud83d\udd5d"), new Emoji("\ud83d\udd52"), new Emoji("\ud83d\udd5e"), new Emoji("\ud83d\udd53"), new Emoji("\ud83d\udd5f"), new Emoji("\ud83d\udd54"), new Emoji("\ud83d\udd60"), new Emoji("\ud83d\udd55"), new Emoji("\ud83d\udd61"), new Emoji("\ud83d\udd56"), new Emoji("\ud83d\udd62"), new Emoji("\ud83d\udd57"), new Emoji("\ud83d\udd63"), new Emoji("\ud83d\udd58"), new Emoji("\ud83d\udd64"), new Emoji("\ud83d\udd59"), new Emoji("\ud83d\udd65"), new Emoji("\ud83d\udd5a"), new Emoji("\ud83d\udd66"), new Emoji("\ud83c\udf11"), new Emoji("\ud83c\udf12"), new Emoji("\ud83c\udf13"), new Emoji("\ud83c\udf14"), new Emoji("\ud83c\udf15"), new Emoji("\ud83c\udf16"), new Emoji("\ud83c\udf17"), new Emoji("\ud83c\udf18"), new Emoji("\ud83c\udf19"), new Emoji("\ud83c\udf1a"), new Emoji("\ud83c\udf1b"), new Emoji("\ud83c\udf1c"), new Emoji("\ud83c\udf21\ufe0f"), new Emoji("\u2600\ufe0f"), new Emoji("\ud83c\udf1d"), new Emoji("\ud83c\udf1e"), new Emoji("\ud83e\ude90"), new Emoji("\u2b50"), new Emoji("\ud83c\udf1f"), new Emoji("\ud83c\udf20"), new Emoji("\ud83c\udf0c"), new Emoji("\u2601\ufe0f"), new Emoji("\u26c5"), new Emoji("\u26c8\ufe0f"), new Emoji("\ud83c\udf24\ufe0f"), new Emoji("\ud83c\udf25\ufe0f"), new Emoji("\ud83c\udf26\ufe0f"), new Emoji("\ud83c\udf27\ufe0f"), new Emoji("\ud83c\udf28\ufe0f"), new Emoji("\ud83c\udf29\ufe0f"), new Emoji("\ud83c\udf2a\ufe0f"), new Emoji("\ud83c\udf2b\ufe0f"), new Emoji("\ud83c\udf2c\ufe0f"), new Emoji("\ud83c\udf00"), new Emoji("\ud83c\udf08"), new Emoji("\ud83c\udf02"), new Emoji("\u2602\ufe0f"), new Emoji("\u2614"), new Emoji("\u26f1\ufe0f"), new Emoji("\u26a1"), new Emoji("\u2744\ufe0f"), new Emoji("\u2603\ufe0f"), new Emoji("\u26c4"), new Emoji("\u2604\ufe0f"), new Emoji("\ud83d\udd25"), new Emoji("\ud83d\udca7"), new Emoji("\ud83c\udf0a"), + }, "emoji/Places.webp"); + + private static final EmojiPageModel PAGE_FOODS = new StaticEmojiPageModel(R.attr.emoji_category_foods, new Emoji[] { + new Emoji("\ud83c\udf47"), new Emoji("\ud83c\udf48"), new Emoji("\ud83c\udf49"), new Emoji("\ud83c\udf4a"), new Emoji("\ud83c\udf4b"), new Emoji("\ud83c\udf4c"), new Emoji("\ud83c\udf4d"), new Emoji("\ud83e\udd6d"), new Emoji("\ud83c\udf4e"), new Emoji("\ud83c\udf4f"), new Emoji("\ud83c\udf50"), new Emoji("\ud83c\udf51"), new Emoji("\ud83c\udf52"), new Emoji("\ud83c\udf53"), new Emoji("\ud83e\uded0"), new Emoji("\ud83e\udd5d"), new Emoji("\ud83c\udf45"), new Emoji("\ud83e\uded2"), new Emoji("\ud83e\udd65"), new Emoji("\ud83e\udd51"), new Emoji("\ud83c\udf46"), new Emoji("\ud83e\udd54"), new Emoji("\ud83e\udd55"), new Emoji("\ud83c\udf3d"), new Emoji("\ud83c\udf36\ufe0f"), new Emoji("\ud83e\uded1"), new Emoji("\ud83e\udd52"), new Emoji("\ud83e\udd6c"), new Emoji("\ud83e\udd66"), new Emoji("\ud83e\uddc4"), new Emoji("\ud83e\uddc5"), new Emoji("\ud83c\udf44"), new Emoji("\ud83e\udd5c"), new Emoji("\ud83c\udf30"), new Emoji("\ud83c\udf5e"), new Emoji("\ud83e\udd50"), new Emoji("\ud83e\udd56"), new Emoji("\ud83e\uded3"), new Emoji("\ud83e\udd68"), new Emoji("\ud83e\udd6f"), new Emoji("\ud83e\udd5e"), new Emoji("\ud83e\uddc7"), new Emoji("\ud83e\uddc0"), new Emoji("\ud83c\udf56"), new Emoji("\ud83c\udf57"), new Emoji("\ud83e\udd69"), new Emoji("\ud83e\udd53"), new Emoji("\ud83c\udf54"), new Emoji("\ud83c\udf5f"), new Emoji("\ud83c\udf55"), new Emoji("\ud83c\udf2d"), new Emoji("\ud83e\udd6a"), new Emoji("\ud83c\udf2e"), new Emoji("\ud83c\udf2f"), new Emoji("\ud83e\uded4"), new Emoji("\ud83e\udd59"), new Emoji("\ud83e\uddc6"), new Emoji("\ud83e\udd5a"), new Emoji("\ud83c\udf73"), new Emoji("\ud83e\udd58"), new Emoji("\ud83c\udf72"), new Emoji("\ud83e\uded5"), new Emoji("\ud83e\udd63"), new Emoji("\ud83e\udd57"), new Emoji("\ud83c\udf7f"), new Emoji("\ud83e\uddc8"), new Emoji("\ud83e\uddc2"), new Emoji("\ud83e\udd6b"), new Emoji("\ud83c\udf71"), new Emoji("\ud83c\udf58"), new Emoji("\ud83c\udf59"), new Emoji("\ud83c\udf5a"), new Emoji("\ud83c\udf5b"), new Emoji("\ud83c\udf5c"), new Emoji("\ud83c\udf5d"), new Emoji("\ud83c\udf60"), new Emoji("\ud83c\udf62"), new Emoji("\ud83c\udf63"), new Emoji("\ud83c\udf64"), new Emoji("\ud83c\udf65"), new Emoji("\ud83e\udd6e"), new Emoji("\ud83c\udf61"), new Emoji("\ud83e\udd5f"), new Emoji("\ud83e\udd60"), new Emoji("\ud83e\udd61"), new Emoji("\ud83e\udd80"), new Emoji("\ud83e\udd9e"), new Emoji("\ud83e\udd90"), new Emoji("\ud83e\udd91"), new Emoji("\ud83e\uddaa"), new Emoji("\ud83c\udf66"), new Emoji("\ud83c\udf67"), new Emoji("\ud83c\udf68"), new Emoji("\ud83c\udf69"), new Emoji("\ud83c\udf6a"), new Emoji("\ud83c\udf82"), new Emoji("\ud83c\udf70"), new Emoji("\ud83e\uddc1"), new Emoji("\ud83e\udd67"), new Emoji("\ud83c\udf6b"), new Emoji("\ud83c\udf6c"), new Emoji("\ud83c\udf6d"), new Emoji("\ud83c\udf6e"), new Emoji("\ud83c\udf6f"), new Emoji("\ud83c\udf7c"), new Emoji("\ud83e\udd5b"), new Emoji("\u2615"), new Emoji("\ud83e\uded6"), new Emoji("\ud83c\udf75"), new Emoji("\ud83c\udf76"), new Emoji("\ud83c\udf7e"), new Emoji("\ud83c\udf77"), new Emoji("\ud83c\udf78"), new Emoji("\ud83c\udf79"), new Emoji("\ud83c\udf7a"), new Emoji("\ud83c\udf7b"), new Emoji("\ud83e\udd42"), new Emoji("\ud83e\udd43"), new Emoji("\ud83e\udd64"), new Emoji("\ud83e\uddcb"), new Emoji("\ud83e\uddc3"), new Emoji("\ud83e\uddc9"), new Emoji("\ud83e\uddca"), new Emoji("\ud83e\udd62"), new Emoji("\ud83c\udf7d\ufe0f"), new Emoji("\ud83c\udf74"), new Emoji("\ud83e\udd44"), new Emoji("\ud83d\udd2a"), new Emoji("\ud83c\udffa"), + }, "emoji/Foods.webp"); + + private static final EmojiPageModel PAGE_ACTIVITY = new StaticEmojiPageModel(R.attr.emoji_category_activity, new Emoji[] { + new Emoji("\ud83c\udf83"), new Emoji("\ud83c\udf84"), new Emoji("\ud83c\udf86"), new Emoji("\ud83c\udf87"), new Emoji("\ud83e\udde8"), new Emoji("\u2728"), new Emoji("\ud83c\udf88"), new Emoji("\ud83c\udf89"), new Emoji("\ud83c\udf8a"), new Emoji("\ud83c\udf8b"), new Emoji("\ud83c\udf8d"), new Emoji("\ud83c\udf8e"), new Emoji("\ud83c\udf8f"), new Emoji("\ud83c\udf90"), new Emoji("\ud83c\udf91"), new Emoji("\ud83e\udde7"), new Emoji("\ud83c\udf80"), new Emoji("\ud83c\udf81"), new Emoji("\ud83c\udf97\ufe0f"), new Emoji("\ud83c\udf9f\ufe0f"), new Emoji("\ud83c\udfab"), new Emoji("\ud83c\udf96\ufe0f"), new Emoji("\ud83c\udfc6"), new Emoji("\ud83c\udfc5"), new Emoji("\ud83e\udd47"), new Emoji("\ud83e\udd48"), new Emoji("\ud83e\udd49"), new Emoji("\u26bd"), new Emoji("\u26be"), new Emoji("\ud83e\udd4e"), new Emoji("\ud83c\udfc0"), new Emoji("\ud83c\udfd0"), new Emoji("\ud83c\udfc8"), new Emoji("\ud83c\udfc9"), new Emoji("\ud83c\udfbe"), new Emoji("\ud83e\udd4f"), new Emoji("\ud83c\udfb3"), new Emoji("\ud83c\udfcf"), new Emoji("\ud83c\udfd1"), new Emoji("\ud83c\udfd2"), new Emoji("\ud83e\udd4d"), new Emoji("\ud83c\udfd3"), new Emoji("\ud83c\udff8"), new Emoji("\ud83e\udd4a"), new Emoji("\ud83e\udd4b"), new Emoji("\ud83e\udd45"), new Emoji("\u26f3"), new Emoji("\u26f8\ufe0f"), new Emoji("\ud83c\udfa3"), new Emoji("\ud83e\udd3f"), new Emoji("\ud83c\udfbd"), new Emoji("\ud83c\udfbf"), new Emoji("\ud83d\udef7"), new Emoji("\ud83e\udd4c"), new Emoji("\ud83c\udfaf"), new Emoji("\ud83e\ude80"), new Emoji("\ud83e\ude81"), new Emoji("\ud83c\udfb1"), new Emoji("\ud83d\udd2e"), new Emoji("\ud83e\ude84"), new Emoji("\ud83e\uddff"), new Emoji("\ud83c\udfae"), new Emoji("\ud83d\udd79\ufe0f"), new Emoji("\ud83c\udfb0"), new Emoji("\ud83c\udfb2"), new Emoji("\ud83e\udde9"), new Emoji("\ud83e\uddf8"), new Emoji("\ud83e\ude85"), new Emoji("\ud83e\ude86"), new Emoji("\u2660\ufe0f"), new Emoji("\u2665\ufe0f"), new Emoji("\u2666\ufe0f"), new Emoji("\u2663\ufe0f"), new Emoji("\u265f\ufe0f"), new Emoji("\ud83c\udccf"), new Emoji("\ud83c\udc04"), new Emoji("\ud83c\udfb4"), new Emoji("\ud83c\udfad"), new Emoji("\ud83d\uddbc\ufe0f"), new Emoji("\ud83c\udfa8"), new Emoji("\ud83e\uddf5"), new Emoji("\ud83e\udea1"), new Emoji("\ud83e\uddf6"), new Emoji("\ud83e\udea2"), + }, "emoji/Activity.webp"); + + private static final EmojiPageModel PAGE_FLAGS_0 = new StaticEmojiPageModel(R.attr.emoji_category_flags, new Emoji[] { + new Emoji("\ud83c\udfc1"), new Emoji("\ud83d\udea9"), new Emoji("\ud83c\udf8c"), new Emoji("\ud83c\udff4"), new Emoji("\ud83c\udff3\ufe0f"), new Emoji("\ud83c\udff3\ufe0f\u200d\ud83c\udf08"), new Emoji("\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f"), new Emoji("\ud83c\udff4\u200d\u2620\ufe0f"), new Emoji("\ud83c\udde6\ud83c\udde8"), new Emoji("\ud83c\udde6\ud83c\udde9"), new Emoji("\ud83c\udde6\ud83c\uddea"), new Emoji("\ud83c\udde6\ud83c\uddeb"), new Emoji("\ud83c\udde6\ud83c\uddec"), new Emoji("\ud83c\udde6\ud83c\uddee"), new Emoji("\ud83c\udde6\ud83c\uddf1"), new Emoji("\ud83c\udde6\ud83c\uddf2"), new Emoji("\ud83c\udde6\ud83c\uddf4"), new Emoji("\ud83c\udde6\ud83c\uddf6"), new Emoji("\ud83c\udde6\ud83c\uddf7"), new Emoji("\ud83c\udde6\ud83c\uddf8"), new Emoji("\ud83c\udde6\ud83c\uddf9"), new Emoji("\ud83c\udde6\ud83c\uddfa"), new Emoji("\ud83c\udde6\ud83c\uddfc"), new Emoji("\ud83c\udde6\ud83c\uddfd"), new Emoji("\ud83c\udde6\ud83c\uddff"), new Emoji("\ud83c\udde7\ud83c\udde6"), new Emoji("\ud83c\udde7\ud83c\udde7"), new Emoji("\ud83c\udde7\ud83c\udde9"), new Emoji("\ud83c\udde7\ud83c\uddea"), new Emoji("\ud83c\udde7\ud83c\uddeb"), new Emoji("\ud83c\udde7\ud83c\uddec"), new Emoji("\ud83c\udde7\ud83c\udded"), new Emoji("\ud83c\udde7\ud83c\uddee"), new Emoji("\ud83c\udde7\ud83c\uddef"), new Emoji("\ud83c\udde7\ud83c\uddf1"), new Emoji("\ud83c\udde7\ud83c\uddf2"), new Emoji("\ud83c\udde7\ud83c\uddf3"), new Emoji("\ud83c\udde7\ud83c\uddf4"), new Emoji("\ud83c\udde7\ud83c\uddf6"), new Emoji("\ud83c\udde7\ud83c\uddf7"), new Emoji("\ud83c\udde7\ud83c\uddf8"), new Emoji("\ud83c\udde7\ud83c\uddf9"), new Emoji("\ud83c\udde7\ud83c\uddfb"), new Emoji("\ud83c\udde7\ud83c\uddfc"), new Emoji("\ud83c\udde7\ud83c\uddfe"), new Emoji("\ud83c\udde7\ud83c\uddff"), new Emoji("\ud83c\udde8\ud83c\udde6"), new Emoji("\ud83c\udde8\ud83c\udde8"), new Emoji("\ud83c\udde8\ud83c\udde9"), new Emoji("\ud83c\udde8\ud83c\uddeb"), new Emoji("\ud83c\udde8\ud83c\uddec"), new Emoji("\ud83c\udde8\ud83c\udded"), new Emoji("\ud83c\udde8\ud83c\uddee"), new Emoji("\ud83c\udde8\ud83c\uddf0"), new Emoji("\ud83c\udde8\ud83c\uddf1"), new Emoji("\ud83c\udde8\ud83c\uddf2"), new Emoji("\ud83c\udde8\ud83c\uddf3"), new Emoji("\ud83c\udde8\ud83c\uddf4"), new Emoji("\ud83c\udde8\ud83c\uddf5"), new Emoji("\ud83c\udde8\ud83c\uddf7"), new Emoji("\ud83c\udde8\ud83c\uddfa"), new Emoji("\ud83c\udde8\ud83c\uddfb"), new Emoji("\ud83c\udde8\ud83c\uddfc"), new Emoji("\ud83c\udde8\ud83c\uddfd"), new Emoji("\ud83c\udde8\ud83c\uddfe"), new Emoji("\ud83c\udde8\ud83c\uddff"), new Emoji("\ud83c\udde9\ud83c\uddea"), new Emoji("\ud83c\udde9\ud83c\uddec"), new Emoji("\ud83c\udde9\ud83c\uddef"), new Emoji("\ud83c\udde9\ud83c\uddf0"), new Emoji("\ud83c\udde9\ud83c\uddf2"), new Emoji("\ud83c\udde9\ud83c\uddf4"), new Emoji("\ud83c\udde9\ud83c\uddff"), new Emoji("\ud83c\uddea\ud83c\udde6"), new Emoji("\ud83c\uddea\ud83c\udde8"), new Emoji("\ud83c\uddea\ud83c\uddea"), new Emoji("\ud83c\uddea\ud83c\uddec"), new Emoji("\ud83c\uddea\ud83c\udded"), new Emoji("\ud83c\uddea\ud83c\uddf7"), new Emoji("\ud83c\uddea\ud83c\uddf8"), new Emoji("\ud83c\uddea\ud83c\uddf9"), new Emoji("\ud83c\uddea\ud83c\uddfa"), new Emoji("\ud83c\uddeb\ud83c\uddee"), new Emoji("\ud83c\uddeb\ud83c\uddef"), new Emoji("\ud83c\uddeb\ud83c\uddf0"), new Emoji("\ud83c\uddeb\ud83c\uddf2"), new Emoji("\ud83c\uddeb\ud83c\uddf4"), new Emoji("\ud83c\uddeb\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\udde6"), new Emoji("\ud83c\uddec\ud83c\udde7"), new Emoji("\ud83c\uddec\ud83c\udde9"), new Emoji("\ud83c\uddec\ud83c\uddea"), new Emoji("\ud83c\uddec\ud83c\uddeb"), new Emoji("\ud83c\uddec\ud83c\uddec"), new Emoji("\ud83c\uddec\ud83c\udded"), new Emoji("\ud83c\uddec\ud83c\uddee"), new Emoji("\ud83c\uddec\ud83c\uddf1"), new Emoji("\ud83c\uddec\ud83c\uddf2"), new Emoji("\ud83c\uddec\ud83c\uddf3"), new Emoji("\ud83c\uddec\ud83c\uddf5"), new Emoji("\ud83c\uddec\ud83c\uddf6"), new Emoji("\ud83c\uddec\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\uddf8"), new Emoji("\ud83c\uddec\ud83c\uddf9"), new Emoji("\ud83c\uddec\ud83c\uddfa"), new Emoji("\ud83c\uddec\ud83c\uddfc"), new Emoji("\ud83c\uddec\ud83c\uddfe"), new Emoji("\ud83c\udded\ud83c\uddf0"), new Emoji("\ud83c\udded\ud83c\uddf2"), new Emoji("\ud83c\udded\ud83c\uddf3"), new Emoji("\ud83c\udded\ud83c\uddf7"), new Emoji("\ud83c\udded\ud83c\uddf9"), new Emoji("\ud83c\udded\ud83c\uddfa"), new Emoji("\ud83c\uddee\ud83c\udde8"), new Emoji("\ud83c\uddee\ud83c\udde9"), new Emoji("\ud83c\uddee\ud83c\uddea"), new Emoji("\ud83c\uddee\ud83c\uddf1"), new Emoji("\ud83c\uddee\ud83c\uddf2"), new Emoji("\ud83c\uddee\ud83c\uddf3"), new Emoji("\ud83c\uddee\ud83c\uddf4"), new Emoji("\ud83c\uddee\ud83c\uddf6"), new Emoji("\ud83c\uddee\ud83c\uddf7"), new Emoji("\ud83c\uddee\ud83c\uddf8"), new Emoji("\ud83c\uddee\ud83c\uddf9"), new Emoji("\ud83c\uddef\ud83c\uddea"), new Emoji("\ud83c\uddef\ud83c\uddf2"), new Emoji("\ud83c\uddef\ud83c\uddf4"), new Emoji("\ud83c\uddef\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddea"), new Emoji("\ud83c\uddf0\ud83c\uddec"), new Emoji("\ud83c\uddf0\ud83c\udded"), new Emoji("\ud83c\uddf0\ud83c\uddee"), new Emoji("\ud83c\uddf0\ud83c\uddf2"), new Emoji("\ud83c\uddf0\ud83c\uddf3"), new Emoji("\ud83c\uddf0\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddf7"), new Emoji("\ud83c\uddf0\ud83c\uddfc"), new Emoji("\ud83c\uddf0\ud83c\uddfe"), new Emoji("\ud83c\uddf0\ud83c\uddff"), new Emoji("\ud83c\uddf1\ud83c\udde6"), new Emoji("\ud83c\uddf1\ud83c\udde7"), new Emoji("\ud83c\uddf1\ud83c\udde8"), new Emoji("\ud83c\uddf1\ud83c\uddee"), new Emoji("\ud83c\uddf1\ud83c\uddf0"), new Emoji("\ud83c\uddf1\ud83c\uddf7"), new Emoji("\ud83c\uddf1\ud83c\uddf8"), new Emoji("\ud83c\uddf1\ud83c\uddf9"), new Emoji("\ud83c\uddf1\ud83c\uddfa"), new Emoji("\ud83c\uddf1\ud83c\uddfb"), new Emoji("\ud83c\uddf1\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\udde6"), new Emoji("\ud83c\uddf2\ud83c\udde8"), new Emoji("\ud83c\uddf2\ud83c\udde9"), new Emoji("\ud83c\uddf2\ud83c\uddea"), new Emoji("\ud83c\uddf2\ud83c\uddeb"), new Emoji("\ud83c\uddf2\ud83c\uddec"), new Emoji("\ud83c\uddf2\ud83c\udded"), new Emoji("\ud83c\uddf2\ud83c\uddf0"), new Emoji("\ud83c\uddf2\ud83c\uddf1"), new Emoji("\ud83c\uddf2\ud83c\uddf2"), new Emoji("\ud83c\uddf2\ud83c\uddf3"), new Emoji("\ud83c\uddf2\ud83c\uddf4"), new Emoji("\ud83c\uddf2\ud83c\uddf5"), new Emoji("\ud83c\uddf2\ud83c\uddf6"), new Emoji("\ud83c\uddf2\ud83c\uddf7"), new Emoji("\ud83c\uddf2\ud83c\uddf8"), new Emoji("\ud83c\uddf2\ud83c\uddf9"), new Emoji("\ud83c\uddf2\ud83c\uddfa"), new Emoji("\ud83c\uddf2\ud83c\uddfb"), new Emoji("\ud83c\uddf2\ud83c\uddfc"), new Emoji("\ud83c\uddf2\ud83c\uddfd"), new Emoji("\ud83c\uddf2\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\uddff"), new Emoji("\ud83c\uddf3\ud83c\udde6"), new Emoji("\ud83c\uddf3\ud83c\udde8"), new Emoji("\ud83c\uddf3\ud83c\uddea"), new Emoji("\ud83c\uddf3\ud83c\uddeb"), new Emoji("\ud83c\uddf3\ud83c\uddec"), new Emoji("\ud83c\uddf3\ud83c\uddee"), new Emoji("\ud83c\uddf3\ud83c\uddf1"), new Emoji("\ud83c\uddf3\ud83c\uddf4"), new Emoji("\ud83c\uddf3\ud83c\uddf5"), new Emoji("\ud83c\uddf3\ud83c\uddf7"), new Emoji("\ud83c\uddf3\ud83c\uddfa"), new Emoji("\ud83c\uddf3\ud83c\uddff"), new Emoji("\ud83c\uddf4\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\udde6"), new Emoji("\ud83c\uddf5\ud83c\uddea"), new Emoji("\ud83c\uddf5\ud83c\uddeb"), new Emoji("\ud83c\uddf5\ud83c\uddec"), new Emoji("\ud83c\uddf5\ud83c\udded"), new Emoji("\ud83c\uddf5\ud83c\uddf0"), new Emoji("\ud83c\uddf5\ud83c\uddf1"), new Emoji("\ud83c\uddf5\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\uddf3"), new Emoji("\ud83c\uddf5\ud83c\uddf7"), new Emoji("\ud83c\uddf5\ud83c\uddf8"), new Emoji("\ud83c\uddf5\ud83c\uddf9"), new Emoji("\ud83c\uddf5\ud83c\uddfc"), new Emoji("\ud83c\uddf5\ud83c\uddfe"), new Emoji("\ud83c\uddf6\ud83c\udde6"), new Emoji("\ud83c\uddf7\ud83c\uddea"), new Emoji("\ud83c\uddf7\ud83c\uddf4"), new Emoji("\ud83c\uddf7\ud83c\uddf8"), new Emoji("\ud83c\uddf7\ud83c\uddfa"), new Emoji("\ud83c\uddf7\ud83c\uddfc"), new Emoji("\ud83c\uddf8\ud83c\udde6"), new Emoji("\ud83c\uddf8\ud83c\udde7"), new Emoji("\ud83c\uddf8\ud83c\udde8"), new Emoji("\ud83c\uddf8\ud83c\udde9"), new Emoji("\ud83c\uddf8\ud83c\uddea"), new Emoji("\ud83c\uddf8\ud83c\uddec"), new Emoji("\ud83c\uddf8\ud83c\udded"), new Emoji("\ud83c\uddf8\ud83c\uddee"), new Emoji("\ud83c\uddf8\ud83c\uddef"), new Emoji("\ud83c\uddf8\ud83c\uddf0"), new Emoji("\ud83c\uddf8\ud83c\uddf1"), new Emoji("\ud83c\uddf8\ud83c\uddf2"), new Emoji("\ud83c\uddf8\ud83c\uddf3"), new Emoji("\ud83c\uddf8\ud83c\uddf4"), new Emoji("\ud83c\uddf8\ud83c\uddf7"), new Emoji("\ud83c\uddf8\ud83c\uddf8"), new Emoji("\ud83c\uddf8\ud83c\uddf9"), new Emoji("\ud83c\uddf8\ud83c\uddfb"), new Emoji("\ud83c\uddf8\ud83c\uddfd"), new Emoji("\ud83c\uddf8\ud83c\uddfe"), new Emoji("\ud83c\uddf8\ud83c\uddff"), new Emoji("\ud83c\uddf9\ud83c\udde6"), new Emoji("\ud83c\uddf9\ud83c\udde8"), new Emoji("\ud83c\uddf9\ud83c\udde9"), new Emoji("\ud83c\uddf9\ud83c\uddeb"), new Emoji("\ud83c\uddf9\ud83c\uddec"), new Emoji("\ud83c\uddf9\ud83c\udded"), new Emoji("\ud83c\uddf9\ud83c\uddef"), new Emoji("\ud83c\uddf9\ud83c\uddf0"), new Emoji("\ud83c\uddf9\ud83c\uddf1"), new Emoji("\ud83c\uddf9\ud83c\uddf2"), new Emoji("\ud83c\uddf9\ud83c\uddf3"), new Emoji("\ud83c\uddf9\ud83c\uddf4"), new Emoji("\ud83c\uddf9\ud83c\uddf7"), new Emoji("\ud83c\uddf9\ud83c\uddf9"), new Emoji("\ud83c\uddf9\ud83c\uddfb"), new Emoji("\ud83c\uddf9\ud83c\uddfc"), new Emoji("\ud83c\uddf9\ud83c\uddff"), new Emoji("\ud83c\uddfa\ud83c\udde6"), new Emoji("\ud83c\uddfa\ud83c\uddec"), new Emoji("\ud83c\uddfa\ud83c\uddf2"), new Emoji("\ud83c\uddfa\ud83c\uddf3"), new Emoji("\ud83c\uddfa\ud83c\uddf8"), new Emoji("\ud83c\uddfa\ud83c\uddfe"), new Emoji("\ud83c\uddfa\ud83c\uddff"), new Emoji("\ud83c\uddfb\ud83c\udde6"), new Emoji("\ud83c\uddfb\ud83c\udde8"), new Emoji("\ud83c\uddfb\ud83c\uddea"), new Emoji("\ud83c\uddfb\ud83c\uddec"), new Emoji("\ud83c\uddfb\ud83c\uddee"), + }, "emoji/Flags_0.webp"); + + private static final EmojiPageModel PAGE_FLAGS_1 = new StaticEmojiPageModel(R.attr.emoji_category_flags, new Emoji[] { + new Emoji("\ud83c\uddfb\ud83c\uddf3"), new Emoji("\ud83c\uddfb\ud83c\uddfa"), new Emoji("\ud83c\uddfc\ud83c\uddeb"), new Emoji("\ud83c\uddfc\ud83c\uddf8"), new Emoji("\ud83c\uddfd\ud83c\uddf0"), new Emoji("\ud83c\uddfe\ud83c\uddea"), new Emoji("\ud83c\uddfe\ud83c\uddf9"), new Emoji("\ud83c\uddff\ud83c\udde6"), new Emoji("\ud83c\uddff\ud83c\uddf2"), new Emoji("\ud83c\uddff\ud83c\uddfc"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f"), + }, "emoji/Flags_1.webp"); + + private static final EmojiPageModel PAGE_SYMBOLS = new StaticEmojiPageModel(R.attr.emoji_category_symbols, new Emoji[] { + new Emoji("\ud83c\udfe7"), new Emoji("\ud83d\udeae"), new Emoji("\ud83d\udeb0"), new Emoji("\u267f"), new Emoji("\ud83d\udeb9"), new Emoji("\ud83d\udeba"), new Emoji("\ud83d\udebb"), new Emoji("\ud83d\udebc"), new Emoji("\ud83d\udebe"), new Emoji("\ud83d\udec2"), new Emoji("\ud83d\udec3"), new Emoji("\ud83d\udec4"), new Emoji("\ud83d\udec5"), new Emoji("\u26a0\ufe0f"), new Emoji("\ud83d\udeb8"), new Emoji("\u26d4"), new Emoji("\ud83d\udeab"), new Emoji("\ud83d\udeb3"), new Emoji("\ud83d\udead"), new Emoji("\ud83d\udeaf"), new Emoji("\ud83d\udeb1"), new Emoji("\ud83d\udeb7"), new Emoji("\ud83d\udcf5"), new Emoji("\ud83d\udd1e"), new Emoji("\u2622\ufe0f"), new Emoji("\u2623\ufe0f"), new Emoji("\u2b06\ufe0f"), new Emoji("\u2197\ufe0f"), new Emoji("\u27a1\ufe0f"), new Emoji("\u2198\ufe0f"), new Emoji("\u2b07\ufe0f"), new Emoji("\u2199\ufe0f"), new Emoji("\u2b05\ufe0f"), new Emoji("\u2196\ufe0f"), new Emoji("\u2195\ufe0f"), new Emoji("\u2194\ufe0f"), new Emoji("\u21a9\ufe0f"), new Emoji("\u21aa\ufe0f"), new Emoji("\u2934\ufe0f"), new Emoji("\u2935\ufe0f"), new Emoji("\ud83d\udd03"), new Emoji("\ud83d\udd04"), new Emoji("\ud83d\udd19"), new Emoji("\ud83d\udd1a"), new Emoji("\ud83d\udd1b"), new Emoji("\ud83d\udd1c"), new Emoji("\ud83d\udd1d"), new Emoji("\ud83d\uded0"), new Emoji("\u269b\ufe0f"), new Emoji("\ud83d\udd49\ufe0f"), new Emoji("\u2721\ufe0f"), new Emoji("\u2638\ufe0f"), new Emoji("\u262f\ufe0f"), new Emoji("\u271d\ufe0f"), new Emoji("\u2626\ufe0f"), new Emoji("\u262a\ufe0f"), new Emoji("\u262e\ufe0f"), new Emoji("\ud83d\udd4e"), new Emoji("\ud83d\udd2f"), new Emoji("\u2648"), new Emoji("\u2649"), new Emoji("\u264a"), new Emoji("\u264b"), new Emoji("\u264c"), new Emoji("\u264d"), new Emoji("\u264e"), new Emoji("\u264f"), new Emoji("\u2650"), new Emoji("\u2651"), new Emoji("\u2652"), new Emoji("\u2653"), new Emoji("\u26ce"), new Emoji("\ud83d\udd00"), new Emoji("\ud83d\udd01"), new Emoji("\ud83d\udd02"), new Emoji("\u25b6\ufe0f"), new Emoji("\u23e9"), new Emoji("\u23ed\ufe0f"), new Emoji("\u23ef\ufe0f"), new Emoji("\u25c0\ufe0f"), new Emoji("\u23ea"), new Emoji("\u23ee\ufe0f"), new Emoji("\ud83d\udd3c"), new Emoji("\u23eb"), new Emoji("\ud83d\udd3d"), new Emoji("\u23ec"), new Emoji("\u23f8\ufe0f"), new Emoji("\u23f9\ufe0f"), new Emoji("\u23fa\ufe0f"), new Emoji("\u23cf\ufe0f"), new Emoji("\ud83c\udfa6"), new Emoji("\ud83d\udd05"), new Emoji("\ud83d\udd06"), new Emoji("\ud83d\udcf6"), new Emoji("\ud83d\udcf3"), new Emoji("\ud83d\udcf4"), new Emoji("\u26a7\ufe0f"), new Emoji("\u2716\ufe0f"), new Emoji("\u2795"), new Emoji("\u2796"), new Emoji("\u2797"), new Emoji("\u267e\ufe0f"), new Emoji("\u203c\ufe0f"), new Emoji("\u2049\ufe0f"), new Emoji("\u2753"), new Emoji("\u2754"), new Emoji("\u2755"), new Emoji("\u2757"), new Emoji("\u3030\ufe0f"), new Emoji("\ud83d\udcb1"), new Emoji("\ud83d\udcb2"), new Emoji("\u267b\ufe0f"), new Emoji("\u269c\ufe0f"), new Emoji("\ud83d\udd31"), new Emoji("\ud83d\udcdb"), new Emoji("\ud83d\udd30"), new Emoji("\u2b55"), new Emoji("\u2705"), new Emoji("\u2611\ufe0f"), new Emoji("\u2714\ufe0f"), new Emoji("\u274c"), new Emoji("\u274e"), new Emoji("\u27b0"), new Emoji("\u27bf"), new Emoji("\u303d\ufe0f"), new Emoji("\u2733\ufe0f"), new Emoji("\u2734\ufe0f"), new Emoji("\u2747\ufe0f"), new Emoji("\u00a9\ufe0f"), new Emoji("\u00ae\ufe0f"), new Emoji("\u2122\ufe0f"), new Emoji("\u0023\ufe0f\u20e3"), new Emoji("\u002a\ufe0f\u20e3"), new Emoji("\u0030\ufe0f\u20e3"), new Emoji("\u0031\ufe0f\u20e3"), new Emoji("\u0032\ufe0f\u20e3"), new Emoji("\u0033\ufe0f\u20e3"), new Emoji("\u0034\ufe0f\u20e3"), new Emoji("\u0035\ufe0f\u20e3"), new Emoji("\u0036\ufe0f\u20e3"), new Emoji("\u0037\ufe0f\u20e3"), new Emoji("\u0038\ufe0f\u20e3"), new Emoji("\u0039\ufe0f\u20e3"), new Emoji("\ud83d\udd1f"), new Emoji("\ud83d\udd20"), new Emoji("\ud83d\udd21"), new Emoji("\ud83d\udd22"), new Emoji("\ud83d\udd23"), new Emoji("\ud83d\udd24"), new Emoji("\ud83c\udd70\ufe0f"), new Emoji("\ud83c\udd8e"), new Emoji("\ud83c\udd71\ufe0f"), new Emoji("\ud83c\udd91"), new Emoji("\ud83c\udd92"), new Emoji("\ud83c\udd93"), new Emoji("\u2139\ufe0f"), new Emoji("\ud83c\udd94"), new Emoji("\u24c2\ufe0f"), new Emoji("\ud83c\udd95"), new Emoji("\ud83c\udd96"), new Emoji("\ud83c\udd7e\ufe0f"), new Emoji("\ud83c\udd97"), new Emoji("\ud83c\udd7f\ufe0f"), new Emoji("\ud83c\udd98"), new Emoji("\ud83c\udd99"), new Emoji("\ud83c\udd9a"), new Emoji("\ud83c\ude01"), new Emoji("\ud83c\ude02\ufe0f"), new Emoji("\ud83c\ude37\ufe0f"), new Emoji("\ud83c\ude36"), new Emoji("\ud83c\ude2f"), new Emoji("\ud83c\ude50"), new Emoji("\ud83c\ude39"), new Emoji("\ud83c\ude1a"), new Emoji("\ud83c\ude32"), new Emoji("\ud83c\ude51"), new Emoji("\ud83c\ude38"), new Emoji("\ud83c\ude34"), new Emoji("\ud83c\ude33"), new Emoji("\u3297\ufe0f"), new Emoji("\u3299\ufe0f"), new Emoji("\ud83c\ude3a"), new Emoji("\ud83c\ude35"), new Emoji("\ud83d\udd34"), new Emoji("\ud83d\udfe0"), new Emoji("\ud83d\udfe1"), new Emoji("\ud83d\udfe2"), new Emoji("\ud83d\udd35"), new Emoji("\ud83d\udfe3"), new Emoji("\ud83d\udfe4"), new Emoji("\u26ab"), new Emoji("\u26aa"), new Emoji("\ud83d\udfe5"), new Emoji("\ud83d\udfe7"), new Emoji("\ud83d\udfe8"), new Emoji("\ud83d\udfe9"), new Emoji("\ud83d\udfe6"), new Emoji("\ud83d\udfea"), new Emoji("\ud83d\udfeb"), new Emoji("\u2b1b"), new Emoji("\u2b1c"), new Emoji("\u25fc\ufe0f"), new Emoji("\u25fb\ufe0f"), new Emoji("\u25fe"), new Emoji("\u25fd"), new Emoji("\u25aa\ufe0f"), new Emoji("\u25ab\ufe0f"), new Emoji("\ud83d\udd36"), new Emoji("\ud83d\udd37"), new Emoji("\ud83d\udd38"), new Emoji("\ud83d\udd39"), new Emoji("\ud83d\udd3a"), new Emoji("\ud83d\udd3b"), new Emoji("\ud83d\udca0"), new Emoji("\ud83d\udd18"), new Emoji("\ud83d\udd33"), new Emoji("\ud83d\udd32"), + }, "emoji/Symbols.webp"); + + private static final EmojiPageModel PAGE_NATURE = new StaticEmojiPageModel(R.attr.emoji_category_nature, new Emoji[] { + new Emoji("\ud83d\udc35"), new Emoji("\ud83d\udc12"), new Emoji("\ud83e\udd8d"), new Emoji("\ud83e\udda7"), new Emoji("\ud83d\udc36"), new Emoji("\ud83d\udc15"), new Emoji("\ud83e\uddae"), new Emoji("\ud83d\udc15\u200d\ud83e\uddba"), new Emoji("\ud83d\udc29"), new Emoji("\ud83d\udc3a"), new Emoji("\ud83e\udd8a"), new Emoji("\ud83e\udd9d"), new Emoji("\ud83d\udc31"), new Emoji("\ud83d\udc08"), new Emoji("\ud83d\udc08\u200d\u2b1b"), new Emoji("\ud83e\udd81"), new Emoji("\ud83d\udc2f"), new Emoji("\ud83d\udc05"), new Emoji("\ud83d\udc06"), new Emoji("\ud83d\udc34"), new Emoji("\ud83d\udc0e"), new Emoji("\ud83e\udd84"), new Emoji("\ud83e\udd93"), new Emoji("\ud83e\udd8c"), new Emoji("\ud83e\uddac"), new Emoji("\ud83d\udc2e"), new Emoji("\ud83d\udc02"), new Emoji("\ud83d\udc03"), new Emoji("\ud83d\udc04"), new Emoji("\ud83d\udc37"), new Emoji("\ud83d\udc16"), new Emoji("\ud83d\udc17"), new Emoji("\ud83d\udc3d"), new Emoji("\ud83d\udc0f"), new Emoji("\ud83d\udc11"), new Emoji("\ud83d\udc10"), new Emoji("\ud83d\udc2a"), new Emoji("\ud83d\udc2b"), new Emoji("\ud83e\udd99"), new Emoji("\ud83e\udd92"), new Emoji("\ud83d\udc18"), new Emoji("\ud83e\udda3"), new Emoji("\ud83e\udd8f"), new Emoji("\ud83e\udd9b"), new Emoji("\ud83d\udc2d"), new Emoji("\ud83d\udc01"), new Emoji("\ud83d\udc00"), new Emoji("\ud83d\udc39"), new Emoji("\ud83d\udc30"), new Emoji("\ud83d\udc07"), new Emoji("\ud83d\udc3f\ufe0f"), new Emoji("\ud83e\uddab"), new Emoji("\ud83e\udd94"), new Emoji("\ud83e\udd87"), new Emoji("\ud83d\udc3b"), new Emoji("\ud83d\udc3b\u200d\u2744\ufe0f"), new Emoji("\ud83d\udc28"), new Emoji("\ud83d\udc3c"), new Emoji("\ud83e\udda5"), new Emoji("\ud83e\udda6"), new Emoji("\ud83e\udda8"), new Emoji("\ud83e\udd98"), new Emoji("\ud83e\udda1"), new Emoji("\ud83d\udc3e"), new Emoji("\ud83e\udd83"), new Emoji("\ud83d\udc14"), new Emoji("\ud83d\udc13"), new Emoji("\ud83d\udc23"), new Emoji("\ud83d\udc24"), new Emoji("\ud83d\udc25"), new Emoji("\ud83d\udc26"), new Emoji("\ud83d\udc27"), new Emoji("\ud83d\udd4a\ufe0f"), new Emoji("\ud83e\udd85"), new Emoji("\ud83e\udd86"), new Emoji("\ud83e\udda2"), new Emoji("\ud83e\udd89"), new Emoji("\ud83e\udda4"), new Emoji("\ud83e\udeb6"), new Emoji("\ud83e\udda9"), new Emoji("\ud83e\udd9a"), new Emoji("\ud83e\udd9c"), new Emoji("\ud83d\udc38"), new Emoji("\ud83d\udc0a"), new Emoji("\ud83d\udc22"), new Emoji("\ud83e\udd8e"), new Emoji("\ud83d\udc0d"), new Emoji("\ud83d\udc32"), new Emoji("\ud83d\udc09"), new Emoji("\ud83e\udd95"), new Emoji("\ud83e\udd96"), new Emoji("\ud83d\udc33"), new Emoji("\ud83d\udc0b"), new Emoji("\ud83d\udc2c"), new Emoji("\ud83e\uddad"), new Emoji("\ud83d\udc1f"), new Emoji("\ud83d\udc20"), new Emoji("\ud83d\udc21"), new Emoji("\ud83e\udd88"), new Emoji("\ud83d\udc19"), new Emoji("\ud83d\udc1a"), new Emoji("\ud83d\udc0c"), new Emoji("\ud83e\udd8b"), new Emoji("\ud83d\udc1b"), new Emoji("\ud83d\udc1c"), new Emoji("\ud83d\udc1d"), new Emoji("\ud83e\udeb2"), new Emoji("\ud83e\udd97"), new Emoji("\ud83e\udeb3"), new Emoji("\ud83d\udd77\ufe0f"), new Emoji("\ud83d\udd78\ufe0f"), new Emoji("\ud83e\udd82"), new Emoji("\ud83e\udd9f"), new Emoji("\ud83e\udeb0"), new Emoji("\ud83e\udeb1"), new Emoji("\ud83e\udda0"), new Emoji("\ud83d\udc90"), new Emoji("\ud83c\udf38"), new Emoji("\ud83d\udcae"), new Emoji("\ud83c\udff5\ufe0f"), new Emoji("\ud83c\udf39"), new Emoji("\ud83e\udd40"), new Emoji("\ud83c\udf3a"), new Emoji("\ud83c\udf3b"), new Emoji("\ud83c\udf3c"), new Emoji("\ud83c\udf37"), new Emoji("\ud83c\udf31"), new Emoji("\ud83e\udeb4"), new Emoji("\ud83c\udf32"), new Emoji("\ud83c\udf33"), new Emoji("\ud83c\udf34"), new Emoji("\ud83c\udf35"), new Emoji("\ud83c\udf3e"), new Emoji("\ud83c\udf3f"), new Emoji("\u2618\ufe0f"), new Emoji("\ud83c\udf40"), new Emoji("\ud83c\udf41"), new Emoji("\ud83c\udf42"), new Emoji("\ud83c\udf43"), new Emoji("\ud83d\udc1e"), + }, "emoji/Nature.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_0 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\ude00"), new Emoji("\ud83d\ude03"), new Emoji("\ud83d\ude04"), new Emoji("\ud83d\ude01"), new Emoji("\ud83d\ude06"), new Emoji("\ud83d\ude05"), new Emoji("\ud83e\udd23"), new Emoji("\ud83d\ude02"), new Emoji("\ud83d\ude42"), new Emoji("\ud83d\ude43"), new Emoji("\ud83d\ude09"), new Emoji("\ud83d\ude0a"), new Emoji("\ud83d\ude07"), new Emoji("\ud83e\udd70"), new Emoji("\ud83d\ude0d"), new Emoji("\ud83e\udd29"), new Emoji("\ud83d\ude18"), new Emoji("\ud83d\ude17"), new Emoji("\u263a\ufe0f"), new Emoji("\ud83d\ude1a"), new Emoji("\ud83d\ude19"), new Emoji("\ud83e\udd72"), new Emoji("\ud83d\ude0b"), new Emoji("\ud83d\ude1b"), new Emoji("\ud83d\ude1c"), new Emoji("\ud83e\udd2a"), new Emoji("\ud83d\ude1d"), new Emoji("\ud83e\udd11"), new Emoji("\ud83e\udd17"), new Emoji("\ud83e\udd2d"), new Emoji("\ud83e\udd2b"), new Emoji("\ud83e\udd14"), new Emoji("\ud83e\udd10"), new Emoji("\ud83e\udd28"), new Emoji("\ud83d\ude10"), new Emoji("\ud83d\ude11"), new Emoji("\ud83d\ude36"), new Emoji("\ud83d\ude0f"), new Emoji("\ud83d\ude12"), new Emoji("\ud83d\ude44"), new Emoji("\ud83d\ude2c"), new Emoji("\ud83e\udd25"), new Emoji("\ud83d\ude0c"), new Emoji("\ud83d\ude14"), new Emoji("\ud83d\ude2a"), new Emoji("\ud83e\udd24"), new Emoji("\ud83d\ude34"), new Emoji("\ud83d\ude37"), new Emoji("\ud83e\udd12"), new Emoji("\ud83e\udd15"), new Emoji("\ud83e\udd22"), new Emoji("\ud83e\udd2e"), new Emoji("\ud83e\udd27"), new Emoji("\ud83e\udd75"), new Emoji("\ud83e\udd76"), new Emoji("\ud83e\udd74"), new Emoji("\ud83d\ude35"), new Emoji("\ud83e\udd2f"), new Emoji("\ud83e\udd20"), new Emoji("\ud83e\udd73"), new Emoji("\ud83e\udd78"), new Emoji("\ud83d\ude0e"), new Emoji("\ud83e\udd13"), new Emoji("\ud83e\uddd0"), new Emoji("\ud83d\ude15"), new Emoji("\ud83d\ude1f"), new Emoji("\ud83d\ude41"), new Emoji("\u2639\ufe0f"), new Emoji("\ud83d\ude2e"), new Emoji("\ud83d\ude2f"), new Emoji("\ud83d\ude32"), new Emoji("\ud83d\ude33"), new Emoji("\ud83e\udd7a"), new Emoji("\ud83d\ude26"), new Emoji("\ud83d\ude27"), new Emoji("\ud83d\ude28"), new Emoji("\ud83d\ude30"), new Emoji("\ud83d\ude25"), new Emoji("\ud83d\ude22"), new Emoji("\ud83d\ude2d"), new Emoji("\ud83d\ude31"), new Emoji("\ud83d\ude16"), new Emoji("\ud83d\ude23"), new Emoji("\ud83d\ude1e"), new Emoji("\ud83d\ude13"), new Emoji("\ud83d\ude29"), new Emoji("\ud83d\ude2b"), new Emoji("\ud83e\udd71"), new Emoji("\ud83d\ude24"), new Emoji("\ud83d\ude21"), new Emoji("\ud83d\ude20"), new Emoji("\ud83e\udd2c"), new Emoji("\ud83d\ude08"), new Emoji("\ud83d\udc7f"), new Emoji("\ud83d\udc80"), new Emoji("\u2620\ufe0f"), new Emoji("\ud83d\udca9"), new Emoji("\ud83e\udd21"), new Emoji("\ud83d\udc79"), new Emoji("\ud83d\udc7a"), new Emoji("\ud83d\udc7b"), new Emoji("\ud83d\udc7d"), new Emoji("\ud83d\udc7e"), new Emoji("\ud83e\udd16"), new Emoji("\ud83d\ude3a"), new Emoji("\ud83d\ude38"), new Emoji("\ud83d\ude39"), new Emoji("\ud83d\ude3b"), new Emoji("\ud83d\ude3c"), new Emoji("\ud83d\ude3d"), new Emoji("\ud83d\ude40"), new Emoji("\ud83d\ude3f"), new Emoji("\ud83d\ude3e"), new Emoji("\ud83d\ude48"), new Emoji("\ud83d\ude49"), new Emoji("\ud83d\ude4a"), new Emoji("\ud83d\udc8b"), new Emoji("\ud83d\udc8c"), new Emoji("\ud83d\udc98"), new Emoji("\ud83d\udc9d"), new Emoji("\ud83d\udc96"), new Emoji("\ud83d\udc97"), new Emoji("\ud83d\udc93"), new Emoji("\ud83d\udc9e"), new Emoji("\ud83d\udc95"), new Emoji("\ud83d\udc9f"), new Emoji("\u2763\ufe0f"), new Emoji("\ud83d\udc94"), new Emoji("\u2764\ufe0f"), new Emoji("\ud83e\udde1"), new Emoji("\ud83d\udc9b"), new Emoji("\ud83d\udc9a"), new Emoji("\ud83d\udc99"), new Emoji("\ud83d\udc9c"), new Emoji("\ud83e\udd0e"), new Emoji("\ud83d\udda4"), new Emoji("\ud83e\udd0d"), new Emoji("\ud83d\udcaf"), new Emoji("\ud83d\udca2"), new Emoji("\ud83d\udca5"), new Emoji("\ud83d\udcab"), new Emoji("\ud83d\udca6"), new Emoji("\ud83d\udca8"), new Emoji("\ud83d\udd73\ufe0f"), new Emoji("\ud83d\udca3"), new Emoji("\ud83d\udcac"), new Emoji("\ud83d\udc41\ufe0f\u200d\ud83d\udde8\ufe0f"), new Emoji("\ud83d\udde8\ufe0f"), new Emoji("\ud83d\uddef\ufe0f"), new Emoji("\ud83d\udcad"), new Emoji("\ud83d\udca4"), new Emoji("\ud83d\udc4b", "\ud83d\udc4b\ud83c\udffb", "\ud83d\udc4b\ud83c\udffc", "\ud83d\udc4b\ud83c\udffd", "\ud83d\udc4b\ud83c\udffe", "\ud83d\udc4b\ud83c\udfff"), new Emoji("\ud83e\udd1a", "\ud83e\udd1a\ud83c\udffb", "\ud83e\udd1a\ud83c\udffc", "\ud83e\udd1a\ud83c\udffd", "\ud83e\udd1a\ud83c\udffe", "\ud83e\udd1a\ud83c\udfff"), new Emoji("\ud83d\udd90\ufe0f", "\ud83d\udd90\ud83c\udffb", "\ud83d\udd90\ud83c\udffc", "\ud83d\udd90\ud83c\udffd", "\ud83d\udd90\ud83c\udffe", "\ud83d\udd90\ud83c\udfff"), new Emoji("\u270b", "\u270b\ud83c\udffb", "\u270b\ud83c\udffc", "\u270b\ud83c\udffd", "\u270b\ud83c\udffe", "\u270b\ud83c\udfff"), new Emoji("\ud83d\udd96", "\ud83d\udd96\ud83c\udffb", "\ud83d\udd96\ud83c\udffc", "\ud83d\udd96\ud83c\udffd", "\ud83d\udd96\ud83c\udffe", "\ud83d\udd96\ud83c\udfff"), new Emoji("\ud83d\udc4c", "\ud83d\udc4c\ud83c\udffb", "\ud83d\udc4c\ud83c\udffc", "\ud83d\udc4c\ud83c\udffd", "\ud83d\udc4c\ud83c\udffe", "\ud83d\udc4c\ud83c\udfff"), new Emoji("\ud83e\udd0c", "\ud83e\udd0c\ud83c\udffb", "\ud83e\udd0c\ud83c\udffc", "\ud83e\udd0c\ud83c\udffd", "\ud83e\udd0c\ud83c\udffe", "\ud83e\udd0c\ud83c\udfff"), new Emoji("\ud83e\udd0f", "\ud83e\udd0f\ud83c\udffb", "\ud83e\udd0f\ud83c\udffc", "\ud83e\udd0f\ud83c\udffd", "\ud83e\udd0f\ud83c\udffe", "\ud83e\udd0f\ud83c\udfff"), new Emoji("\u270c\ufe0f", "\u270c\ud83c\udffb", "\u270c\ud83c\udffc", "\u270c\ud83c\udffd", "\u270c\ud83c\udffe", "\u270c\ud83c\udfff"), new Emoji("\ud83e\udd1e", "\ud83e\udd1e\ud83c\udffb", "\ud83e\udd1e\ud83c\udffc", "\ud83e\udd1e\ud83c\udffd", "\ud83e\udd1e\ud83c\udffe", "\ud83e\udd1e\ud83c\udfff"), new Emoji("\ud83e\udd1f", "\ud83e\udd1f\ud83c\udffb", "\ud83e\udd1f\ud83c\udffc", "\ud83e\udd1f\ud83c\udffd", "\ud83e\udd1f\ud83c\udffe", "\ud83e\udd1f\ud83c\udfff"), new Emoji("\ud83e\udd18", "\ud83e\udd18\ud83c\udffb", "\ud83e\udd18\ud83c\udffc", "\ud83e\udd18\ud83c\udffd", "\ud83e\udd18\ud83c\udffe", "\ud83e\udd18\ud83c\udfff"), new Emoji("\ud83e\udd19", "\ud83e\udd19\ud83c\udffb", "\ud83e\udd19\ud83c\udffc", "\ud83e\udd19\ud83c\udffd", "\ud83e\udd19\ud83c\udffe", "\ud83e\udd19\ud83c\udfff"), new Emoji("\ud83d\udc48", "\ud83d\udc48\ud83c\udffb", "\ud83d\udc48\ud83c\udffc", "\ud83d\udc48\ud83c\udffd", "\ud83d\udc48\ud83c\udffe", "\ud83d\udc48\ud83c\udfff"), new Emoji("\ud83d\udc49", "\ud83d\udc49\ud83c\udffb", "\ud83d\udc49\ud83c\udffc", "\ud83d\udc49\ud83c\udffd", "\ud83d\udc49\ud83c\udffe", "\ud83d\udc49\ud83c\udfff"), new Emoji("\ud83d\udc46", "\ud83d\udc46\ud83c\udffb", "\ud83d\udc46\ud83c\udffc", "\ud83d\udc46\ud83c\udffd", "\ud83d\udc46\ud83c\udffe", "\ud83d\udc46\ud83c\udfff"), new Emoji("\ud83d\udd95", "\ud83d\udd95\ud83c\udffb", "\ud83d\udd95\ud83c\udffc", "\ud83d\udd95\ud83c\udffd", "\ud83d\udd95\ud83c\udffe", "\ud83d\udd95\ud83c\udfff"), + }, "emoji/People_0.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_1 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\udc47", "\ud83d\udc47\ud83c\udffb", "\ud83d\udc47\ud83c\udffc", "\ud83d\udc47\ud83c\udffd", "\ud83d\udc47\ud83c\udffe", "\ud83d\udc47\ud83c\udfff"), new Emoji("\u261d\ufe0f", "\u261d\ud83c\udffb", "\u261d\ud83c\udffc", "\u261d\ud83c\udffd", "\u261d\ud83c\udffe", "\u261d\ud83c\udfff"), new Emoji("\ud83d\udc4d", "\ud83d\udc4d\ud83c\udffb", "\ud83d\udc4d\ud83c\udffc", "\ud83d\udc4d\ud83c\udffd", "\ud83d\udc4d\ud83c\udffe", "\ud83d\udc4d\ud83c\udfff"), new Emoji("\ud83d\udc4e", "\ud83d\udc4e\ud83c\udffb", "\ud83d\udc4e\ud83c\udffc", "\ud83d\udc4e\ud83c\udffd", "\ud83d\udc4e\ud83c\udffe", "\ud83d\udc4e\ud83c\udfff"), new Emoji("\u270a", "\u270a\ud83c\udffb", "\u270a\ud83c\udffc", "\u270a\ud83c\udffd", "\u270a\ud83c\udffe", "\u270a\ud83c\udfff"), new Emoji("\ud83d\udc4a", "\ud83d\udc4a\ud83c\udffb", "\ud83d\udc4a\ud83c\udffc", "\ud83d\udc4a\ud83c\udffd", "\ud83d\udc4a\ud83c\udffe", "\ud83d\udc4a\ud83c\udfff"), new Emoji("\ud83e\udd1b", "\ud83e\udd1b\ud83c\udffb", "\ud83e\udd1b\ud83c\udffc", "\ud83e\udd1b\ud83c\udffd", "\ud83e\udd1b\ud83c\udffe", "\ud83e\udd1b\ud83c\udfff"), new Emoji("\ud83e\udd1c", "\ud83e\udd1c\ud83c\udffb", "\ud83e\udd1c\ud83c\udffc", "\ud83e\udd1c\ud83c\udffd", "\ud83e\udd1c\ud83c\udffe", "\ud83e\udd1c\ud83c\udfff"), new Emoji("\ud83d\udc4f", "\ud83d\udc4f\ud83c\udffb", "\ud83d\udc4f\ud83c\udffc", "\ud83d\udc4f\ud83c\udffd", "\ud83d\udc4f\ud83c\udffe", "\ud83d\udc4f\ud83c\udfff"), new Emoji("\ud83d\ude4c", "\ud83d\ude4c\ud83c\udffb", "\ud83d\ude4c\ud83c\udffc", "\ud83d\ude4c\ud83c\udffd", "\ud83d\ude4c\ud83c\udffe", "\ud83d\ude4c\ud83c\udfff"), new Emoji("\ud83d\udc50", "\ud83d\udc50\ud83c\udffb", "\ud83d\udc50\ud83c\udffc", "\ud83d\udc50\ud83c\udffd", "\ud83d\udc50\ud83c\udffe", "\ud83d\udc50\ud83c\udfff"), new Emoji("\ud83e\udd32", "\ud83e\udd32\ud83c\udffb", "\ud83e\udd32\ud83c\udffc", "\ud83e\udd32\ud83c\udffd", "\ud83e\udd32\ud83c\udffe", "\ud83e\udd32\ud83c\udfff"), new Emoji("\ud83e\udd1d"), new Emoji("\ud83d\ude4f", "\ud83d\ude4f\ud83c\udffb", "\ud83d\ude4f\ud83c\udffc", "\ud83d\ude4f\ud83c\udffd", "\ud83d\ude4f\ud83c\udffe", "\ud83d\ude4f\ud83c\udfff"), new Emoji("\u270d\ufe0f", "\u270d\ud83c\udffb", "\u270d\ud83c\udffc", "\u270d\ud83c\udffd", "\u270d\ud83c\udffe", "\u270d\ud83c\udfff"), new Emoji("\ud83d\udc85", "\ud83d\udc85\ud83c\udffb", "\ud83d\udc85\ud83c\udffc", "\ud83d\udc85\ud83c\udffd", "\ud83d\udc85\ud83c\udffe", "\ud83d\udc85\ud83c\udfff"), new Emoji("\ud83e\udd33", "\ud83e\udd33\ud83c\udffb", "\ud83e\udd33\ud83c\udffc", "\ud83e\udd33\ud83c\udffd", "\ud83e\udd33\ud83c\udffe", "\ud83e\udd33\ud83c\udfff"), new Emoji("\ud83d\udcaa", "\ud83d\udcaa\ud83c\udffb", "\ud83d\udcaa\ud83c\udffc", "\ud83d\udcaa\ud83c\udffd", "\ud83d\udcaa\ud83c\udffe", "\ud83d\udcaa\ud83c\udfff"), new Emoji("\ud83e\uddbe"), new Emoji("\ud83e\uddbf"), new Emoji("\ud83e\uddb5", "\ud83e\uddb5\ud83c\udffb", "\ud83e\uddb5\ud83c\udffc", "\ud83e\uddb5\ud83c\udffd", "\ud83e\uddb5\ud83c\udffe", "\ud83e\uddb5\ud83c\udfff"), new Emoji("\ud83e\uddb6", "\ud83e\uddb6\ud83c\udffb", "\ud83e\uddb6\ud83c\udffc", "\ud83e\uddb6\ud83c\udffd", "\ud83e\uddb6\ud83c\udffe", "\ud83e\uddb6\ud83c\udfff"), new Emoji("\ud83d\udc42", "\ud83d\udc42\ud83c\udffb", "\ud83d\udc42\ud83c\udffc", "\ud83d\udc42\ud83c\udffd", "\ud83d\udc42\ud83c\udffe", "\ud83d\udc42\ud83c\udfff"), new Emoji("\ud83e\uddbb", "\ud83e\uddbb\ud83c\udffb", "\ud83e\uddbb\ud83c\udffc", "\ud83e\uddbb\ud83c\udffd", "\ud83e\uddbb\ud83c\udffe", "\ud83e\uddbb\ud83c\udfff"), new Emoji("\ud83d\udc43", "\ud83d\udc43\ud83c\udffb", "\ud83d\udc43\ud83c\udffc", "\ud83d\udc43\ud83c\udffd", "\ud83d\udc43\ud83c\udffe", "\ud83d\udc43\ud83c\udfff"), new Emoji("\ud83e\udde0"), new Emoji("\ud83e\udec0"), new Emoji("\ud83e\udec1"), new Emoji("\ud83e\uddb7"), new Emoji("\ud83e\uddb4"), new Emoji("\ud83d\udc40"), new Emoji("\ud83d\udc41\ufe0f"), new Emoji("\ud83d\udc45"), new Emoji("\ud83d\udc44"), new Emoji("\ud83d\udc76", "\ud83d\udc76\ud83c\udffb", "\ud83d\udc76\ud83c\udffc", "\ud83d\udc76\ud83c\udffd", "\ud83d\udc76\ud83c\udffe", "\ud83d\udc76\ud83c\udfff"), new Emoji("\ud83e\uddd2", "\ud83e\uddd2\ud83c\udffb", "\ud83e\uddd2\ud83c\udffc", "\ud83e\uddd2\ud83c\udffd", "\ud83e\uddd2\ud83c\udffe", "\ud83e\uddd2\ud83c\udfff"), new Emoji("\ud83d\udc66", "\ud83d\udc66\ud83c\udffb", "\ud83d\udc66\ud83c\udffc", "\ud83d\udc66\ud83c\udffd", "\ud83d\udc66\ud83c\udffe", "\ud83d\udc66\ud83c\udfff"), new Emoji("\ud83d\udc67", "\ud83d\udc67\ud83c\udffb", "\ud83d\udc67\ud83c\udffc", "\ud83d\udc67\ud83c\udffd", "\ud83d\udc67\ud83c\udffe", "\ud83d\udc67\ud83c\udfff"), new Emoji("\ud83e\uddd1", "\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udfff"), new Emoji("\ud83d\udc68", "\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udfff"), new Emoji("\ud83e\uddd4", "\ud83e\uddd4\ud83c\udffb", "\ud83e\uddd4\ud83c\udffc", "\ud83e\uddd4\ud83c\udffd", "\ud83e\uddd4\ud83c\udffe", "\ud83e\uddd4\ud83c\udfff"), new Emoji("\ud83d\udc68\u200d\ud83e\uddb0", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddb0", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddb0", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddb0", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddb0", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddb0"), new Emoji("\ud83d\udc68\u200d\ud83e\uddb1", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddb1", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddb1", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddb1", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddb1", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddb1"), new Emoji("\ud83d\udc68\u200d\ud83e\uddb3", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddb3", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddb3", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddb3", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddb3", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddb3"), new Emoji("\ud83d\udc68\u200d\ud83e\uddb2", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddb2", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddb2", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddb2", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddb2", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddb2"), new Emoji("\ud83d\udc69", "\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udfff"), new Emoji("\ud83d\udc69\u200d\ud83e\uddb0", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddb0", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddb0", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddb0", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddb0", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddb0"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddb0", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddb0", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddb0", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddb0", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddb0", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddb0"), new Emoji("\ud83d\udc69\u200d\ud83e\uddb1", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddb1", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddb1", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddb1", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddb1", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddb1"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddb1", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddb1", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddb1", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddb1", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddb1", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddb1"), new Emoji("\ud83d\udc69\u200d\ud83e\uddb3", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddb3", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddb3", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddb3", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddb3", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddb3"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddb3", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddb3", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddb3", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddb3", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddb3", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddb3"), + }, "emoji/People_1.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_2 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\udc69\u200d\ud83e\uddb2", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddb2", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddb2", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddb2", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddb2", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddb2"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddb2", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddb2", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddb2", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddb2", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddb2", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddb2"), new Emoji("\ud83d\udc71\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc71\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd3", "\ud83e\uddd3\ud83c\udffb", "\ud83e\uddd3\ud83c\udffc", "\ud83e\uddd3\ud83c\udffd", "\ud83e\uddd3\ud83c\udffe", "\ud83e\uddd3\ud83c\udfff"), new Emoji("\ud83d\udc74", "\ud83d\udc74\ud83c\udffb", "\ud83d\udc74\ud83c\udffc", "\ud83d\udc74\ud83c\udffd", "\ud83d\udc74\ud83c\udffe", "\ud83d\udc74\ud83c\udfff"), new Emoji("\ud83d\udc75", "\ud83d\udc75\ud83c\udffb", "\ud83d\udc75\ud83c\udffc", "\ud83d\udc75\ud83c\udffd", "\ud83d\udc75\ud83c\udffe", "\ud83d\udc75\ud83c\udfff"), new Emoji("\ud83d\ude4d\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4d\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddcf", "\ud83e\uddcf\ud83c\udffb", "\ud83e\uddcf\ud83c\udffc", "\ud83e\uddcf\ud83c\udffd", "\ud83e\uddcf\ud83c\udffe", "\ud83e\uddcf\ud83c\udfff"), new Emoji("\ud83e\uddcf\u200d\u2642\ufe0f", "\ud83e\uddcf\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddcf\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddcf\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddcf\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddcf\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddcf\u200d\u2640\ufe0f", "\ud83e\uddcf\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddcf\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddcf\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddcf\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddcf\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd26", "\ud83e\udd26\ud83c\udffb", "\ud83e\udd26\ud83c\udffc", "\ud83e\udd26\ud83c\udffd", "\ud83e\udd26\ud83c\udffe", "\ud83e\udd26\ud83c\udfff"), new Emoji("\ud83e\udd26\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd26\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd37", "\ud83e\udd37\ud83c\udffb", "\ud83e\udd37\ud83c\udffc", "\ud83e\udd37\ud83c\udffd", "\ud83e\udd37\ud83c\udffe", "\ud83e\udd37\ud83c\udfff"), new Emoji("\ud83e\udd37\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd37\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd1\u200d\u2695\ufe0f", "\ud83e\uddd1\ud83c\udffb\u200d\u2695\ufe0f", "\ud83e\uddd1\ud83c\udffc\u200d\u2695\ufe0f", "\ud83e\uddd1\ud83c\udffd\u200d\u2695\ufe0f", "\ud83e\uddd1\ud83c\udffe\u200d\u2695\ufe0f", "\ud83e\uddd1\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc68\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83e\uddd1\u200d\ud83c\udf93", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udf93", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udf93", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udf93", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udf93", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc68\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc69\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83e\uddd1\u200d\ud83c\udfeb", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udfeb", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udfeb", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udfeb", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udfeb", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc68\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc69\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83e\uddd1\u200d\u2696\ufe0f", "\ud83e\uddd1\ud83c\udffb\u200d\u2696\ufe0f", "\ud83e\uddd1\ud83c\udffc\u200d\u2696\ufe0f", "\ud83e\uddd1\ud83c\udffd\u200d\u2696\ufe0f", "\ud83e\uddd1\ud83c\udffe\u200d\u2696\ufe0f", "\ud83e\uddd1\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc68\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2696\ufe0f"), + }, "emoji/People_2.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_3 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83e\uddd1\u200d\ud83c\udf3e", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udf3e", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udf3e", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udf3e", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udf3e", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc68\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc69\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83e\uddd1\u200d\ud83c\udf73", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udf73", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udf73", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udf73", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udf73", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc68\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc69\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83e\uddd1\u200d\ud83d\udd27", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\udd27", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\udd27", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\udd27", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\udd27", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc68\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc69\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83e\uddd1\u200d\ud83c\udfed", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udfed", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udfed", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udfed", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udfed", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc68\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc69\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83e\uddd1\u200d\ud83d\udcbc", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\udcbc", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\udcbc", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\udcbc", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\udcbc", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83e\uddd1\u200d\ud83d\udd2c", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\udd2c", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\udd2c", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\udd2c", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\udd2c", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc68\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc69\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83e\uddd1\u200d\ud83d\udcbb", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\udcbb", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\udcbb", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\udcbb", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\udcbb", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83e\uddd1\u200d\ud83c\udfa4", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udfa4", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udfa4", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udfa4", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udfa4", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83e\uddd1\u200d\ud83c\udfa8", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udfa8", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udfa8", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udfa8", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udfa8", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83e\uddd1\u200d\u2708\ufe0f", "\ud83e\uddd1\ud83c\udffb\u200d\u2708\ufe0f", "\ud83e\uddd1\ud83c\udffc\u200d\u2708\ufe0f", "\ud83e\uddd1\ud83c\udffd\u200d\u2708\ufe0f", "\ud83e\uddd1\ud83c\udffe\u200d\u2708\ufe0f", "\ud83e\uddd1\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc68\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83e\uddd1\u200d\ud83d\ude80", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\ude80", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\ude80", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\ude80", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\ude80", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc68\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc69\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83e\uddd1\u200d\ud83d\ude92", "\ud83e\uddd1\ud83c\udffb\u200d\ud83d\ude92", "\ud83e\uddd1\ud83c\udffc\u200d\ud83d\ude92", "\ud83e\uddd1\ud83c\udffd\u200d\ud83d\ude92", "\ud83e\uddd1\ud83c\udffe\u200d\ud83d\ude92", "\ud83e\uddd1\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc68\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc69\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc6e\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6e\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc82\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc82\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2640\ufe0f"), + }, "emoji/People_3.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_4 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83e\udd77", "\ud83e\udd77\ud83c\udffb", "\ud83e\udd77\ud83c\udffc", "\ud83e\udd77\ud83c\udffd", "\ud83e\udd77\ud83c\udffe", "\ud83e\udd77\ud83c\udfff"), new Emoji("\ud83d\udc77\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc77\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd34", "\ud83e\udd34\ud83c\udffb", "\ud83e\udd34\ud83c\udffc", "\ud83e\udd34\ud83c\udffd", "\ud83e\udd34\ud83c\udffe", "\ud83e\udd34\ud83c\udfff"), new Emoji("\ud83d\udc78", "\ud83d\udc78\ud83c\udffb", "\ud83d\udc78\ud83c\udffc", "\ud83d\udc78\ud83c\udffd", "\ud83d\udc78\ud83c\udffe", "\ud83d\udc78\ud83c\udfff"), new Emoji("\ud83d\udc73\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc73\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc72", "\ud83d\udc72\ud83c\udffb", "\ud83d\udc72\ud83c\udffc", "\ud83d\udc72\ud83c\udffd", "\ud83d\udc72\ud83c\udffe", "\ud83d\udc72\ud83c\udfff"), new Emoji("\ud83e\uddd5", "\ud83e\uddd5\ud83c\udffb", "\ud83e\uddd5\ud83c\udffc", "\ud83e\uddd5\ud83c\udffd", "\ud83e\uddd5\ud83c\udffe", "\ud83e\uddd5\ud83c\udfff"), new Emoji("\ud83e\udd35\u200d\u2642\ufe0f", "\ud83e\udd35\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd35\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd35\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd35\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd35\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd35\u200d\u2640\ufe0f", "\ud83e\udd35\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd35\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd35\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd35\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd35\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc70", "\ud83d\udc70\ud83c\udffb", "\ud83d\udc70\ud83c\udffc", "\ud83d\udc70\ud83c\udffd", "\ud83d\udc70\ud83c\udffe", "\ud83d\udc70\ud83c\udfff"), new Emoji("\ud83d\udc70\u200d\u2642\ufe0f", "\ud83d\udc70\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc70\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc70\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc70\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc70\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc70\u200d\u2640\ufe0f", "\ud83d\udc70\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc70\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc70\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc70\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc70\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd30", "\ud83e\udd30\ud83c\udffb", "\ud83e\udd30\ud83c\udffc", "\ud83e\udd30\ud83c\udffd", "\ud83e\udd30\ud83c\udffe", "\ud83e\udd30\ud83c\udfff"), new Emoji("\ud83e\udd31", "\ud83e\udd31\ud83c\udffb", "\ud83e\udd31\ud83c\udffc", "\ud83e\udd31\ud83c\udffd", "\ud83e\udd31\ud83c\udffe", "\ud83e\udd31\ud83c\udfff"), new Emoji("\ud83d\udc69\u200d\ud83c\udf7c", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf7c", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf7c", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf7c", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf7c", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf7c"), new Emoji("\ud83d\udc68\u200d\ud83c\udf7c", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf7c", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf7c", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf7c", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf7c", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf7c"), new Emoji("\ud83e\uddd1\u200d\ud83c\udf7c", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udf7c", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udf7c", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udf7c", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udf7c", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udf7c"), new Emoji("\ud83d\udc7c", "\ud83d\udc7c\ud83c\udffb", "\ud83d\udc7c\ud83c\udffc", "\ud83d\udc7c\ud83c\udffd", "\ud83d\udc7c\ud83c\udffe", "\ud83d\udc7c\ud83c\udfff"), new Emoji("\ud83c\udf85", "\ud83c\udf85\ud83c\udffb", "\ud83c\udf85\ud83c\udffc", "\ud83c\udf85\ud83c\udffd", "\ud83c\udf85\ud83c\udffe", "\ud83c\udf85\ud83c\udfff"), new Emoji("\ud83e\udd36", "\ud83e\udd36\ud83c\udffb", "\ud83e\udd36\ud83c\udffc", "\ud83e\udd36\ud83c\udffd", "\ud83e\udd36\ud83c\udffe", "\ud83e\udd36\ud83c\udfff"), new Emoji("\ud83e\uddd1\u200d\ud83c\udf84", "\ud83e\uddd1\ud83c\udffb\u200d\ud83c\udf84", "\ud83e\uddd1\ud83c\udffc\u200d\ud83c\udf84", "\ud83e\uddd1\ud83c\udffd\u200d\ud83c\udf84", "\ud83e\uddd1\ud83c\udffe\u200d\ud83c\udf84", "\ud83e\uddd1\ud83c\udfff\u200d\ud83c\udf84"), new Emoji("\ud83e\uddb8", "\ud83e\uddb8\ud83c\udffb", "\ud83e\uddb8\ud83c\udffc", "\ud83e\uddb8\ud83c\udffd", "\ud83e\uddb8\ud83c\udffe", "\ud83e\uddb8\ud83c\udfff"), new Emoji("\ud83e\uddb8\u200d\u2642\ufe0f", "\ud83e\uddb8\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddb8\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddb8\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddb8\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddb8\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddb8\u200d\u2640\ufe0f", "\ud83e\uddb8\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddb8\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddb8\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddb8\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddb8\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddb9", "\ud83e\uddb9\ud83c\udffb", "\ud83e\uddb9\ud83c\udffc", "\ud83e\uddb9\ud83c\udffd", "\ud83e\uddb9\ud83c\udffe", "\ud83e\uddb9\ud83c\udfff"), new Emoji("\ud83e\uddb9\u200d\u2642\ufe0f", "\ud83e\uddb9\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddb9\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddb9\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddb9\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddb9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddb9\u200d\u2640\ufe0f", "\ud83e\uddb9\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddb9\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddb9\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddb9\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddb9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd9\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd9\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc87\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2642\ufe0f"), + }, "emoji/People_4.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_5 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\udc87\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddcd", "\ud83e\uddcd\ud83c\udffb", "\ud83e\uddcd\ud83c\udffc", "\ud83e\uddcd\ud83c\udffd", "\ud83e\uddcd\ud83c\udffe", "\ud83e\uddcd\ud83c\udfff"), new Emoji("\ud83e\uddcd\u200d\u2642\ufe0f", "\ud83e\uddcd\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddcd\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddcd\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddcd\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddcd\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddcd\u200d\u2640\ufe0f", "\ud83e\uddcd\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddcd\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddcd\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddcd\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddcd\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddce", "\ud83e\uddce\ud83c\udffb", "\ud83e\uddce\ud83c\udffc", "\ud83e\uddce\ud83c\udffd", "\ud83e\uddce\ud83c\udffe", "\ud83e\uddce\ud83c\udfff"), new Emoji("\ud83e\uddce\u200d\u2642\ufe0f", "\ud83e\uddce\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddce\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddce\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddce\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddce\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddce\u200d\u2640\ufe0f", "\ud83e\uddce\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddce\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddce\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddce\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddce\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddaf", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddaf", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddaf", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddaf", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddaf", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddaf"), new Emoji("\ud83d\udc68\u200d\ud83e\uddaf", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddaf", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddaf", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddaf", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddaf", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddaf"), new Emoji("\ud83d\udc69\u200d\ud83e\uddaf", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddaf", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddaf", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddaf", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddaf", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddaf"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddbc", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddbc", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddbc", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddbc", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddbc", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddbc"), new Emoji("\ud83d\udc68\u200d\ud83e\uddbc", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddbc", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddbc", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddbc", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddbc", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddbc"), new Emoji("\ud83d\udc69\u200d\ud83e\uddbc", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddbc", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddbc", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddbc", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddbc", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddbc"), new Emoji("\ud83e\uddd1\u200d\ud83e\uddbd", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\uddbd", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\uddbd", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\uddbd", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\uddbd", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\uddbd"), new Emoji("\ud83d\udc68\u200d\ud83e\uddbd", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\uddbd", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\uddbd", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\uddbd", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\uddbd", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\uddbd"), new Emoji("\ud83d\udc69\u200d\ud83e\uddbd", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\uddbd", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\uddbd", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\uddbd", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\uddbd", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\uddbd"), new Emoji("\ud83c\udfc3\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc3\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc83", "\ud83d\udc83\ud83c\udffb", "\ud83d\udc83\ud83c\udffc", "\ud83d\udc83\ud83c\udffd", "\ud83d\udc83\ud83c\udffe", "\ud83d\udc83\ud83c\udfff"), new Emoji("\ud83d\udd7a", "\ud83d\udd7a\ud83c\udffb", "\ud83d\udd7a\ud83c\udffc", "\ud83d\udd7a\ud83c\udffd", "\ud83d\udd7a\ud83c\udffe", "\ud83d\udd7a\ud83c\udfff"), new Emoji("\ud83d\udd74\ufe0f", "\ud83d\udd74\ud83c\udffb", "\ud83d\udd74\ud83c\udffc", "\ud83d\udd74\ud83c\udffd", "\ud83d\udd74\ud83c\udffe", "\ud83d\udd74\ud83c\udfff"), new Emoji("\ud83d\udc6f\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6f\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd7\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd7\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3a"), new Emoji("\ud83c\udfc7", "\ud83c\udfc7\ud83c\udffb", "\ud83c\udfc7\ud83c\udffc", "\ud83c\udfc7\ud83c\udffd", "\ud83c\udfc7\ud83c\udffe", "\ud83c\udfc7\ud83c\udfff"), new Emoji("\u26f7\ufe0f"), new Emoji("\ud83c\udfc2", "\ud83c\udfc2\ud83c\udffb", "\ud83c\udfc2\ud83c\udffc", "\ud83c\udfc2\ud83c\udffd", "\ud83c\udfc2\ud83c\udffe", "\ud83c\udfc2\ud83c\udfff"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2642\ufe0f", "\u26f9\ud83c\udffb\u200d\u2642\ufe0f", "\u26f9\ud83c\udffc\u200d\u2642\ufe0f", "\u26f9\ud83c\udffd\u200d\u2642\ufe0f", "\u26f9\ud83c\udffe\u200d\u2642\ufe0f", "\u26f9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2640\ufe0f", "\u26f9\ud83c\udffb\u200d\u2640\ufe0f", "\u26f9\ud83c\udffc\u200d\u2640\ufe0f", "\u26f9\ud83c\udffd\u200d\u2640\ufe0f", "\u26f9\ud83c\udffe\u200d\u2640\ufe0f", "\u26f9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb4\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2642\ufe0f"), + }, "emoji/People_5.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_6 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\udeb4\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd38", "\ud83e\udd38\ud83c\udffb", "\ud83e\udd38\ud83c\udffc", "\ud83e\udd38\ud83c\udffd", "\ud83e\udd38\ud83c\udffe", "\ud83e\udd38\ud83c\udfff"), new Emoji("\ud83e\udd38\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd38\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3c"), new Emoji("\ud83e\udd3c\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3c\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3d", "\ud83e\udd3d\ud83c\udffb", "\ud83e\udd3d\ud83c\udffc", "\ud83e\udd3d\ud83c\udffd", "\ud83e\udd3d\ud83c\udffe", "\ud83e\udd3d\ud83c\udfff"), new Emoji("\ud83e\udd3d\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3d\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3e", "\ud83e\udd3e\ud83c\udffb", "\ud83e\udd3e\ud83c\udffc", "\ud83e\udd3e\ud83c\udffd", "\ud83e\udd3e\ud83c\udffe", "\ud83e\udd3e\ud83c\udfff"), new Emoji("\ud83e\udd3e\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3e\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd39", "\ud83e\udd39\ud83c\udffb", "\ud83e\udd39\ud83c\udffc", "\ud83e\udd39\ud83c\udffd", "\ud83e\udd39\ud83c\udffe", "\ud83e\udd39\ud83c\udfff"), new Emoji("\ud83e\udd39\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd39\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udec0", "\ud83d\udec0\ud83c\udffb", "\ud83d\udec0\ud83c\udffc", "\ud83d\udec0\ud83c\udffd", "\ud83d\udec0\ud83c\udffe", "\ud83d\udec0\ud83c\udfff"), new Emoji("\ud83d\udecc", "\ud83d\udecc\ud83c\udffb", "\ud83d\udecc\ud83c\udffc", "\ud83d\udecc\ud83c\udffd", "\ud83d\udecc\ud83c\udffe", "\ud83d\udecc\ud83c\udfff"), new Emoji("\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udfff", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udfff", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udfff", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udfff", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c\udfff"), new Emoji("\ud83d\udc6d", "\ud83d\udc6d\ud83c\udffb", "\ud83d\udc6d\ud83c\udffc", "\ud83d\udc6d\ud83c\udffd", "\ud83d\udc6d\ud83c\udffe", "\ud83d\udc6d\ud83c\udfff", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udfff", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udfff", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udfff", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udfff", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c\udffe"), new Emoji("\ud83d\udc6b", "\ud83d\udc6b\ud83c\udffb", "\ud83d\udc6b\ud83c\udffc", "\ud83d\udc6b\ud83c\udffd", "\ud83d\udc6b\ud83c\udffe", "\ud83d\udc6b\ud83c\udfff", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe"), new Emoji("\ud83d\udc6c", "\ud83d\udc6c\ud83c\udffb", "\ud83d\udc6c\ud83c\udffc", "\ud83d\udc6c\ud83c\udffd", "\ud83d\udc6c\ud83c\udffe", "\ud83d\udc6c\ud83c\udfff", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udfff", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c\udffe"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc69"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udde3\ufe0f"), new Emoji("\ud83d\udc64"), new Emoji("\ud83d\udc65"), new Emoji("\ud83e\udec2"), + }, "emoji/People_6.webp"); + + private static final EmojiPageModel PAGE_PEOPLE_7 = new StaticEmojiPageModel(R.attr.emoji_category_people, new Emoji[] { + new Emoji("\ud83d\udc63"), new Emoji("\ud83e\udd35", "\ud83e\udd35\ud83c\udffb", "\ud83e\udd35\ud83c\udffc", "\ud83e\udd35\ud83c\udffd", "\ud83e\udd35\ud83c\udffe", "\ud83e\udd35\ud83c\udfff"), + }, "emoji/People_7.webp"); + + private static final EmojiPageModel PAGE_OBJECTS = new StaticEmojiPageModel(R.attr.emoji_category_objects, new Emoji[] { + new Emoji("\ud83d\udc53"), new Emoji("\ud83d\udd76\ufe0f"), new Emoji("\ud83e\udd7d"), new Emoji("\ud83e\udd7c"), new Emoji("\ud83e\uddba"), new Emoji("\ud83d\udc54"), new Emoji("\ud83d\udc55"), new Emoji("\ud83d\udc56"), new Emoji("\ud83e\udde3"), new Emoji("\ud83e\udde4"), new Emoji("\ud83e\udde5"), new Emoji("\ud83e\udde6"), new Emoji("\ud83d\udc57"), new Emoji("\ud83d\udc58"), new Emoji("\ud83e\udd7b"), new Emoji("\ud83e\ude71"), new Emoji("\ud83e\ude72"), new Emoji("\ud83e\ude73"), new Emoji("\ud83d\udc59"), new Emoji("\ud83d\udc5a"), new Emoji("\ud83d\udc5b"), new Emoji("\ud83d\udc5c"), new Emoji("\ud83d\udc5d"), new Emoji("\ud83d\udecd\ufe0f"), new Emoji("\ud83c\udf92"), new Emoji("\ud83e\ude74"), new Emoji("\ud83d\udc5e"), new Emoji("\ud83d\udc5f"), new Emoji("\ud83e\udd7e"), new Emoji("\ud83e\udd7f"), new Emoji("\ud83d\udc60"), new Emoji("\ud83d\udc61"), new Emoji("\ud83e\ude70"), new Emoji("\ud83d\udc62"), new Emoji("\ud83d\udc51"), new Emoji("\ud83d\udc52"), new Emoji("\ud83c\udfa9"), new Emoji("\ud83c\udf93"), new Emoji("\ud83e\udde2"), new Emoji("\ud83e\ude96"), new Emoji("\u26d1\ufe0f"), new Emoji("\ud83d\udcff"), new Emoji("\ud83d\udc84"), new Emoji("\ud83d\udc8d"), new Emoji("\ud83d\udc8e"), new Emoji("\ud83d\udd07"), new Emoji("\ud83d\udd08"), new Emoji("\ud83d\udd09"), new Emoji("\ud83d\udd0a"), new Emoji("\ud83d\udce2"), new Emoji("\ud83d\udce3"), new Emoji("\ud83d\udcef"), new Emoji("\ud83d\udd14"), new Emoji("\ud83d\udd15"), new Emoji("\ud83c\udfbc"), new Emoji("\ud83c\udfb5"), new Emoji("\ud83c\udfb6"), new Emoji("\ud83c\udf99\ufe0f"), new Emoji("\ud83c\udf9a\ufe0f"), new Emoji("\ud83c\udf9b\ufe0f"), new Emoji("\ud83c\udfa4"), new Emoji("\ud83c\udfa7"), new Emoji("\ud83d\udcfb"), new Emoji("\ud83c\udfb7"), new Emoji("\ud83e\ude97"), new Emoji("\ud83c\udfb8"), new Emoji("\ud83c\udfb9"), new Emoji("\ud83c\udfba"), new Emoji("\ud83c\udfbb"), new Emoji("\ud83e\ude95"), new Emoji("\ud83e\udd41"), new Emoji("\ud83e\ude98"), new Emoji("\ud83d\udcf1"), new Emoji("\ud83d\udcf2"), new Emoji("\u260e\ufe0f"), new Emoji("\ud83d\udcde"), new Emoji("\ud83d\udcdf"), new Emoji("\ud83d\udce0"), new Emoji("\ud83d\udd0b"), new Emoji("\ud83d\udd0c"), new Emoji("\ud83d\udcbb"), new Emoji("\ud83d\udda5\ufe0f"), new Emoji("\ud83d\udda8\ufe0f"), new Emoji("\u2328\ufe0f"), new Emoji("\ud83d\uddb1\ufe0f"), new Emoji("\ud83d\uddb2\ufe0f"), new Emoji("\ud83d\udcbd"), new Emoji("\ud83d\udcbe"), new Emoji("\ud83d\udcbf"), new Emoji("\ud83d\udcc0"), new Emoji("\ud83e\uddee"), new Emoji("\ud83c\udfa5"), new Emoji("\ud83c\udf9e\ufe0f"), new Emoji("\ud83d\udcfd\ufe0f"), new Emoji("\ud83c\udfac"), new Emoji("\ud83d\udcfa"), new Emoji("\ud83d\udcf7"), new Emoji("\ud83d\udcf8"), new Emoji("\ud83d\udcf9"), new Emoji("\ud83d\udcfc"), new Emoji("\ud83d\udd0d"), new Emoji("\ud83d\udd0e"), new Emoji("\ud83d\udd6f\ufe0f"), new Emoji("\ud83d\udca1"), new Emoji("\ud83d\udd26"), new Emoji("\ud83c\udfee"), new Emoji("\ud83e\ude94"), new Emoji("\ud83d\udcd4"), new Emoji("\ud83d\udcd5"), new Emoji("\ud83d\udcd6"), new Emoji("\ud83d\udcd7"), new Emoji("\ud83d\udcd8"), new Emoji("\ud83d\udcd9"), new Emoji("\ud83d\udcda"), new Emoji("\ud83d\udcd3"), new Emoji("\ud83d\udcd2"), new Emoji("\ud83d\udcc3"), new Emoji("\ud83d\udcdc"), new Emoji("\ud83d\udcc4"), new Emoji("\ud83d\udcf0"), new Emoji("\ud83d\uddde\ufe0f"), new Emoji("\ud83d\udcd1"), new Emoji("\ud83d\udd16"), new Emoji("\ud83c\udff7\ufe0f"), new Emoji("\ud83d\udcb0"), new Emoji("\ud83e\ude99"), new Emoji("\ud83d\udcb4"), new Emoji("\ud83d\udcb5"), new Emoji("\ud83d\udcb6"), new Emoji("\ud83d\udcb7"), new Emoji("\ud83d\udcb8"), new Emoji("\ud83d\udcb3"), new Emoji("\ud83e\uddfe"), new Emoji("\ud83d\udcb9"), new Emoji("\u2709\ufe0f"), new Emoji("\ud83d\udce7"), new Emoji("\ud83d\udce8"), new Emoji("\ud83d\udce9"), new Emoji("\ud83d\udce4"), new Emoji("\ud83d\udce5"), new Emoji("\ud83d\udce6"), new Emoji("\ud83d\udceb"), new Emoji("\ud83d\udcea"), new Emoji("\ud83d\udcec"), new Emoji("\ud83d\udced"), new Emoji("\ud83d\udcee"), new Emoji("\ud83d\uddf3\ufe0f"), new Emoji("\u270f\ufe0f"), new Emoji("\u2712\ufe0f"), new Emoji("\ud83d\udd8b\ufe0f"), new Emoji("\ud83d\udd8a\ufe0f"), new Emoji("\ud83d\udd8c\ufe0f"), new Emoji("\ud83d\udd8d\ufe0f"), new Emoji("\ud83d\udcdd"), new Emoji("\ud83d\udcbc"), new Emoji("\ud83d\udcc1"), new Emoji("\ud83d\udcc2"), new Emoji("\ud83d\uddc2\ufe0f"), new Emoji("\ud83d\udcc5"), new Emoji("\ud83d\udcc6"), new Emoji("\ud83d\uddd2\ufe0f"), new Emoji("\ud83d\uddd3\ufe0f"), new Emoji("\ud83d\udcc7"), new Emoji("\ud83d\udcc8"), new Emoji("\ud83d\udcc9"), new Emoji("\ud83d\udcca"), new Emoji("\ud83d\udccb"), new Emoji("\ud83d\udccc"), new Emoji("\ud83d\udccd"), new Emoji("\ud83d\udcce"), new Emoji("\ud83d\udd87\ufe0f"), new Emoji("\ud83d\udccf"), new Emoji("\ud83d\udcd0"), new Emoji("\u2702\ufe0f"), new Emoji("\ud83d\uddc3\ufe0f"), new Emoji("\ud83d\uddc4\ufe0f"), new Emoji("\ud83d\uddd1\ufe0f"), new Emoji("\ud83d\udd12"), new Emoji("\ud83d\udd13"), new Emoji("\ud83d\udd0f"), new Emoji("\ud83d\udd10"), new Emoji("\ud83d\udd11"), new Emoji("\ud83d\udddd\ufe0f"), new Emoji("\ud83d\udd28"), new Emoji("\ud83e\ude93"), new Emoji("\u26cf\ufe0f"), new Emoji("\u2692\ufe0f"), new Emoji("\ud83d\udee0\ufe0f"), new Emoji("\ud83d\udde1\ufe0f"), new Emoji("\u2694\ufe0f"), new Emoji("\ud83d\udd2b"), new Emoji("\ud83e\ude83"), new Emoji("\ud83c\udff9"), new Emoji("\ud83d\udee1\ufe0f"), new Emoji("\ud83e\ude9a"), new Emoji("\ud83d\udd27"), new Emoji("\ud83e\ude9b"), new Emoji("\ud83d\udd29"), new Emoji("\u2699\ufe0f"), new Emoji("\ud83d\udddc\ufe0f"), new Emoji("\u2696\ufe0f"), new Emoji("\ud83e\uddaf"), new Emoji("\ud83d\udd17"), new Emoji("\u26d3\ufe0f"), new Emoji("\ud83e\ude9d"), new Emoji("\ud83e\uddf0"), new Emoji("\ud83e\uddf2"), new Emoji("\ud83e\ude9c"), new Emoji("\u2697\ufe0f"), new Emoji("\ud83e\uddea"), new Emoji("\ud83e\uddeb"), new Emoji("\ud83e\uddec"), new Emoji("\ud83d\udd2c"), new Emoji("\ud83d\udd2d"), new Emoji("\ud83d\udce1"), new Emoji("\ud83d\udc89"), new Emoji("\ud83e\ude78"), new Emoji("\ud83d\udc8a"), new Emoji("\ud83e\ude79"), new Emoji("\ud83e\ude7a"), new Emoji("\ud83d\udeaa"), new Emoji("\ud83d\uded7"), new Emoji("\ud83e\ude9e"), new Emoji("\ud83e\ude9f"), new Emoji("\ud83d\udecf\ufe0f"), new Emoji("\ud83d\udecb\ufe0f"), new Emoji("\ud83e\ude91"), new Emoji("\ud83d\udebd"), new Emoji("\ud83e\udea0"), new Emoji("\ud83d\udebf"), new Emoji("\ud83d\udec1"), new Emoji("\ud83e\udea4"), new Emoji("\ud83e\ude92"), new Emoji("\ud83e\uddf4"), new Emoji("\ud83e\uddf7"), new Emoji("\ud83e\uddf9"), new Emoji("\ud83e\uddfa"), new Emoji("\ud83e\uddfb"), new Emoji("\ud83e\udea3"), new Emoji("\ud83e\uddfc"), new Emoji("\ud83e\udea5"), new Emoji("\ud83e\uddfd"), new Emoji("\ud83e\uddef"), new Emoji("\ud83d\uded2"), new Emoji("\ud83d\udeac"), new Emoji("\u26b0\ufe0f"), new Emoji("\ud83e\udea6"), new Emoji("\u26b1\ufe0f"), new Emoji("\ud83d\uddff"), new Emoji("\ud83e\udea7"), + }, "emoji/Objects.webp"); + + private static final EmojiPageModel PAGE_PEOPLE = new CompositeEmojiPageModel(R.attr.emoji_category_people, PAGE_PEOPLE_0, PAGE_PEOPLE_1, PAGE_PEOPLE_2, PAGE_PEOPLE_3, PAGE_PEOPLE_4, PAGE_PEOPLE_5, PAGE_PEOPLE_6, PAGE_PEOPLE_7); + + private static final EmojiPageModel PAGE_FLAGS = new CompositeEmojiPageModel(R.attr.emoji_category_flags, PAGE_FLAGS_0, PAGE_FLAGS_1); + + private static final EmojiPageModel PAGE_EMOTICONS = new StaticEmojiPageModel(R.attr.emoji_category_emoticons, new String[] { + ":-)", ";-)", "(-:", ":->", ":-D", "\\o/", + ":-P", "B-)", ":-$", ":-*", "O:-)", "=-O", + "O_O", "O_o", "o_O", ":O", ":-!", ":-x", + ":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(", + "^.^", "^_^", "\\(\u02c6\u02da\u02c6)/", + "\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af", + "\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)", + "(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e", + "\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e", + "(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b", + "\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)", + "(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05", + "\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)", + " \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)", + "\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b" + }, null); + + static final List DISPLAY_PAGES = Arrays.asList(PAGE_PEOPLE, PAGE_NATURE, PAGE_FOODS, PAGE_ACTIVITY, PAGE_PLACES, PAGE_OBJECTS, PAGE_SYMBOLS, PAGE_FLAGS, PAGE_EMOTICONS); + + static final List DATA_PAGES = Arrays.asList(PAGE_PEOPLE_0, PAGE_PEOPLE_1, PAGE_PEOPLE_2, PAGE_PEOPLE_3, PAGE_PEOPLE_4, PAGE_PEOPLE_5, PAGE_PEOPLE_6, PAGE_PEOPLE_7, PAGE_NATURE, PAGE_FOODS, PAGE_ACTIVITY, PAGE_PLACES, PAGE_OBJECTS, PAGE_SYMBOLS, PAGE_FLAGS_0, PAGE_FLAGS_1, PAGE_EMOTICONS); + + static final List> OBSOLETE = new LinkedList>() {{ + add(new Pair<>("\ud83d\udc71", "\ud83d\udc71\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc71\ud83c\udffb", "\ud83d\udc71\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc71\ud83c\udffc", "\ud83d\udc71\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc71\ud83c\udffd", "\ud83d\udc71\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc71\ud83c\udffe", "\ud83d\udc71\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc71\ud83c\udfff", "\ud83d\udc71\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude4d", "\ud83d\ude4d\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4d\ud83c\udffb", "\ud83d\ude4d\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4d\ud83c\udffc", "\ud83d\ude4d\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4d\ud83c\udffd", "\ud83d\ude4d\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4d\ud83c\udffe", "\ud83d\ude4d\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4d\ud83c\udfff", "\ud83d\ude4d\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e", "\ud83d\ude4e\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e\ud83c\udffb", "\ud83d\ude4e\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e\ud83c\udffc", "\ud83d\ude4e\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e\ud83c\udffd", "\ud83d\ude4e\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e\ud83c\udffe", "\ud83d\ude4e\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4e\ud83c\udfff", "\ud83d\ude4e\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45", "\ud83d\ude45\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45\ud83c\udffb", "\ud83d\ude45\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45\ud83c\udffc", "\ud83d\ude45\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45\ud83c\udffd", "\ud83d\ude45\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45\ud83c\udffe", "\ud83d\ude45\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude45\ud83c\udfff", "\ud83d\ude45\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46", "\ud83d\ude46\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46\ud83c\udffb", "\ud83d\ude46\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46\ud83c\udffc", "\ud83d\ude46\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46\ud83c\udffd", "\ud83d\ude46\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46\ud83c\udffe", "\ud83d\ude46\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude46\ud83c\udfff", "\ud83d\ude46\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81", "\ud83d\udc81\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81\ud83c\udffb", "\ud83d\udc81\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81\ud83c\udffc", "\ud83d\udc81\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81\ud83c\udffd", "\ud83d\udc81\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81\ud83c\udffe", "\ud83d\udc81\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc81\ud83c\udfff", "\ud83d\udc81\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b", "\ud83d\ude4b\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b\ud83c\udffb", "\ud83d\ude4b\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b\ud83c\udffc", "\ud83d\ude4b\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b\ud83c\udffd", "\ud83d\ude4b\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b\ud83c\udffe", "\ud83d\ude4b\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude4b\ud83c\udfff", "\ud83d\ude4b\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\ude47", "\ud83d\ude47\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude47\ud83c\udffb", "\ud83d\ude47\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude47\ud83c\udffc", "\ud83d\ude47\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude47\ud83c\udffd", "\ud83d\ude47\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude47\ud83c\udffe", "\ud83d\ude47\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\ude47\ud83c\udfff", "\ud83d\ude47\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e", "\ud83d\udc6e\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e\ud83c\udffb", "\ud83d\udc6e\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e\ud83c\udffc", "\ud83d\udc6e\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e\ud83c\udffd", "\ud83d\udc6e\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e\ud83c\udffe", "\ud83d\udc6e\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6e\ud83c\udfff", "\ud83d\udc6e\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ufe0f", "\ud83d\udd75\ufe0f\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ud83c\udffb", "\ud83d\udd75\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ud83c\udffc", "\ud83d\udd75\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ud83c\udffd", "\ud83d\udd75\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ud83c\udffe", "\ud83d\udd75\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udd75\ud83c\udfff", "\ud83d\udd75\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82", "\ud83d\udc82\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82\ud83c\udffb", "\ud83d\udc82\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82\ud83c\udffc", "\ud83d\udc82\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82\ud83c\udffd", "\ud83d\udc82\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82\ud83c\udffe", "\ud83d\udc82\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc82\ud83c\udfff", "\ud83d\udc82\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77", "\ud83d\udc77\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77\ud83c\udffb", "\ud83d\udc77\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77\ud83c\udffc", "\ud83d\udc77\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77\ud83c\udffd", "\ud83d\udc77\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77\ud83c\udffe", "\ud83d\udc77\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc77\ud83c\udfff", "\ud83d\udc77\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73", "\ud83d\udc73\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73\ud83c\udffb", "\ud83d\udc73\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73\ud83c\udffc", "\ud83d\udc73\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73\ud83c\udffd", "\ud83d\udc73\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73\ud83c\udffe", "\ud83d\udc73\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc73\ud83c\udfff", "\ud83d\udc73\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd9", "\ud83e\uddd9\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd9\ud83c\udffb", "\ud83e\uddd9\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd9\ud83c\udffc", "\ud83e\uddd9\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd9\ud83c\udffd", "\ud83e\uddd9\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd9\ud83c\udffe", "\ud83e\uddd9\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd9\ud83c\udfff", "\ud83e\uddd9\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda", "\ud83e\uddda\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda\ud83c\udffb", "\ud83e\uddda\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda\ud83c\udffc", "\ud83e\uddda\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda\ud83c\udffd", "\ud83e\uddda\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda\ud83c\udffe", "\ud83e\uddda\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddda\ud83c\udfff", "\ud83e\uddda\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb", "\ud83e\udddb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb\ud83c\udffb", "\ud83e\udddb\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb\ud83c\udffc", "\ud83e\udddb\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb\ud83c\udffd", "\ud83e\udddb\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb\ud83c\udffe", "\ud83e\udddb\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddb\ud83c\udfff", "\ud83e\udddb\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\udddc", "\ud83e\udddc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddc\ud83c\udffb", "\ud83e\udddc\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddc\ud83c\udffc", "\ud83e\udddc\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddc\ud83c\udffd", "\ud83e\udddc\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddc\ud83c\udffe", "\ud83e\udddc\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddc\ud83c\udfff", "\ud83e\udddc\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd", "\ud83e\udddd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd\ud83c\udffb", "\ud83e\udddd\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd\ud83c\udffc", "\ud83e\udddd\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd\ud83c\udffd", "\ud83e\udddd\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd\ud83c\udffe", "\ud83e\udddd\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddd\ud83c\udfff", "\ud83e\udddd\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddde", "\ud83e\uddde\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\udddf", "\ud83e\udddf\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc86", "\ud83d\udc86\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc86\ud83c\udffb", "\ud83d\udc86\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc86\ud83c\udffc", "\ud83d\udc86\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc86\ud83c\udffd", "\ud83d\udc86\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc86\ud83c\udffe", "\ud83d\udc86\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc86\ud83c\udfff", "\ud83d\udc86\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87", "\ud83d\udc87\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87\ud83c\udffb", "\ud83d\udc87\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87\ud83c\udffc", "\ud83d\udc87\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87\ud83c\udffd", "\ud83d\udc87\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87\ud83c\udffe", "\ud83d\udc87\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc87\ud83c\udfff", "\ud83d\udc87\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udeb6", "\ud83d\udeb6\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb6\ud83c\udffb", "\ud83d\udeb6\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb6\ud83c\udffc", "\ud83d\udeb6\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb6\ud83c\udffd", "\ud83d\udeb6\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb6\ud83c\udffe", "\ud83d\udeb6\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb6\ud83c\udfff", "\ud83d\udeb6\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3", "\ud83c\udfc3\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3\ud83c\udffb", "\ud83c\udfc3\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3\ud83c\udffc", "\ud83c\udfc3\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3\ud83c\udffd", "\ud83c\udfc3\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3\ud83c\udffe", "\ud83c\udfc3\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc3\ud83c\udfff", "\ud83c\udfc3\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udc6f", "\ud83d\udc6f\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd6", "\ud83e\uddd6\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd6\ud83c\udffb", "\ud83e\uddd6\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd6\ud83c\udffc", "\ud83e\uddd6\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd6\ud83c\udffd", "\ud83e\uddd6\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd6\ud83c\udffe", "\ud83e\uddd6\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd6\ud83c\udfff", "\ud83e\uddd6\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd7", "\ud83e\uddd7\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd7\ud83c\udffb", "\ud83e\uddd7\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd7\ud83c\udffc", "\ud83e\uddd7\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd7\ud83c\udffd", "\ud83e\uddd7\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd7\ud83c\udffe", "\ud83e\uddd7\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd7\ud83c\udfff", "\ud83e\uddd7\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ufe0f", "\ud83c\udfcc\ufe0f\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ud83c\udffb", "\ud83c\udfcc\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ud83c\udffc", "\ud83c\udfcc\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ud83c\udffd", "\ud83c\udfcc\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ud83c\udffe", "\ud83c\udfcc\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcc\ud83c\udfff", "\ud83c\udfcc\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4", "\ud83c\udfc4\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4\ud83c\udffb", "\ud83c\udfc4\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4\ud83c\udffc", "\ud83c\udfc4\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4\ud83c\udffd", "\ud83c\udfc4\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4\ud83c\udffe", "\ud83c\udfc4\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfc4\ud83c\udfff", "\ud83c\udfc4\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3", "\ud83d\udea3\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3\ud83c\udffb", "\ud83d\udea3\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3\ud83c\udffc", "\ud83d\udea3\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3\ud83c\udffd", "\ud83d\udea3\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3\ud83c\udffe", "\ud83d\udea3\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udea3\ud83c\udfff", "\ud83d\udea3\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca", "\ud83c\udfca\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca\ud83c\udffb", "\ud83c\udfca\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca\ud83c\udffc", "\ud83c\udfca\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca\ud83c\udffd", "\ud83c\udfca\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca\ud83c\udffe", "\ud83c\udfca\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfca\ud83c\udfff", "\ud83c\udfca\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ufe0f", "\u26f9\ufe0f\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ud83c\udffb", "\u26f9\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ud83c\udffc", "\u26f9\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ud83c\udffd", "\u26f9\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ud83c\udffe", "\u26f9\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\u26f9\ud83c\udfff", "\u26f9\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ufe0f", "\ud83c\udfcb\ufe0f\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ud83c\udffb", "\ud83c\udfcb\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ud83c\udffc", "\ud83c\udfcb\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ud83c\udffd", "\ud83c\udfcb\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ud83c\udffe", "\ud83c\udfcb\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83c\udfcb\ud83c\udfff", "\ud83c\udfcb\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4", "\ud83d\udeb4\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4\ud83c\udffb", "\ud83d\udeb4\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4\ud83c\udffc", "\ud83d\udeb4\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4\ud83c\udffd", "\ud83d\udeb4\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4\ud83c\udffe", "\ud83d\udeb4\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb4\ud83c\udfff", "\ud83d\udeb4\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5", "\ud83d\udeb5\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5\ud83c\udffb", "\ud83d\udeb5\ud83c\udffb\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5\ud83c\udffc", "\ud83d\udeb5\ud83c\udffc\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5\ud83c\udffd", "\ud83d\udeb5\ud83c\udffd\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5\ud83c\udffe", "\ud83d\udeb5\ud83c\udffe\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83d\udeb5\ud83c\udfff", "\ud83d\udeb5\ud83c\udfff\u200d\u2642\ufe0f")); + add(new Pair<>("\ud83e\uddd8", "\ud83e\uddd8\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd8\ud83c\udffb", "\ud83e\uddd8\ud83c\udffb\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd8\ud83c\udffc", "\ud83e\uddd8\ud83c\udffc\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd8\ud83c\udffd", "\ud83e\uddd8\ud83c\udffd\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd8\ud83c\udffe", "\ud83e\uddd8\ud83c\udffe\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83e\uddd8\ud83c\udfff", "\ud83e\uddd8\ud83c\udfff\u200d\u2640\ufe0f")); + add(new Pair<>("\ud83d\udc8f", "\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68")); + add(new Pair<>("\ud83d\udc91", "\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc68")); + add(new Pair<>("\ud83d\udc6a", "\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66")); + }}; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java new file mode 100644 index 00000000..72939dff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java @@ -0,0 +1,192 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo; +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiPageBitmap; +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree; +import org.thoughtcrime.securesms.util.FutureTaskListener; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +class EmojiProvider { + + private static final String TAG = EmojiProvider.class.getSimpleName(); + private static volatile EmojiProvider instance = null; + private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); + + private final EmojiTree emojiTree = new EmojiTree(); + + private static final int EMOJI_RAW_HEIGHT = 64; + private static final int EMOJI_RAW_WIDTH = 64; + private static final int EMOJI_VERT_PAD = 0; + private static final int EMOJI_PER_ROW = 16; + + private final float decodeScale; + private final float verticalPad; + + public static EmojiProvider getInstance(Context context) { + if (instance == null) { + synchronized (EmojiProvider.class) { + if (instance == null) { + instance = new EmojiProvider(context); + } + } + } + return instance; + } + + private EmojiProvider(Context context) { + this.decodeScale = Math.min(1f, context.getResources().getDimension(R.dimen.emoji_drawer_size) / EMOJI_RAW_HEIGHT); + this.verticalPad = EMOJI_VERT_PAD * this.decodeScale; + + for (EmojiPageModel page : EmojiPages.DATA_PAGES) { + if (page.hasSpriteMap()) { + EmojiPageBitmap pageBitmap = new EmojiPageBitmap(context, page, decodeScale); + + List emojis = page.getEmoji(); + for (int i = 0; i < emojis.size(); i++) { + emojiTree.add(emojis.get(i), new EmojiDrawInfo(pageBitmap, i)); + } + } + } + + for (Pair obsolete : EmojiPages.OBSOLETE) { + emojiTree.add(obsolete.first(), emojiTree.getEmoji(obsolete.second(), 0, obsolete.second().length())); + } + } + + @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) { + if (text == null) return null; + return new EmojiParser(emojiTree).findCandidates(text); + } + + @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) { + return emojify(getCandidates(text), text, tv); + } + + @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches, + @Nullable CharSequence text, + @NonNull TextView tv) { + if (matches == null || text == null) return null; + SpannableStringBuilder builder = new SpannableStringBuilder(text); + + for (EmojiParser.Candidate candidate : matches) { + Drawable drawable = getEmojiDrawable(candidate.getDrawInfo()); + + if (drawable != null) { + builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + return builder; + } + + @Nullable Drawable getEmojiDrawable(CharSequence emoji) { + EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length()); + return getEmojiDrawable(drawInfo); + } + + private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) { + if (drawInfo == null) { + return null; + } + + final EmojiDrawable drawable = new EmojiDrawable(drawInfo, decodeScale); + drawInfo.getPage().get().addListener(new FutureTaskListener() { + @Override public void onSuccess(final Bitmap result) { + Util.runOnMain(() -> drawable.setBitmap(result)); + } + + @Override public void onFailure(ExecutionException error) { + Log.w(TAG, error); + } + }); + return drawable; + } + + class EmojiDrawable extends Drawable { + private final EmojiDrawInfo info; + private Bitmap bmp; + private float intrinsicWidth; + private float intrinsicHeight; + + @Override + public int getIntrinsicWidth() { + return (int)intrinsicWidth; + } + + @Override + public int getIntrinsicHeight() { + return (int)intrinsicHeight; + } + + EmojiDrawable(EmojiDrawInfo info, float decodeScale) { + this.info = info; + this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale; + this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale; + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (bmp == null) { + return; + } + + final int row = info.getIndex() / EMOJI_PER_ROW; + final int row_index = info.getIndex() % EMOJI_PER_ROW; + + canvas.drawBitmap(bmp, + new Rect((int)(row_index * intrinsicWidth), + (int)(row * intrinsicHeight + row * verticalPad)+1, + (int)(((row_index + 1) * intrinsicWidth)-1), + (int)((row + 1) * intrinsicHeight + row * verticalPad)-1), + getBounds(), + paint); + } + + @TargetApi(VERSION_CODES.HONEYCOMB_MR1) + public void setBitmap(Bitmap bitmap) { + Util.assertMainThread(); + if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) { + bmp = bitmap; + invalidateSelf(); + } + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public void setAlpha(int alpha) { } + + @Override + public void setColorFilter(ColorFilter cf) { } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java new file mode 100644 index 00000000..1733cb5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.FontMetricsInt; +import android.graphics.drawable.Drawable; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public class EmojiSpan extends AnimatingImageSpan { + + private final float SHIFT_FACTOR = 1.5f; + + private final int size; + private final FontMetricsInt fm; + + public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) { + super(drawable, tv); + fm = tv.getPaint().getFontMetricsInt(); + size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent) + : tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size); + getDrawable().setBounds(0, 0, size, size); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) { + if (fm != null && this.fm != null) { + fm.ascent = this.fm.ascent; + fm.descent = this.fm.descent; + fm.top = this.fm.top; + fm.bottom = this.fm.bottom; + fm.leading = this.fm.leading; + return size; + } else { + return super.getSize(paint, text, start, end, fm); + } + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + int height = bottom - top; + int centeringMargin = (height - size) / 2; + int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR); + super.draw(canvas, text, start, end, x, top, y, bottom - adjustedMargin, paint); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java new file mode 100644 index 00000000..0e09e621 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.components.emoji; + +public final class EmojiStrings { + public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64"; + public static final String PHOTO = "\uD83D\uDCF7"; + public static final String VIDEO = "\uD83C\uDFA5"; + public static final String GIF = "\uD83C\uDFA1"; + public static final String AUDIO = "\uD83C\uDFA4"; + public static final String FILE = "\uD83D\uDCCE"; + public static final String STICKER = "\u2B50"; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java new file mode 100644 index 00000000..7448cd95 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -0,0 +1,256 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.text.Annotation; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.ViewGroup; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; +import androidx.core.widget.TextViewCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; + + +public class EmojiTextView extends AppCompatTextView { + + private final boolean scaleEmojis; + private final boolean forceCustom; + + private static final char ELLIPSIS = '…'; + + private CharSequence previousText; + private BufferType previousBufferType; + private float originalFontSize; + private boolean useSystemEmoji; + private boolean sizeChangeInProgress; + private int maxLength; + private CharSequence overflowText; + private CharSequence previousOverflowText; + private boolean renderMentions; + + private MentionRendererDelegate mentionRendererDelegate; + + public EmojiTextView(Context context) { + this(context, null); + } + + public EmojiTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); + scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); + maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); + forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); + renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true); + a.recycle(); + + a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize}); + originalFontSize = a.getDimensionPixelSize(0, 0); + a.recycle(); + + if (renderMentions) { + mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20)); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (renderMentions && getText() instanceof Spanned && getLayout() != null) { + int checkpoint = canvas.save(); + canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); + try { + mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout()); + } finally { + canvas.restoreToCount(checkpoint); + } + } + super.onDraw(canvas); + } + + @Override public void setText(@Nullable CharSequence text, BufferType type) { + EmojiProvider provider = EmojiProvider.getInstance(getContext()); + EmojiParser.CandidateList candidates = provider.getCandidates(text); + + if (scaleEmojis && candidates != null && candidates.allEmojis) { + int emojis = candidates.size(); + float scale = 1.0f; + + if (emojis <= 8) scale += 0.25f; + if (emojis <= 6) scale += 0.25f; + if (emojis <= 4) scale += 0.25f; + if (emojis <= 2) scale += 0.25f; + + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale); + } else if (scaleEmojis) { + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize); + } + + if (unchanged(text, overflowText, type)) { + return; + } + + previousText = text; + previousOverflowText = overflowText; + previousBufferType = type; + useSystemEmoji = useSystemEmoji(); + + if (useSystemEmoji || candidates == null || candidates.size() == 0) { + super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")).append(Optional.fromNullable(overflowText).or("")), BufferType.NORMAL); + + if (getEllipsize() == TextUtils.TruncateAt.END && maxLength > 0) { + ellipsizeAnyTextForMaxLength(); + } + } else { + CharSequence emojified = provider.emojify(candidates, text, this); + super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE); + + // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) + // We ellipsize them ourselves by manually truncating the appropriate section. + if (getEllipsize() == TextUtils.TruncateAt.END) { + if (maxLength > 0) { + ellipsizeAnyTextForMaxLength(); + } else { + ellipsizeEmojiTextForMaxLines(); + } + } + } + + if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) { + requestLayout(); + } + } + + public void setOverflowText(@Nullable CharSequence overflowText) { + this.overflowText = overflowText; + setText(previousText, BufferType.SPANNABLE); + } + + private void ellipsizeAnyTextForMaxLength() { + if (maxLength > 0 && getText().length() > maxLength + 1) { + SpannableStringBuilder newContent = new SpannableStringBuilder(); + + CharSequence shortenedText = getText().subSequence(0, maxLength); + if (shortenedText instanceof Spanned) { + Spanned spanned = (Spanned) shortenedText; + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength); + if (!mentionAnnotations.isEmpty()) { + shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0))); + } + } + + newContent.append(shortenedText) + .append(ELLIPSIS) + .append(Util.emptyIfNull(overflowText)); + + EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent); + + if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) { + super.setText(newContent, BufferType.NORMAL); + } else { + CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this); + super.setText(emojified, BufferType.SPANNABLE); + } + } + } + + private void ellipsizeEmojiTextForMaxLines() { + post(() -> { + if (getLayout() == null) { + ellipsizeEmojiTextForMaxLines(); + return; + } + + int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this); + if (maxLines <= 0 && maxLength < 0) { + return; + } + + int lineCount = getLineCount(); + if (lineCount > maxLines) { + int overflowStart = getLayout().getLineStart(maxLines - 1); + CharSequence overflow = getText().subSequence(overflowStart, getText().length()); + CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth(), TextUtils.TruncateAt.END); + + SpannableStringBuilder newContent = new SpannableStringBuilder(); + newContent.append(getText().subSequence(0, overflowStart)) + .append(ellipsized.subSequence(0, ellipsized.length())) + .append(Optional.fromNullable(overflowText).or("")); + + EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent); + CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this); + + super.setText(emojified, BufferType.SPANNABLE); + } + }); + } + + private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { + return Util.equals(previousText, text) && + Util.equals(previousOverflowText, overflowText) && + Util.equals(previousBufferType, bufferType) && + useSystemEmoji == useSystemEmoji() && + !sizeChangeInProgress; + } + + private boolean useSystemEmoji() { + return !forceCustom && TextSecurePreferences.isSystemEmojiPreferred(getContext()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + if (!sizeChangeInProgress) { + sizeChangeInProgress = true; + setText(previousText, previousBufferType); + sizeChangeInProgress = false; + } + } + + @Override + public void invalidateDrawable(@NonNull Drawable drawable) { + if (drawable instanceof EmojiDrawable) invalidate(); + else super.invalidateDrawable(drawable); + } + + @Override + public void setTextSize(float size) { + setTextSize(TypedValue.COMPLEX_UNIT_SP, size); + } + + @Override + public void setTextSize(int unit, float size) { + this.originalFontSize = TypedValue.applyDimension(unit, size, getResources().getDisplayMetrics()); + super.setTextSize(unit, size); + } + + public void setMentionBackgroundTint(@ColorInt int mentionBackgroundTint) { + if (renderMentions) { + mentionRendererDelegate.setTint(mentionBackgroundTint); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java new file mode 100644 index 00000000..b0787e0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener { + + private Drawable emojiToggle; + private Drawable stickerToggle; + + private Drawable mediaToggle; + private Drawable imeToggle; + + + public EmojiToggle(Context context) { + super(context); + initialize(); + } + + public EmojiToggle(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public EmojiToggle(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(); + } + + public void setToMedia() { + setImageDrawable(mediaToggle); + } + + public void setToIme() { + setImageDrawable(imeToggle); + } + + private void initialize() { + this.emojiToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_emoji_smiley_24); + this.stickerToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_sticker_24); + this.imeToggle = ContextCompat.getDrawable(getContext(), R.drawable.ic_keyboard_24); + this.mediaToggle = emojiToggle; + + setToMedia(); + } + + public void attach(MediaKeyboard drawer) { + drawer.setKeyboardListener(this); + } + + public void setStickerMode(boolean stickerMode) { + this.mediaToggle = stickerMode ? stickerToggle : emojiToggle; + + if (getDrawable() != imeToggle) { + setToMedia(); + } + } + + public boolean isStickerMode() { + return this.mediaToggle == stickerToggle; + } + + @Override public void onShown() { + setToIme(); + } + + @Override public void onHidden() { + setToMedia(); + } + + @Override + public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) { + setStickerMode(provider instanceof StickerKeyboardProvider); + TextSecurePreferences.setMediaKeyboardMode(getContext(), (provider instanceof StickerKeyboardProvider) ? TextSecurePreferences.MediaKeyboardMode.STICKER + : TextSecurePreferences.MediaKeyboardMode.EMOJI); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java new file mode 100644 index 00000000..ec8e7d2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class EmojiUtil { + + private static final Map VARIATION_MAP = new HashMap<>(); + + static { + for (EmojiPageModel page : EmojiPages.DATA_PAGES) { + for (Emoji emoji : page.getDisplayEmoji()) { + for (String variation : emoji.getVariations()) { + VARIATION_MAP.put(variation, emoji.getValue()); + } + } + } + } + + public static final int MAX_EMOJI_LENGTH; + static { + int max = 0; + for (EmojiPageModel page : EmojiPages.DATA_PAGES) { + for (String emoji : page.getEmoji()) { + max = Math.max(max, emoji.length()); + } + } + MAX_EMOJI_LENGTH = max; + } + + private EmojiUtil() {} + + public static List getDisplayPages() { + return EmojiPages.DISPLAY_PAGES; + } + + /** + * This will return all ways we know of expressing a singular emoji. This is to aid in search, + * where some platforms may send an emoji we've locally marked as 'obsolete'. + */ + public static @NonNull Set getAllRepresentations(@NonNull String emoji) { + Set out = new HashSet<>(); + + out.add(emoji); + + for (Pair pair : EmojiPages.OBSOLETE) { + if (pair.first().equals(emoji)) { + out.add(pair.second()); + } else if (pair.second().equals(emoji)) { + out.add(pair.first()); + } + } + + return out; + } + + /** + * When provided an emoji that is a skin variation of another, this will return the default yellow + * version. This is to aid in search, so using a variation will still find all emojis tagged with + * the default version. + * + * If the emoji has no skin variations, this function will return the original emoji. + */ + public static @NonNull String getCanonicalRepresentation(@NonNull String emoji) { + String canonical = VARIATION_MAP.get(emoji); + return canonical != null ? canonical : emoji; + } + + /** + * Converts the provided emoji string into a single drawable, if possible. + */ + public static @Nullable Drawable convertToDrawable(@NonNull Context context, @Nullable String emoji) { + if (Util.isEmpty(emoji)) { + return null; + } else { + return EmojiProvider.getInstance(context).getEmojiDrawable(emoji); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiVariationSelectorPopup.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiVariationSelectorPopup.java new file mode 100644 index 00000000..6dee2824 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiVariationSelectorPopup.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.os.Build; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener; + +import java.util.List; + +public class EmojiVariationSelectorPopup extends PopupWindow { + + private final Context context; + private final ViewGroup list; + private final EmojiEventListener listener; + + public EmojiVariationSelectorPopup(@NonNull Context context, @NonNull EmojiEventListener listener) { + super(LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector, null), + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + this.context = context; + this.listener = listener; + this.list = (ViewGroup) getContentView().findViewById(R.id.emoji_variation_container); + + setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.emoji_variation_selector_background)); + setOutsideTouchable(true); + + if (Build.VERSION.SDK_INT >= 21) { + setElevation(20); + } + } + + public void setVariations(List variations) { + list.removeAllViews(); + + for (String variation : variations) { + ImageView imageView = (ImageView) LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector_item, list, false); + imageView.setImageDrawable(EmojiProvider.getInstance(context).getEmojiDrawable(variation)); + imageView.setOnClickListener(v -> { + listener.onEmojiSelected(variation); + dismiss(); + }); + list.addView(imageView); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java new file mode 100644 index 00000000..d632dece --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java @@ -0,0 +1,297 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.InputAwareLayout.InputView; +import org.thoughtcrime.securesms.components.RepeatableImageKey; +import org.thoughtcrime.securesms.mms.GlideApp; + +import java.util.Arrays; + +public class MediaKeyboard extends FrameLayout implements InputView, + MediaKeyboardProvider.Presenter, + MediaKeyboardProvider.Controller, + MediaKeyboardBottomTabAdapter.EventListener +{ + + private static final String TAG = Log.tag(MediaKeyboard.class); + + private RecyclerView categoryTabs; + private ViewPager categoryPager; + private ViewGroup providerTabs; + private RepeatableImageKey backspaceButton; + private RepeatableImageKey backspaceButtonBackup; + private View searchButton; + private View addButton; + @Nullable private MediaKeyboardListener keyboardListener; + private MediaKeyboardProvider[] providers; + private int providerIndex; + + private final boolean tabsAtBottom; + + private MediaKeyboardBottomTabAdapter categoryTabAdapter; + + public MediaKeyboard(Context context) { + this(context, null); + } + + public MediaKeyboard(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MediaKeyboard, 0, 0); + + try { + tabsAtBottom = typedArray.getInt(R.styleable.MediaKeyboard_tabs_gravity, 0) == 0; + } finally { + typedArray.recycle(); + } + } + + public void setProviders(int startIndex, MediaKeyboardProvider... providers) { + if (!Arrays.equals(this.providers, providers)) { + this.providers = providers; + this.providerIndex = startIndex; + + requestPresent(providers, providerIndex); + } + } + + public void setKeyboardListener(@Nullable MediaKeyboardListener listener) { + this.keyboardListener = listener; + } + + @Override + public boolean isShowing() { + return getVisibility() == VISIBLE; + } + + @Override + public void show(int height, boolean immediate) { + if (this.categoryPager == null) initView(); + + ViewGroup.LayoutParams params = getLayoutParams(); + params.height = height; + Log.i(TAG, "showing emoji drawer with height " + params.height); + setLayoutParams(params); + + show(); + } + + public void show() { + if (this.categoryPager == null) initView(); + + setVisibility(VISIBLE); + if (keyboardListener != null) keyboardListener.onShown(); + + requestPresent(providers, providerIndex); + } + + @Override + public void hide(boolean immediate) { + setVisibility(GONE); + if (keyboardListener != null) keyboardListener.onHidden(); + Log.i(TAG, "hide()"); + } + + @Override + public void present(@NonNull MediaKeyboardProvider provider, + @NonNull PagerAdapter pagerAdapter, + @NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider, + @Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, + @Nullable MediaKeyboardProvider.AddObserver addObserver, + @Nullable MediaKeyboardProvider.SearchObserver searchObserver, + int startingIndex) + { + if (categoryPager == null) return; + if (!provider.equals(providers[providerIndex])) return; + if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(provider); + + boolean isSolo = providers.length == 1; + + presentProviderStrip(isSolo); + presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex); + presentProviderTabs(providers, providerIndex); + presentSearchButton(searchObserver); + presentBackspaceButton(backspaceObserver, isSolo); + presentAddButton(addObserver); + } + + @Override + public int getCurrentPosition() { + return categoryPager != null ? categoryPager.getCurrentItem() : 0; + } + + @Override + public void requestDismissal() { + hide(true); + providerIndex = 0; + if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(providers[providerIndex]); + } + + @Override + public boolean isVisible() { + return getVisibility() == View.VISIBLE; + } + + @Override + public void onTabSelected(int index) { + if (categoryPager != null) { + categoryPager.setCurrentItem(index); + categoryTabs.smoothScrollToPosition(index); + } + } + + @Override + public void setViewPagerEnabled(boolean enabled) { + if (categoryPager != null) { + categoryPager.setEnabled(enabled); + } + } + + private void initView() { + final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true); + + RecyclerView categoryTabsTop = view.findViewById(R.id.media_keyboard_tabs_top); + RecyclerView categoryTabsBottom = view.findViewById(R.id.media_keyboard_tabs); + + this.categoryTabs = tabsAtBottom ? categoryTabsBottom : categoryTabsTop; + this.categoryPager = view.findViewById(R.id.media_keyboard_pager); + this.providerTabs = view.findViewById(R.id.media_keyboard_provider_tabs); + this.backspaceButton = view.findViewById(R.id.media_keyboard_backspace); + this.backspaceButtonBackup = view.findViewById(R.id.media_keyboard_backspace_backup); + this.searchButton = view.findViewById(R.id.media_keyboard_search); + this.addButton = view.findViewById(R.id.media_keyboard_add); + + this.categoryTabAdapter = new MediaKeyboardBottomTabAdapter(GlideApp.with(this), this, tabsAtBottom); + + categoryTabs.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false)); + categoryTabs.setAdapter(categoryTabAdapter); + categoryTabs.setVisibility(VISIBLE); + } + + private void requestPresent(@NonNull MediaKeyboardProvider[] providers, int newIndex) { + providers[providerIndex].setController(null); + providerIndex = newIndex; + + providers[providerIndex].setController(this); + providers[providerIndex].requestPresentation(this, providers.length == 1); + } + + + private void presentCategoryPager(@NonNull PagerAdapter pagerAdapter, + @NonNull MediaKeyboardProvider.TabIconProvider iconProvider, + int startingIndex) { + if (categoryPager.getAdapter() != pagerAdapter) { + categoryPager.setAdapter(pagerAdapter); + } + + categoryPager.setCurrentItem(startingIndex); + + categoryPager.clearOnPageChangeListeners(); + categoryPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled(int i, float v, int i1) { + } + + @Override + public void onPageSelected(int i) { + categoryTabAdapter.setActivePosition(i); + categoryTabs.smoothScrollToPosition(i); + providers[providerIndex].setCurrentPosition(i); + } + + @Override + public void onPageScrollStateChanged(int i) { + } + }); + + categoryTabAdapter.setTabIconProvider(iconProvider, pagerAdapter.getCount()); + categoryTabAdapter.setActivePosition(startingIndex); + } + + private void presentProviderTabs(@NonNull MediaKeyboardProvider[] providers, int selected) { + providerTabs.removeAllViews(); + + LayoutInflater inflater = LayoutInflater.from(getContext()); + + for (int i = 0; i < providers.length; i++) { + MediaKeyboardProvider provider = providers[i]; + View view = inflater.inflate(provider.getProviderIconView(i == selected), providerTabs, false); + + view.setTag(provider); + + final int index = i; + view.setOnClickListener(v -> { + requestPresent(providers, index); + }); + + providerTabs.addView(view); + } + } + + private void presentBackspaceButton(@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, + boolean useBackupPosition) + { + if (backspaceObserver != null) { + if (useBackupPosition) { + backspaceButton.setVisibility(INVISIBLE); + backspaceButton.setOnKeyEventListener(null); + backspaceButtonBackup.setVisibility(VISIBLE); + backspaceButtonBackup.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); + } else { + backspaceButton.setVisibility(VISIBLE); + backspaceButton.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); + backspaceButtonBackup.setVisibility(GONE); + backspaceButtonBackup.setOnKeyEventListener(null); + } + } else { + backspaceButton.setVisibility(INVISIBLE); + backspaceButton.setOnKeyEventListener(null); + backspaceButtonBackup.setVisibility(GONE); + backspaceButton.setOnKeyEventListener(null); + } + } + + private void presentAddButton(@Nullable MediaKeyboardProvider.AddObserver addObserver) { + if (addObserver != null) { + addButton.setVisibility(VISIBLE); + addButton.setOnClickListener(v -> addObserver.onAddClicked()); + } else { + addButton.setVisibility(GONE); + addButton.setOnClickListener(null); + } + } + + private void presentSearchButton(@Nullable MediaKeyboardProvider.SearchObserver searchObserver) { + searchButton.setVisibility(searchObserver != null ? VISIBLE : INVISIBLE); + } + + private void presentProviderStrip(boolean isSolo) { + int visibility = isSolo ? View.GONE : View.VISIBLE; + + searchButton.setVisibility(visibility); + backspaceButton.setVisibility(visibility); + providerTabs.setVisibility(visibility); + } + + public interface MediaKeyboardListener { + void onShown(); + void onHidden(); + void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java new file mode 100644 index 00000000..92176b32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider; +import org.thoughtcrime.securesms.mms.GlideRequests; + +public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final boolean highlightTop; + + private TabIconProvider tabIconProvider; + private int activePosition; + private int count; + + public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean highlightTop) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.highlightTop = highlightTop; + } + + @Override + public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false), + highlightTop); + } + + @Override + public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) { + viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition); + } + + @Override + public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return count; + } + + public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) { + this.tabIconProvider = iconProvider; + this.count = count; + + notifyDataSetChanged(); + } + + public void setActivePosition(int position) { + this.activePosition = position; + notifyDataSetChanged(); + } + + static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder { + + private final ImageView image; + private final View indicator; + + public MediaKeyboardBottomTabViewHolder(@NonNull View itemView, boolean highlightTop) { + super(itemView); + + View indicatorTop = itemView.findViewById(R.id.media_keyboard_top_tab_indicator); + View indicatorBottom = itemView.findViewById(R.id.media_keyboard_bottom_tab_indicator); + + this.image = itemView.findViewById(R.id.media_keyboard_bottom_tab_image); + this.indicator = highlightTop ? indicatorTop : indicatorBottom; + + this.indicator.setVisibility(View.VISIBLE); + } + + void bind(@NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull TabIconProvider tabIconProvider, + int index, + boolean selected) + { + tabIconProvider.loadCategoryTabIcon(glideRequests, image, index); + image.setAlpha(selected ? 1 : 0.5f); + image.setSelected(selected); + + indicator.setVisibility(selected ? View.VISIBLE : View.INVISIBLE); + + itemView.setOnClickListener(v -> eventListener.onTabSelected(index)); + } + + void recycle() { + itemView.setOnClickListener(null); + } + } + + interface EventListener { + void onTabSelected(int index); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java new file mode 100644 index 00000000..44884635 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.widget.ImageView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.PagerAdapter; + +import org.thoughtcrime.securesms.mms.GlideRequests; + +public interface MediaKeyboardProvider { + @LayoutRes int getProviderIconView(boolean selected); + /** @return True if the click was handled with provider-specific logic, otherwise false */ + void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider); + void setController(@Nullable Controller controller); + void setCurrentPosition(int currentPosition); + + interface BackspaceObserver { + void onBackspaceClicked(); + } + + interface AddObserver { + void onAddClicked(); + } + + interface SearchObserver { + void onSearchOpened(); + void onSearchClosed(); + void onSearchChanged(@NonNull String query); + } + + interface Controller { + void setViewPagerEnabled(boolean enabled); + } + + interface Presenter { + void present(@NonNull MediaKeyboardProvider provider, + @NonNull PagerAdapter pagerAdapter, + @NonNull TabIconProvider iconProvider, + @Nullable BackspaceObserver backspaceObserver, + @Nullable AddObserver addObserver, + @Nullable SearchObserver searchObserver, + int startingIndex); + int getCurrentPosition(); + void requestDismissal(); + boolean isVisible(); + } + + interface TabIconProvider { + void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java new file mode 100644 index 00000000..0291a5a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.fasterxml.jackson.databind.type.TypeFactory; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; + +public class RecentEmojiPageModel implements EmojiPageModel { + private static final String TAG = RecentEmojiPageModel.class.getSimpleName(); + private static final int EMOJI_LRU_SIZE = 50; + + private final SharedPreferences prefs; + private final String preferenceName; + private final LinkedHashSet recentlyUsed; + + public RecentEmojiPageModel(Context context, @NonNull String preferenceName) { + this.prefs = PreferenceManager.getDefaultSharedPreferences(context); + this.preferenceName = preferenceName; + this.recentlyUsed = getPersistedCache(); + } + + private LinkedHashSet getPersistedCache() { + String serialized = prefs.getString(preferenceName, "[]"); + try { + CollectionType collectionType = TypeFactory.defaultInstance() + .constructCollectionType(LinkedHashSet.class, String.class); + return JsonUtils.getMapper().readValue(serialized, collectionType); + } catch (IOException e) { + Log.w(TAG, e); + return new LinkedHashSet<>(); + } + } + + @Override public int getIconAttr() { + return R.attr.emoji_category_recent; + } + + @Override public List getEmoji() { + List emoji = new ArrayList<>(recentlyUsed); + Collections.reverse(emoji); + return emoji; + } + + @Override public List getDisplayEmoji() { + return Stream.of(getEmoji()).map(Emoji::new).toList(); + } + + @Override public boolean hasSpriteMap() { + return false; + } + + @Override public String getSprite() { + return null; + } + + @Override public boolean isDynamic() { + return true; + } + + @MainThread + public void onCodePointSelected(String emoji) { + recentlyUsed.remove(emoji); + recentlyUsed.add(emoji); + + if (recentlyUsed.size() > EMOJI_LRU_SIZE) { + Iterator iterator = recentlyUsed.iterator(); + iterator.next(); + iterator.remove(); + } + + final LinkedHashSet latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed); + SignalExecutors.BOUNDED.execute(() -> { + try { + String serialized = JsonUtils.toJson(latestRecentlyUsed); + prefs.edit() + .putString(preferenceName, serialized) + .apply(); + } catch (IOException e) { + Log.w(TAG, e); + } + }); + } + + private String[] toReversePrimitiveArray(@NonNull LinkedHashSet emojiSet) { + String[] emojis = new String[emojiSet.size()]; + int i = emojiSet.size() - 1; + for (String emoji : emojiSet) { + emojis[i--] = emoji; + } + return emojis; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/StaticEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/StaticEmojiPageModel.java new file mode 100644 index 00000000..e1b248b5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/StaticEmojiPageModel.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.components.emoji; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +public class StaticEmojiPageModel implements EmojiPageModel { + @AttrRes private final int iconAttr; + @NonNull private final List emoji; + @Nullable private final String sprite; + + public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable String sprite) { + List emoji = new ArrayList<>(strings.length); + for (String s : strings) { + emoji.add(new Emoji(s)); + } + + this.iconAttr = iconAttr; + this.emoji = emoji; + this.sprite = sprite; + } + + public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull Emoji[] emoji, @Nullable String sprite) { + this.iconAttr = iconAttr; + this.emoji = Arrays.asList(emoji); + this.sprite = sprite; + } + + public int getIconAttr() { + return iconAttr; + } + + @Override + public @NonNull List getEmoji() { + List emojis = new LinkedList<>(); + for (Emoji e : emoji) { + emojis.addAll(e.getVariations()); + } + return emojis; + } + + @Override + public @NonNull List getDisplayEmoji() { + return emoji; + } + + @Override + public boolean hasSpriteMap() { + return sprite != null; + } + + @Override + public @Nullable String getSprite() { + return sprite; + } + + @Override + public boolean isDynamic() { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/WarningTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/WarningTextView.java new file mode 100644 index 00000000..d74e7baf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/WarningTextView.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; + +import androidx.annotation.ColorInt; +import androidx.appcompat.widget.AppCompatTextView; + +import org.thoughtcrime.securesms.R; + +public final class WarningTextView extends AppCompatTextView { + + @ColorInt private final int originalTextColor; + @ColorInt private final int warningTextColor; + + private boolean warning; + + public WarningTextView(Context context) { + this(context, null); + } + + public WarningTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public WarningTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.WarningTextView, 0, 0); + warningTextColor = styledAttributes.getColor(R.styleable.WarningTextView_warning_text_color, 0); + + styledAttributes.recycle(); + + styledAttributes = context.obtainStyledAttributes(attrs, new int[]{ android.R.attr.textColor }); + + originalTextColor = styledAttributes.getColor(0, 0); + + styledAttributes.recycle(); + } + + public void setWarning(boolean warning) { + if (this.warning != warning) { + this.warning = warning; + setTextColor(warning ? warningTextColor : originalTextColor); + invalidate(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java new file mode 100644 index 00000000..387af40a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.emoji.parsing; + + +import androidx.annotation.NonNull; + +public class EmojiDrawInfo { + + private final EmojiPageBitmap page; + private final int index; + + public EmojiDrawInfo(final @NonNull EmojiPageBitmap page, final int index) { + this.page = page; + this.index = index; + } + + public @NonNull EmojiPageBitmap getPage() { + return page; + } + + public int getIndex() { + return index; + } + + @Override + public @NonNull String toString() { + return "DrawInfo{" + + "page=" + page + + ", index=" + index + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java new file mode 100644 index 00000000..6f52cfdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.components.emoji.parsing; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.AssetManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.emoji.EmojiPageModel; +import org.thoughtcrime.securesms.util.ListenableFutureTask; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.SoftReference; +import java.util.concurrent.Callable; + +public class EmojiPageBitmap { + + private static final String TAG = EmojiPageBitmap.class.getSimpleName(); + + private final Context context; + private final EmojiPageModel model; + private final float decodeScale; + + private SoftReference bitmapReference; + private ListenableFutureTask task; + + public EmojiPageBitmap(@NonNull Context context, @NonNull EmojiPageModel model, float decodeScale) { + this.context = context.getApplicationContext(); + this.model = model; + this.decodeScale = decodeScale; + } + + @SuppressLint("StaticFieldLeak") + public ListenableFutureTask get() { + Util.assertMainThread(); + + if (bitmapReference != null && bitmapReference.get() != null) { + return new ListenableFutureTask<>(bitmapReference.get()); + } else if (task != null) { + return task; + } else { + Callable callable = () -> { + try { + Log.i(TAG, "loading page " + model.getSprite()); + return loadPage(); + } catch (IOException ioe) { + Log.w(TAG, ioe); + } + return null; + }; + task = new ListenableFutureTask<>(callable); + SimpleTask.run(() -> { + task.run(); + return null; + }, + unused -> task = null); + } + return task; + } + + private Bitmap loadPage() throws IOException { + if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get(); + + + float scale = decodeScale; + AssetManager assetManager = context.getAssets(); + InputStream assetStream = assetManager.open(model.getSprite()); + BitmapFactory.Options options = new BitmapFactory.Options(); + + if (Util.isLowMemory(context)) { + Log.i(TAG, "Low memory detected. Changing sample size."); + options.inSampleSize = 2; + scale = decodeScale * 2; + } + + Stopwatch stopwatch = new Stopwatch(model.getSprite()); + Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options); + stopwatch.split("decode"); + + Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * scale), (int)(bitmap.getHeight() * scale), true); + stopwatch.split("scale"); + stopwatch.stop(TAG); + + bitmapReference = new SoftReference<>(scaledBitmap); + Log.i(TAG, "onPageLoaded(" + model.getSprite() + ") originalByteCount: " + bitmap.getByteCount() + + " scaledByteCount: " + scaledBitmap.getByteCount() + + " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight()); + return scaledBitmap; + } + + @Override + public @NonNull String toString() { + return model.getSprite(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java new file mode 100644 index 00000000..91450e10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2014-present Vincent DURMONT vdurmont@gmail.com + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package org.thoughtcrime.securesms.components.emoji.parsing; + + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** + * Based in part on code from emoji-java + */ +public class EmojiParser { + + private final EmojiTree emojiTree; + + public EmojiParser(EmojiTree emojiTree) { + this.emojiTree = emojiTree; + } + + public @NonNull CandidateList findCandidates(@Nullable CharSequence text) { + List results = new LinkedList<>(); + + if (text == null) { + return new CandidateList(results, false); + } + + boolean allEmojis = text.length() > 0; + + for (int i = 0; i < text.length(); i++) { + int emojiEnd = getEmojiEndPos(text, i); + + if (emojiEnd != -1) { + EmojiDrawInfo drawInfo = emojiTree.getEmoji(text, i, emojiEnd); + + if (emojiEnd + 2 <= text.length()) { + if (Fitzpatrick.fitzpatrickFromUnicode(text, emojiEnd) != null) { + emojiEnd += 2; + } + } + + results.add(new Candidate(i, emojiEnd, drawInfo)); + + i = emojiEnd - 1; + } else if (text.charAt(i) != ' '){ + allEmojis = false; + } + } + + allEmojis &= !results.isEmpty(); + + return new CandidateList(results, allEmojis); + } + + private int getEmojiEndPos(CharSequence text, int startPos) { + int best = -1; + + for (int j = startPos + 1; j <= text.length(); j++) { + EmojiTree.Matches status = emojiTree.isEmoji(text, startPos, j); + + if (status.exactMatch()) { + best = j; + } else if (status.impossibleMatch()) { + return best; + } + } + + return best; + } + + public static class Candidate { + + private final int startIndex; + private final int endIndex; + private final EmojiDrawInfo drawInfo; + + Candidate(int startIndex, int endIndex, EmojiDrawInfo drawInfo) { + this.startIndex = startIndex; + this.endIndex = endIndex; + this.drawInfo = drawInfo; + } + + public EmojiDrawInfo getDrawInfo() { + return drawInfo; + } + + public int getEndIndex() { + return endIndex; + } + + public int getStartIndex() { + return startIndex; + } + } + + public static class CandidateList implements Iterable { + public final List list; + public final boolean allEmojis; + + public CandidateList(List candidates, boolean allEmojis) { + this.list = candidates; + this.allEmojis = allEmojis; + } + + public int size() { + return list.size(); + } + + @Override + public @NonNull Iterator iterator() { + return list.iterator(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiTree.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiTree.java new file mode 100644 index 00000000..af8caa56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiTree.java @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2014-present Vincent DURMONT vdurmont@gmail.com + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package org.thoughtcrime.securesms.components.emoji.parsing; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Based in part on code from emoji-java + */ +public class EmojiTree { + + private final EmojiTreeNode root = new EmojiTreeNode(); + + private static final char TERMINATOR = '\ufe0f'; + + public void add(String emojiEncoding, EmojiDrawInfo emoji) { + EmojiTreeNode tree = root; + + for (char c: emojiEncoding.toCharArray()) { + if (!tree.hasChild(c)) { + tree.addChild(c); + } + + tree = tree.getChild(c); + } + + tree.setEmoji(emoji); + } + + public Matches isEmoji(CharSequence sequence, int startPosition, int endPosition) { + if (sequence == null) { + return Matches.POSSIBLY; + } + + EmojiTreeNode tree = root; + + for (int i=startPosition; i children = new HashMap<>(); + private EmojiDrawInfo emoji; + + public void setEmoji(EmojiDrawInfo emoji) { + this.emoji = emoji; + } + + public @Nullable EmojiDrawInfo getEmoji() { + return emoji; + } + + boolean hasChild(char child) { + return children.containsKey(child); + } + + void addChild(char child) { + children.put(child, new EmojiTreeNode()); + } + + EmojiTreeNode getChild(char child) { + return children.get(child); + } + + boolean isEndOfEmoji() { + return emoji != null; + } + } + + public enum Matches { + EXACTLY, POSSIBLY, IMPOSSIBLE; + + public boolean exactMatch() { + return this == EXACTLY; + } + + public boolean impossibleMatch() { + return this == IMPOSSIBLE; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/Fitzpatrick.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/Fitzpatrick.java new file mode 100644 index 00000000..68315a6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/Fitzpatrick.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.components.emoji.parsing; + + +public enum Fitzpatrick { + /** + * Fitzpatrick modifier of type 1/2 (pale white/white) + */ + TYPE_1_2("\uD83C\uDFFB"), + + /** + * Fitzpatrick modifier of type 3 (cream white) + */ + TYPE_3("\uD83C\uDFFC"), + + /** + * Fitzpatrick modifier of type 4 (moderate brown) + */ + TYPE_4("\uD83C\uDFFD"), + + /** + * Fitzpatrick modifier of type 5 (dark brown) + */ + TYPE_5("\uD83C\uDFFE"), + + /** + * Fitzpatrick modifier of type 6 (black) + */ + TYPE_6("\uD83C\uDFFF"); + + /** + * The unicode representation of the Fitzpatrick modifier + */ + public final String unicode; + + Fitzpatrick(String unicode) { + this.unicode = unicode; + } + + + public static Fitzpatrick fitzpatrickFromUnicode(CharSequence unicode, int index) { + for (Fitzpatrick v : values()) { + boolean match = true; + + for (int i=0;i untrustedRecords; + private final ResendListener resendListener; + + public UntrustedSendDialog(@NonNull Context context, + @NonNull String message, + @NonNull List untrustedRecords, + @NonNull ResendListener resendListener) + { + super(context); + this.untrustedRecords = untrustedRecords; + this.resendListener = resendListener; + + setTitle(R.string.UntrustedSendDialog_send_message); + setIcon(R.drawable.ic_warning); + setMessage(message); + setPositiveButton(R.string.UntrustedSendDialog_send, this); + setNegativeButton(android.R.string.cancel, null); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext()); + + SimpleTask.run(() -> { + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + for (IdentityRecord identityRecord : untrustedRecords) { + identityDatabase.setApproval(identityRecord.getRecipientId(), true); + } + } + + return null; + }, unused -> resendListener.onResendMessage()); + } + + public interface ResendListener { + public void onResendMessage(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java new file mode 100644 index 00000000..2fdfa454 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.components.identity; + + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; + +import java.util.List; + +public class UnverifiedBannerView extends LinearLayout { + + private static final String TAG = UnverifiedBannerView.class.getSimpleName(); + + private View container; + private TextView text; + private ImageView closeButton; + + public UnverifiedBannerView(Context context) { + super(context); + initialize(); + } + + public UnverifiedBannerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB) + public UnverifiedBannerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public UnverifiedBannerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + private void initialize() { + LayoutInflater.from(getContext()).inflate(R.layout.unverified_banner_view, this, true); + this.container = findViewById(R.id.container); + this.text = findViewById(R.id.unverified_text); + this.closeButton = findViewById(R.id.cancel); + } + + public void display(@NonNull final String text, + @NonNull final List unverifiedIdentities, + @NonNull final ClickListener clickListener, + @NonNull final DismissListener dismissListener) + { + this.text.setText(text); + setVisibility(View.VISIBLE); + + this.container.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + Log.i(TAG, "onClick()"); + clickListener.onClicked(unverifiedIdentities); + } + }); + + this.closeButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + hide(); + dismissListener.onDismissed(unverifiedIdentities); + } + }); + } + + public void hide() { + setVisibility(View.GONE); + } + + public interface DismissListener { + public void onDismissed(List unverifiedIdentities); + } + + public interface ClickListener { + public void onClicked(List unverifiedIdentities); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java new file mode 100644 index 00000000..d1e981c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.components.identity; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.List; + +public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogInterface.OnClickListener { + + private final List untrustedRecords; + private final ResendListener resendListener; + + public UnverifiedSendDialog(@NonNull Context context, + @NonNull String message, + @NonNull List untrustedRecords, + @NonNull ResendListener resendListener) + { + super(context); + this.untrustedRecords = untrustedRecords; + this.resendListener = resendListener; + + setTitle(R.string.UnverifiedSendDialog_send_message); + setIcon(R.drawable.ic_warning); + setMessage(message); + setPositiveButton(R.string.UnverifiedSendDialog_send, this); + setNegativeButton(android.R.string.cancel, null); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext()); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + for (IdentityRecord identityRecord : untrustedRecords) { + identityDatabase.setVerified(identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + IdentityDatabase.VerifiedStatus.DEFAULT); + } + } + + return null; + } + + @Override + protected void onPostExecute(Void result) { + resendListener.onResendMessage(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + public interface ResendListener { + public void onResendMessage(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java new file mode 100644 index 00000000..ccdd64ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.components.location; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.MapView; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.model.MarkerOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; + +public class SignalMapView extends LinearLayout { + + private MapView mapView; + private ImageView imageView; + private TextView textView; + + public SignalMapView(Context context) { + this(context, null); + } + + public SignalMapView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public SignalMapView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(context); + } + + private void initialize(Context context) { + setOrientation(LinearLayout.VERTICAL); + LayoutInflater.from(context).inflate(R.layout.signal_map_view, this, true); + + this.mapView = findViewById(R.id.map_view); + this.imageView = findViewById(R.id.image_view); + this.textView = findViewById(R.id.address_view); + } + + public ListenableFuture display(final SignalPlace place) { + final SettableFuture future = new SettableFuture<>(); + + this.mapView.onCreate(null); + this.mapView.onResume(); + + this.mapView.setVisibility(View.VISIBLE); + this.imageView.setVisibility(View.GONE); + + this.mapView.getMapAsync(new OnMapReadyCallback() { + @Override + public void onMapReady(final GoogleMap googleMap) { + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(place.getLatLong(), 13)); + googleMap.addMarker(new MarkerOptions().position(place.getLatLong())); + googleMap.setBuildingsEnabled(true); + googleMap.setMapType(GoogleMap.MAP_TYPE_NORMAL); + googleMap.getUiSettings().setAllGesturesEnabled(false); + googleMap.setOnMapLoadedCallback(new GoogleMap.OnMapLoadedCallback() { + @Override + public void onMapLoaded() { + googleMap.snapshot(new GoogleMap.SnapshotReadyCallback() { + @Override + public void onSnapshotReady(Bitmap bitmap) { + future.set(bitmap); + imageView.setImageBitmap(bitmap); + imageView.setVisibility(View.VISIBLE); + mapView.setVisibility(View.GONE); + mapView.onPause(); + mapView.onDestroy(); + } + }); + } + }); + } + }); + + this.textView.setText(place.getDescription()); + + return future; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalPlace.java b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalPlace.java new file mode 100644 index 00000000..7b2786d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalPlace.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.components.location; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.android.gms.maps.model.LatLng; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.maps.AddressData; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; + +public class SignalPlace { + + private static final String URL = "https://maps.google.com/maps"; + private static final String TAG = SignalPlace.class.getSimpleName(); + + @JsonProperty + private CharSequence name; + + @JsonProperty + private CharSequence address; + + @JsonProperty + private double latitude; + + @JsonProperty + private double longitude; + + public SignalPlace(@NonNull AddressData place) { + this.name = ""; + this.address = place.getAddress(); + this.latitude = place.getLatitude(); + this.longitude = place.getLongitude(); + } + + @JsonCreator + @SuppressWarnings("unused") + public SignalPlace() {} + + @JsonIgnore + public LatLng getLatLong() { + return new LatLng(latitude, longitude); + } + + @JsonIgnore + public String getDescription() { + String description = ""; + + if (!TextUtils.isEmpty(name)) { + description += (name + "\n"); + } + + if (!TextUtils.isEmpty(address)) { + description += (address + "\n"); + } + + description += Uri.parse(URL) + .buildUpon() + .appendQueryParameter("q", String.format("%s,%s", latitude, longitude)) + .build().toString(); + + return description; + } + + public @Nullable String serialize() { + try { + return JsonUtils.toJson(this); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + public static SignalPlace deserialize(@NonNull String serialized) throws IOException { + return JsonUtils.fromJson(serialized, SignalPlace.class); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java new file mode 100644 index 00000000..bc30322c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.components.mention; + + +import android.text.Annotation; +import android.text.Spannable; +import android.text.Spanned; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collections; +import java.util.List; + +/** + * This wraps an Android standard {@link Annotation} so it can leverage the built in + * span parceling for copy/paste. The annotation span contains the mentioned recipient's + * id (in numerical form). + * + * Note: Do not extend Annotation or the parceling behavior will be lost. + */ +public final class MentionAnnotation { + + public static final String MENTION_ANNOTATION = "mention"; + + private MentionAnnotation() { + } + + public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) { + return new Annotation(MENTION_ANNOTATION, idToMentionAnnotationValue(id)); + } + + public static String idToMentionAnnotationValue(@NonNull RecipientId id) { + return String.valueOf(id.toLong()); + } + + public static boolean isMentionAnnotation(@NonNull Annotation annotation) { + return MENTION_ANNOTATION.equals(annotation.getKey()); + } + + public static void setMentionAnnotations(Spannable body, List mentions) { + for (Mention mention : mentions) { + body.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(mention.getRecipientId()), mention.getStart(), mention.getStart() + mention.getLength(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + public static @NonNull List getMentionsFromAnnotations(@Nullable CharSequence text) { + if (text instanceof Spanned) { + Spanned spanned = (Spanned) text; + return Stream.of(getMentionAnnotations(spanned)) + .map(annotation -> { + int spanStart = spanned.getSpanStart(annotation); + int spanLength = spanned.getSpanEnd(annotation) - spanStart; + return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength); + }) + .toList(); + } + return Collections.emptyList(); + } + + public static @NonNull List getMentionAnnotations(@NonNull Spanned spanned) { + return getMentionAnnotations(spanned, 0, spanned.length()); + } + + public static @NonNull List getMentionAnnotations(@NonNull Spanned spanned, int start, int end) { + return Stream.of(spanned.getSpans(start, end, Annotation.class)) + .filter(MentionAnnotation::isMentionAnnotation) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java new file mode 100644 index 00000000..db5257ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.text.Annotation; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; + +import androidx.annotation.Nullable; + +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; + +/** + * Detects if some part of the mention is being deleted, and if so, deletes the entire mention and + * span from the text view. + */ +public class MentionDeleter implements TextWatcher { + + @Nullable private Annotation toDelete; + + @Override + public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { + if (count > 0 && sequence instanceof Spanned) { + Spanned text = (Spanned) sequence; + + for (Annotation annotation : MentionAnnotation.getMentionAnnotations(text, start, start + count)) { + if (text.getSpanStart(annotation) < start && text.getSpanEnd(annotation) > start) { + toDelete = annotation; + return; + } + } + } + } + + @Override + public void afterTextChanged(Editable editable) { + if (toDelete == null) { + return; + } + + int toDeleteStart = editable.getSpanStart(toDelete); + int toDeleteEnd = editable.getSpanEnd(toDelete); + editable.removeSpan(toDelete); + toDelete = null; + + editable.replace(toDeleteStart, toDeleteEnd, String.valueOf(MENTION_STARTER)); + } + + @Override + public void onTextChanged(CharSequence sequence, int start, int before, int count) { } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java new file mode 100644 index 00000000..1c6ba77d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.text.Layout; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.LayoutUtil; + +/** + * Handles actually drawing the mention backgrounds for a TextView. + *

+ * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin + */ +public abstract class MentionRenderer { + + protected final int horizontalPadding; + protected final int verticalPadding; + + public MentionRenderer(int horizontalPadding, int verticalPadding) { + this.horizontalPadding = horizontalPadding; + this.verticalPadding = verticalPadding; + } + + public abstract void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset); + + protected int getLineTop(@NonNull Layout layout, int line) { + return LayoutUtil.getLineTopWithoutPadding(layout, line) - verticalPadding; + } + + protected int getLineBottom(@NonNull Layout layout, int line) { + return LayoutUtil.getLineBottomWithoutPadding(layout, line) + verticalPadding; + } + + public static final class SingleLineMentionRenderer extends MentionRenderer { + + private final Drawable drawable; + + public SingleLineMentionRenderer(int horizontalPadding, int verticalPadding, @NonNull Drawable drawable) { + super(horizontalPadding, verticalPadding); + this.drawable = drawable; + } + + @Override + public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) { + int lineTop = getLineTop(layout, startLine); + int lineBottom = getLineBottom(layout, startLine); + int left = Math.min(startOffset, endOffset); + int right = Math.max(startOffset, endOffset); + + drawable.setBounds(left, lineTop, right, lineBottom); + drawable.draw(canvas); + } + } + + public static final class MultiLineMentionRenderer extends MentionRenderer { + + private final Drawable drawableLeft; + private final Drawable drawableMid; + private final Drawable drawableRight; + + public MultiLineMentionRenderer(int horizontalPadding, int verticalPadding, + @NonNull Drawable drawableLeft, + @NonNull Drawable drawableMid, + @NonNull Drawable drawableRight) + { + super(horizontalPadding, verticalPadding); + this.drawableLeft = drawableLeft; + this.drawableMid = drawableMid; + this.drawableRight = drawableRight; + } + + @Override + public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) { + int paragraphDirection = layout.getParagraphDirection(startLine); + + float lineEndOffset; + if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) { + lineEndOffset = layout.getLineLeft(startLine) - horizontalPadding; + } else { + lineEndOffset = layout.getLineRight(startLine) + horizontalPadding; + } + + int lineBottom = getLineBottom(layout, startLine); + int lineTop = getLineTop(layout, startLine); + drawStart(canvas, startOffset, lineTop, (int) lineEndOffset, lineBottom); + + for (int line = startLine + 1; line < endLine; line++) { + int left = (int) layout.getLineLeft(line) - horizontalPadding; + int right = (int) layout.getLineRight(line) + horizontalPadding; + + lineTop = getLineTop(layout, line); + lineBottom = getLineBottom(layout, line); + + drawableMid.setBounds(left, lineTop, right, lineBottom); + drawableMid.draw(canvas); + } + + float lineStartOffset; + if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) { + lineStartOffset = layout.getLineRight(startLine) + horizontalPadding; + } else { + lineStartOffset = layout.getLineLeft(startLine) - horizontalPadding; + } + + lineBottom = getLineBottom(layout, endLine); + lineTop = getLineTop(layout, endLine); + + drawEnd(canvas, (int) lineStartOffset, lineTop, endOffset, lineBottom); + } + + private void drawStart(@NonNull Canvas canvas, int start, int top, int end, int bottom) { + if (start > end) { + drawableRight.setBounds(end, top, start, bottom); + drawableRight.draw(canvas); + } else { + drawableLeft.setBounds(start, top, end, bottom); + drawableLeft.draw(canvas); + } + } + + private void drawEnd(@NonNull Canvas canvas, int start, int top, int end, int bottom) { + if (start > end) { + drawableLeft.setBounds(end, top, start, bottom); + drawableLeft.draw(canvas); + } else { + drawableRight.setBounds(start, top, end, bottom); + drawableRight.draw(canvas); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java new file mode 100644 index 00000000..4c087cfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.text.Annotation; +import android.text.Layout; +import android.text.Spanned; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.DrawableUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * Encapsulates the logic for determining the type of mention rendering needed (single vs multi-line) and then + * passing that information to the appropriate {@link MentionRenderer}. + *

+ * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin + */ +public class MentionRendererDelegate { + + private final MentionRenderer single; + private final MentionRenderer multi; + private final int horizontalPadding; + private final Drawable drawable; + private final Drawable drawableLeft; + private final Drawable drawableMid; + private final Drawable drawableEnd; + + public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) { + this.horizontalPadding = ViewUtil.dpToPx(2); + + drawable = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg), tint); + drawableLeft = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_left), tint); + drawableMid = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_mid), tint); + drawableEnd = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_right), tint); + + single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding, + 0, + drawable); + + multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding, + 0, + drawableLeft, + drawableMid, + drawableEnd); + } + + public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) { + Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class); + for (Annotation annotation : annotations) { + if (MentionAnnotation.isMentionAnnotation(annotation)) { + int spanStart = text.getSpanStart(annotation); + int spanEnd = text.getSpanEnd(annotation); + int startLine = layout.getLineForOffset(spanStart); + int endLine = layout.getLineForOffset(spanEnd); + + int startOffset = (int) (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine) * horizontalPadding); + int endOffset = (int) (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine) * horizontalPadding); + + MentionRenderer renderer = (startLine == endLine) ? single : multi; + renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset); + } + } + } + + public void setTint(@ColorInt int tint) { + DrawableCompat.setTint(drawable, tint); + DrawableCompat.setTint(drawableLeft, tint); + DrawableCompat.setTint(drawableMid, tint); + DrawableCompat.setTint(drawableEnd, tint); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java new file mode 100644 index 00000000..3e9e84b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.text.Annotation; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; + +import androidx.annotation.Nullable; + +import java.util.List; + +/** + * Provides a mechanism to validate mention annotations set on an edit text. This enables + * removing invalid mentions if the user mentioned isn't in the group. + */ +public class MentionValidatorWatcher implements TextWatcher { + + @Nullable private List invalidMentionAnnotations; + @Nullable private MentionValidator mentionValidator; + + @Override + public void onTextChanged(CharSequence sequence, int start, int before, int count) { + if (count > 1 && mentionValidator != null && sequence instanceof Spanned) { + Spanned span = (Spanned) sequence; + + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(span, start, start + count); + + if (mentionAnnotations.size() > 0) { + invalidMentionAnnotations = mentionValidator.getInvalidMentionAnnotations(mentionAnnotations); + } + } + } + + @Override + public void afterTextChanged(Editable editable) { + if (invalidMentionAnnotations == null) { + return; + } + + List invalidMentions = invalidMentionAnnotations; + invalidMentionAnnotations = null; + + for (Annotation annotation : invalidMentions) { + editable.removeSpan(annotation); + } + } + + public void setMentionValidator(@Nullable MentionValidator mentionValidator) { + this.mentionValidator = mentionValidator; + } + + @Override + public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { } + + public interface MentionValidator { + List getInvalidMentionAnnotations(List mentionAnnotations); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java new file mode 100644 index 00000000..1bcd9eba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.components.qr; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.util.AttributeSet; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; + +import com.google.zxing.common.BitMatrix; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.SquareImageView; +import org.thoughtcrime.securesms.qr.QrCode; + +/** + * Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it. + */ +public class QrView extends SquareImageView { + + private static final @ColorInt int DEFAULT_FOREGROUND_COLOR = Color.BLACK; + private static final @ColorInt int DEFAULT_BACKGROUND_COLOR = Color.TRANSPARENT; + + private @Nullable Bitmap qrBitmap; + private @ColorInt int foregroundColor; + private @ColorInt int backgroundColor; + + public QrView(Context context) { + super(context); + init(null); + } + + public QrView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public QrView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QrView, 0, 0); + foregroundColor = typedArray.getColor(R.styleable.QrView_qr_foreground_color, DEFAULT_FOREGROUND_COLOR); + backgroundColor = typedArray.getColor(R.styleable.QrView_qr_background_color, DEFAULT_BACKGROUND_COLOR); + typedArray.recycle(); + } else { + foregroundColor = DEFAULT_FOREGROUND_COLOR; + backgroundColor = DEFAULT_BACKGROUND_COLOR; + } + + if (isInEditMode()) { + setQrText("https://signal.org"); + } + } + + public void setQrText(@Nullable String text) { + setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor)); + } + + private void setQrBitmap(@Nullable Bitmap qrBitmap) { + if (this.qrBitmap == qrBitmap) { + return; + } + + if (this.qrBitmap != null) { + this.qrBitmap.recycle(); + } + + this.qrBitmap = qrBitmap; + + setImageBitmap(this.qrBitmap); + } + + public @Nullable Bitmap getQrBitmap() { + return qrBitmap; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java new file mode 100644 index 00000000..4fc11656 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.components.recyclerview; + + +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView; + +public class DeleteItemAnimator extends DefaultItemAnimator { + + public DeleteItemAnimator() { + setSupportsChangeAnimations(false); + } + + @Override + public boolean animateAdd(RecyclerView.ViewHolder viewHolder) { + dispatchAddFinished(viewHolder); + return false; + } + + @Override + public boolean animateMove(RecyclerView.ViewHolder viewHolder, int fromX, int fromY, int toX, int toY) { + dispatchMoveFinished(viewHolder); + return false; + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java new file mode 100644 index 00000000..ccbcd11b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.components.recyclerview; + +import android.content.Context; +import android.util.DisplayMetrics; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + +public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager { + + public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout) { + super(context, RecyclerView.VERTICAL, reverseLayout); + } + + public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) { + final LinearSmoothScroller scroller = new LinearSmoothScroller(context) { + @Override + protected int getVerticalSnapPreference() { + return LinearSmoothScroller.SNAP_TO_END; + } + + @Override + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return millisecondsPerInch / displayMetrics.densityDpi; + } + }; + + scroller.setTargetPosition(position); + startSmoothScroll(scroller); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/registration/CallMeCountDownView.java b/app/src/main/java/org/thoughtcrime/securesms/components/registration/CallMeCountDownView.java new file mode 100644 index 00000000..9cbfaf1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/registration/CallMeCountDownView.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.components.registration; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +import java.util.concurrent.TimeUnit; + +public class CallMeCountDownView extends androidx.appcompat.widget.AppCompatButton { + + private long countDownToTime; + @Nullable + private Listener listener; + + public CallMeCountDownView(Context context) { + super(context); + } + + public CallMeCountDownView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CallMeCountDownView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * Starts a count down to the specified {@param time}. + */ + public void startCountDownTo(long time) { + if (time > 0) { + this.countDownToTime = time; + updateCountDown(); + } + } + + public void setCallEnabled() { + setText(R.string.RegistrationActivity_call); + setEnabled(true); + setAlpha(1.0f); + } + + private void updateCountDown() { + final long remainingMillis = countDownToTime - System.currentTimeMillis(); + + if (remainingMillis > 0) { + setEnabled(false); + setAlpha(0.5f); + + int totalRemainingSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(remainingMillis); + int minutesRemaining = totalRemainingSeconds / 60; + int secondsRemaining = totalRemainingSeconds % 60; + + setText(getResources().getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining)); + + if (listener != null) { + listener.onRemaining(this, totalRemainingSeconds); + } + + postDelayed(this::updateCountDown, 250); + } else { + setCallEnabled(); + } + } + + public void setListener(@Nullable Listener listener) { + this.listener = listener; + } + + public interface Listener { + void onRemaining(@NonNull CallMeCountDownView view, int secondsRemaining); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java new file mode 100644 index 00000000..a1d97d6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/registration/PulsingFloatingActionButton.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.components.registration; + + +import android.animation.Animator; +import android.content.Context; +import android.util.AttributeSet; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; + +public class PulsingFloatingActionButton extends FloatingActionButton { + + private boolean pulsing; + + public PulsingFloatingActionButton(Context context) { + super(context); + } + + public PulsingFloatingActionButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public PulsingFloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void startPulse(long periodMillis) { + if (!pulsing) { + pulsing = true; + pulse(periodMillis); + } + } + + public void stopPulse() { + pulsing = false; + } + + private void pulse(long periodMillis) { + if (!pulsing) return; + + this.animate().scaleX(1.2f).scaleY(1.2f).setDuration(150).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + clearAnimation(); + animate().scaleX(1.0f).scaleY(1.0f).setDuration(150).setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + PulsingFloatingActionButton.this.postDelayed(() -> pulse(periodMillis), periodMillis); + } + }).start(); + } + }).start(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationCodeView.java b/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationCodeView.java new file mode 100644 index 00000000..466f36ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationCodeView.java @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.components.registration; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.OvershootInterpolator; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.List; + +public final class VerificationCodeView extends FrameLayout { + + private final List codes = new ArrayList<>(6); + private final List containers = new ArrayList<>(6); + + private OnCodeEnteredListener listener; + private int index; + + public VerificationCodeView(Context context) { + super(context); + initialize(context); + } + + public VerificationCodeView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + public VerificationCodeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(context); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public VerificationCodeView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(context); + } + + private void initialize(@NonNull Context context) { + inflate(context, R.layout.verification_code_view, this); + + codes.add(findViewById(R.id.code_zero)); + codes.add(findViewById(R.id.code_one)); + codes.add(findViewById(R.id.code_two)); + codes.add(findViewById(R.id.code_three)); + codes.add(findViewById(R.id.code_four)); + codes.add(findViewById(R.id.code_five)); + + containers.add(findViewById(R.id.container_zero)); + containers.add(findViewById(R.id.container_one)); + containers.add(findViewById(R.id.container_two)); + containers.add(findViewById(R.id.container_three)); + containers.add(findViewById(R.id.container_four)); + containers.add(findViewById(R.id.container_five)); + } + + @MainThread + public void setOnCompleteListener(OnCodeEnteredListener listener) { + this.listener = listener; + } + + @MainThread + public void append(int value) { + if (index >= codes.size()) return; + + setInactive(containers); + setActive(containers.get(index)); + + TextView codeView = codes.get(index++); + + Animation translateIn = new TranslateAnimation(0, 0, codeView.getHeight(), 0); + translateIn.setInterpolator(new OvershootInterpolator()); + translateIn.setDuration(500); + + Animation fadeIn = new AlphaAnimation(0, 1); + fadeIn.setDuration(200); + + AnimationSet animationSet = new AnimationSet(false); + animationSet.addAnimation(fadeIn); + animationSet.addAnimation(translateIn); + animationSet.reset(); + animationSet.setStartTime(0); + + codeView.setText(String.valueOf(value)); + codeView.clearAnimation(); + codeView.startAnimation(animationSet); + + if (index == codes.size() && listener != null) { + listener.onCodeComplete(Stream.of(codes).map(TextView::getText).collect(Collectors.joining())); + } + } + + @MainThread + public void delete() { + if (index <= 0) return; + codes.get(--index).setText(""); + setInactive(containers); + setActive(containers.get(index)); + } + + @MainThread + public void clear() { + if (index != 0) { + Stream.of(codes).forEach(code -> code.setText("")); + index = 0; + } + setInactive(containers); + } + + private static void setInactive(List views) { + Stream.of(views).forEach(c -> c.setBackgroundResource(R.drawable.labeled_edit_text_background_inactive)); + } + + private static void setActive(@NonNull View container) { + container.setBackgroundResource(R.drawable.labeled_edit_text_background_active); + } + + public interface OnCodeEnteredListener { + void onCodeComplete(@NonNull String code); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationPinKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationPinKeyboard.java new file mode 100644 index 00000000..c511bdc1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationPinKeyboard.java @@ -0,0 +1,213 @@ +package org.thoughtcrime.securesms.components.registration; + + +import android.content.Context; +import android.graphics.PorterDuff; +import android.inputmethodservice.Keyboard; +import android.inputmethodservice.KeyboardView; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; + +public class VerificationPinKeyboard extends FrameLayout { + + private KeyboardView keyboardView; + private ProgressBar progressBar; + private ImageView successView; + private ImageView failureView; + private ImageView lockedView; + + private OnKeyPressListener listener; + + public VerificationPinKeyboard(@NonNull Context context) { + super(context); + initialize(); + } + + public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.verification_pin_keyboard_view, this); + + this.keyboardView = findViewById(R.id.keyboard_view); + this.progressBar = findViewById(R.id.progress); + this.successView = findViewById(R.id.success); + this.failureView = findViewById(R.id.failure); + this.lockedView = findViewById(R.id.locked); + + keyboardView.setPreviewEnabled(false); + keyboardView.setKeyboard(new Keyboard(getContext(), R.xml.pin_keyboard)); + keyboardView.setOnKeyboardActionListener(new KeyboardView.OnKeyboardActionListener() { + @Override + public void onPress(int primaryCode) { + if (listener != null) listener.onKeyPress(primaryCode); + } + @Override + public void onRelease(int primaryCode) {} + @Override + public void onKey(int primaryCode, int[] keyCodes) {} + @Override + public void onText(CharSequence text) {} + @Override + public void swipeLeft() {} + @Override + public void swipeRight() {} + @Override + public void swipeDown() {} + @Override + public void swipeUp() {} + }); + + displayKeyboard(); + } + + public void setOnKeyPressListener(@Nullable OnKeyPressListener listener) { + this.listener = listener; + } + + public void displayKeyboard() { + this.keyboardView.setVisibility(View.VISIBLE); + this.progressBar.setVisibility(View.GONE); + this.successView.setVisibility(View.GONE); + this.failureView.setVisibility(View.GONE); + this.lockedView.setVisibility(View.GONE); + } + + public void displayProgress() { + this.keyboardView.setVisibility(View.INVISIBLE); + this.progressBar.setVisibility(View.VISIBLE); + this.successView.setVisibility(View.GONE); + this.failureView.setVisibility(View.GONE); + this.lockedView.setVisibility(View.GONE); + } + + public ListenableFuture displaySuccess() { + SettableFuture result = new SettableFuture<>(); + + this.keyboardView.setVisibility(View.INVISIBLE); + this.progressBar.setVisibility(View.GONE); + this.failureView.setVisibility(View.GONE); + this.lockedView.setVisibility(View.GONE); + + this.successView.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); + + ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f); + scaleAnimation.setInterpolator(new OvershootInterpolator()); + scaleAnimation.setDuration(800); + scaleAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + result.set(true); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + ViewUtil.animateIn(this.successView, scaleAnimation); + return result; + } + + public ListenableFuture displayFailure() { + SettableFuture result = new SettableFuture<>(); + + this.keyboardView.setVisibility(View.INVISIBLE); + this.progressBar.setVisibility(View.GONE); + this.failureView.setVisibility(View.GONE); + this.lockedView.setVisibility(View.GONE); + + this.failureView.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.SRC_IN); + this.failureView.setVisibility(View.VISIBLE); + + TranslateAnimation shake = new TranslateAnimation(0, 30, 0, 0); + shake.setDuration(50); + shake.setRepeatCount(7); + shake.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + result.set(true); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + this.failureView.startAnimation(shake); + + return result; + } + + public ListenableFuture displayLocked() { + SettableFuture result = new SettableFuture<>(); + + this.keyboardView.setVisibility(View.INVISIBLE); + this.progressBar.setVisibility(View.GONE); + this.failureView.setVisibility(View.GONE); + this.lockedView.setVisibility(View.GONE); + + this.lockedView.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); + + ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f); + scaleAnimation.setInterpolator(new OvershootInterpolator()); + scaleAnimation.setDuration(800); + scaleAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + result.set(true); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + ViewUtil.animateIn(this.lockedView, scaleAnimation); + return result; + } + + public interface OnKeyPressListener { + void onKeyPress(int keyCode); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java new file mode 100644 index 00000000..fd647c12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.components.reminder; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +@SuppressLint("BatteryLife") +public class DozeReminder extends Reminder { + + @RequiresApi(api = Build.VERSION_CODES.M) + public DozeReminder(@NonNull final Context context) { + super(context.getString(R.string.DozeReminder_optimize_for_missing_play_services), + context.getString(R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery)); + + setOkListener(v -> { + TextSecurePreferences.setPromptedOptimizeDoze(context, true); + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + }); + + setDismissListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + TextSecurePreferences.setPromptedOptimizeDoze(context, true); + } + }); + } + + public static boolean isEligible(Context context) { + return TextSecurePreferences.isFcmDisabled(context) && + !TextSecurePreferences.hasPromptedOptimizeDoze(context) && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + !((PowerManager)context.getSystemService(Context.POWER_SERVICE)).isIgnoringBatteryOptimizations(context.getPackageName()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java new file mode 100644 index 00000000..b5089cf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ExpiredBuildReminder.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.PlayStoreUtil; + +import java.util.List; + +/** + * Showed when a build has fully expired (either via the compile-time constant, or remote + * deprecation). + */ +public class ExpiredBuildReminder extends Reminder { + + public ExpiredBuildReminder(final Context context) { + super(null, context.getString(R.string.ExpiredBuildReminder_this_version_of_signal_has_expired)); + + setOkListener(v -> PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)); + addAction(new Action(context.getString(R.string.ExpiredBuildReminder_update_now), R.id.reminder_action_update_now)); + } + + @Override + public boolean isDismissable() { + return false; + } + + @Override + public List getActions() { + return super.getActions(); + } + + @Override + public @NonNull Importance getImportance() { + return Importance.TERMINAL; + } + + public static boolean isEligible() { + return SignalStore.misc().isClientDeprecated(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/FirstInviteReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/FirstInviteReminder.java new file mode 100644 index 00000000..b8709c9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/FirstInviteReminder.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +public final class FirstInviteReminder extends Reminder { + + public FirstInviteReminder(final @NonNull Context context, + final @NonNull Recipient recipient, + final int percentIncrease) { + super(context.getString(R.string.FirstInviteReminder__title), + context.getString(R.string.FirstInviteReminder__description, percentIncrease)); + + addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite)); + addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java new file mode 100644 index 00000000..138a2c10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +/** + * Shows a reminder to upgrade a group to GV2. + */ +public class GroupsV1MigrationInitiationReminder extends Reminder { + + public GroupsV1MigrationInitiationReminder(@NonNull Context context) { + super(null, context.getString(R.string.GroupsV1MigrationInitiationReminder_to_access_new_features_like_mentions)); + addAction(new Action(context.getString(R.string.GroupsV1MigrationInitiationReminder_upgrade_group), R.id.reminder_action_gv1_initiation_update_group)); + addAction(new Action(context.getResources().getString(R.string.GroupsV1MigrationInitiationReminder_not_now), R.id.reminder_action_gv1_initiation_not_now)); + } + + @Override + public boolean isDismissable() { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java new file mode 100644 index 00000000..cfad166b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.List; + +/** + * Shows a reminder to add anyone that might have been missed in GV1->GV2 migration. + */ +public class GroupsV1MigrationSuggestionsReminder extends Reminder { + public GroupsV1MigrationSuggestionsReminder(@NonNull Context context, @NonNull List suggestions) { + super(null, context.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group, suggestions.size(), suggestions.size())); + addAction(new Action(context.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, suggestions.size()), R.id.reminder_action_gv1_suggestion_add_members)); + addAction(new Action(context.getResources().getString(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks), R.id.reminder_action_gv1_suggestion_no_thanks)); + } + + @Override + public boolean isDismissable() { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java new file mode 100644 index 00000000..0a860135 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/OutdatedBuildReminder.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.TimeUnit; + +/** + * Reminder that is shown when a build is getting close to expiry (either because of the + * compile-time constant, or remote deprecation). + */ +public class OutdatedBuildReminder extends Reminder { + + public OutdatedBuildReminder(final Context context) { + super(null, getPluralsText(context)); + + setOkListener(v -> PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)); + addAction(new Action(context.getString(R.string.OutdatedBuildReminder_update_now), R.id.reminder_action_update_now)); + } + + private static CharSequence getPluralsText(final Context context) { + int days = getDaysUntilExpiry() - 1; + + if (days == 0) { + return context.getString(R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today); + } else { + return context.getResources().getQuantityString(R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, days, days); + } + } + + @Override + public boolean isDismissable() { + return false; + } + + public static boolean isEligible() { + return getDaysUntilExpiry() <= 10; + } + + private static int getDaysUntilExpiry() { + return (int) TimeUnit.MILLISECONDS.toDays(Util.getTimeUntilBuildExpiry()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PendingGroupJoinRequestsReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PendingGroupJoinRequestsReminder.java new file mode 100644 index 00000000..f707009e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PendingGroupJoinRequestsReminder.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +/** + * Shown to admins when there are pending group join requests. + */ +public final class PendingGroupJoinRequestsReminder extends Reminder { + + private PendingGroupJoinRequestsReminder(@Nullable CharSequence title, + @NonNull CharSequence text) + { + super(title, text); + } + + public static Reminder create(@NonNull Context context, int count) { + String message = context.getResources().getQuantityString(R.plurals.PendingGroupJoinRequestsReminder_d_pending_member_requests, count, count); + Reminder reminder = new PendingGroupJoinRequestsReminder(null, message); + + reminder.addAction(new Action(context.getString(R.string.PendingGroupJoinRequestsReminder_view), R.id.reminder_action_review_join_requests)); + + return reminder; + } + + @Override + public boolean isDismissable() { + return true; + } + + @Override + public @NonNull Importance getImportance() { + return Importance.NORMAL; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java new file mode 100644 index 00000000..e431fa68 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class PushRegistrationReminder extends Reminder { + + public PushRegistrationReminder(final Context context) { + super(context.getString(R.string.reminder_header_push_title), + context.getString(R.string.reminder_header_push_text)); + + setOkListener(v -> context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context))); + } + + @Override + public boolean isDismissable() { + return false; + } + + public static boolean isEligible(Context context) { + return !TextSecurePreferences.isPushRegistered(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/Reminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/Reminder.java new file mode 100644 index 00000000..4ff4a65b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/Reminder.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.view.View.OnClickListener; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedList; +import java.util.List; + +public abstract class Reminder { + private CharSequence title; + private CharSequence text; + + private OnClickListener okListener; + private OnClickListener dismissListener; + + private final List actions = new LinkedList<>(); + + public Reminder(@Nullable CharSequence title, + @NonNull CharSequence text) + { + this.title = title; + this.text = text; + } + + public @Nullable CharSequence getTitle() { + return title; + } + + public CharSequence getText() { + return text; + } + + public OnClickListener getOkListener() { + return okListener; + } + + public OnClickListener getDismissListener() { + return dismissListener; + } + + public void setOkListener(OnClickListener okListener) { + this.okListener = okListener; + } + + public void setDismissListener(OnClickListener dismissListener) { + this.dismissListener = dismissListener; + } + + public boolean isDismissable() { + return true; + } + + public @NonNull Importance getImportance() { + return Importance.NORMAL; + } + + protected void addAction(@NonNull Action action) { + actions.add(action); + } + + public List getActions() { + return actions; + } + + public int getProgress() { + return -1; + } + + public enum Importance { + NORMAL, ERROR, TERMINAL + } + + public static final class Action { + private final CharSequence title; + private final int actionId; + + public Action(CharSequence title, @IdRes int actionId) { + this.title = title; + this.actionId = actionId; + } + + CharSequence getTitle() { + return title; + } + + int getActionId() { + return actionId; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java new file mode 100644 index 00000000..64afd9f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.Collections; +import java.util.List; + +final class ReminderActionsAdapter extends RecyclerView.Adapter { + + private final List actions; + private final ReminderView.OnActionClickListener actionClickListener; + + ReminderActionsAdapter(List actions, ReminderView.OnActionClickListener actionClickListener) { + this.actions = Collections.unmodifiableList(actions); + this.actionClickListener = actionClickListener; + } + + @NonNull + @Override + public ActionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ActionViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reminder_action_button, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ActionViewHolder holder, int position) { + final Reminder.Action action = actions.get(position); + + ((Button) holder.itemView).setText(action.getTitle()); + holder.itemView.setOnClickListener(v -> { + if (holder.getAdapterPosition() == RecyclerView.NO_POSITION) return; + + actionClickListener.onActionClick(action.getActionId()); + }); + } + + @Override + public int getItemCount() { + return actions.size(); + } + + final class ActionViewHolder extends RecyclerView.ViewHolder { + ActionViewHolder(@NonNull View itemView) { + super(itemView); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java new file mode 100644 index 00000000..4de9ab72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.Space; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +/** + * View to display actionable reminders to the user + */ +public final class ReminderView extends FrameLayout { + private ProgressBar progressBar; + private TextView progressText; + private ViewGroup container; + private ImageButton closeButton; + private TextView title; + private TextView text; + private OnDismissListener dismissListener; + private Space space; + private RecyclerView actionsRecycler; + private OnActionClickListener actionClickListener; + + public ReminderView(Context context) { + super(context); + initialize(); + } + + public ReminderView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ReminderView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + LayoutInflater.from(getContext()).inflate(R.layout.reminder_header, this, true); + progressBar = findViewById(R.id.reminder_progress); + progressText = findViewById(R.id.reminder_progress_text); + container = findViewById(R.id.container); + closeButton = findViewById(R.id.cancel); + title = findViewById(R.id.reminder_title); + text = findViewById(R.id.reminder_text); + space = findViewById(R.id.reminder_space); + actionsRecycler = findViewById(R.id.reminder_actions); + } + + public void showReminder(final Reminder reminder) { + if (!TextUtils.isEmpty(reminder.getTitle())) { + title.setText(reminder.getTitle()); + title.setVisibility(VISIBLE); + space.setVisibility(GONE); + } else { + title.setText(""); + title.setVisibility(GONE); + space.setVisibility(VISIBLE); + } + + if (!reminder.isDismissable()) { + space.setVisibility(GONE); + } + + text.setText(reminder.getText()); + text.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_button_primary_text)); + + switch (reminder.getImportance()) { + case NORMAL: + container.setBackgroundResource(R.drawable.reminder_background_normal); + break; + case ERROR: + container.setBackgroundResource(R.drawable.reminder_background_error); + text.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary)); + break; + case TERMINAL: + container.setBackgroundResource(R.drawable.reminder_background_terminal); + break; + default: + throw new IllegalStateException(); + } + + setOnClickListener(reminder.getOkListener()); + + closeButton.setVisibility(reminder.isDismissable() ? View.VISIBLE : View.GONE); + closeButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + hide(); + if (reminder.getDismissListener() != null) reminder.getDismissListener().onClick(v); + if (dismissListener != null) dismissListener.onDismiss(); + } + }); + + int progress = reminder.getProgress(); + if (progress != -1) { + progressBar.setProgress(progress); + progressBar.setVisibility(VISIBLE); + progressText.setText(getContext().getString(R.string.reminder_header_progress, progress)); + progressText.setVisibility(VISIBLE); + } else { + progressBar.setVisibility(GONE); + progressText.setVisibility(GONE); + } + + List actions = reminder.getActions(); + if (actions.isEmpty()) { + actionsRecycler.setVisibility(GONE); + } else { + actionsRecycler.setVisibility(VISIBLE); + actionsRecycler.setAdapter(new ReminderActionsAdapter(actions, this::handleActionClicked)); + } + + container.setVisibility(View.VISIBLE); + } + + private void handleActionClicked(@IdRes int actionId) { + if (actionClickListener != null) actionClickListener.onActionClick(actionId); + } + + public void setOnDismissListener(OnDismissListener dismissListener) { + this.dismissListener = dismissListener; + } + + public void setOnActionClickListener(@Nullable OnActionClickListener actionClickListener) { + this.actionClickListener = actionClickListener; + } + + public void requestDismiss() { + closeButton.performClick(); + } + + public void hide() { + container.setVisibility(View.GONE); + } + + public interface OnDismissListener { + void onDismiss(); + } + + public interface OnActionClickListener { + void onActionClick(@IdRes int actionId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SecondInviteReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SecondInviteReminder.java new file mode 100644 index 00000000..0aede621 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SecondInviteReminder.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +public final class SecondInviteReminder extends Reminder { + + private final int progress; + + public SecondInviteReminder(final @NonNull Context context, + final @NonNull Recipient recipient, + final int percent) + { + super(context.getString(R.string.SecondInviteReminder__title), + context.getString(R.string.SecondInviteReminder__description, recipient.getDisplayName(context))); + + this.progress = percent; + + addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite)); + addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights)); + } + + @Override + public int getProgress() { + return progress; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ServiceOutageReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ServiceOutageReminder.java new file mode 100644 index 00000000..4d98f7eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ServiceOutageReminder.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class ServiceOutageReminder extends Reminder { + + public ServiceOutageReminder(@NonNull Context context) { + super(null, + context.getString(R.string.reminder_header_service_outage_text)); + } + + public static boolean isEligible(@NonNull Context context) { + return TextSecurePreferences.getServiceOutage(context); + } + + @Override + public boolean isDismissable() { + return false; + } + + @NonNull + @Override + public Importance getImportance() { + return Importance.ERROR; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SystemSmsImportReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SystemSmsImportReminder.java new file mode 100644 index 00000000..c99bf05b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/SystemSmsImportReminder.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; +import android.content.Intent; +import android.view.View; +import android.view.View.OnClickListener; + +import org.thoughtcrime.securesms.DatabaseMigrationActivity; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.service.ApplicationMigrationService; + +public class SystemSmsImportReminder extends Reminder { + + public SystemSmsImportReminder(final Context context) { + super(context.getString(R.string.reminder_header_sms_import_title), + context.getString(R.string.reminder_header_sms_import_text)); + + final OnClickListener okListener = v -> { + Intent intent = new Intent(context, ApplicationMigrationService.class); + intent.setAction(ApplicationMigrationService.MIGRATE_DATABASE); + context.startService(intent); + + // TODO [greyson] Navigation + Intent nextIntent = MainActivity.clearTop(context); + Intent activityIntent = new Intent(context, DatabaseMigrationActivity.class); + activityIntent.putExtra("next_intent", nextIntent); + context.startActivity(activityIntent); + }; + final OnClickListener cancelListener = new OnClickListener() { + @Override + public void onClick(View v) { + ApplicationMigrationService.setDatabaseImported(context); + } + }; + setOkListener(okListener); + setDismissListener(cancelListener); + } + + public static boolean isEligible(Context context) { + return !ApplicationMigrationService.isDatabaseImported(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java new file mode 100644 index 00000000..e277ef97 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class UnauthorizedReminder extends Reminder { + + public UnauthorizedReminder(final Context context) { + super(context.getString(R.string.UnauthorizedReminder_device_no_longer_registered), + context.getString(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device)); + + setOkListener(v -> { + context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context)); + }); + } + + @Override + public boolean isDismissable() { + return false; + } + + public static boolean isEligible(Context context) { + return TextSecurePreferences.isUnauthorizedRecieved(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/sensors/DeviceOrientationMonitor.java b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/DeviceOrientationMonitor.java new file mode 100644 index 00000000..911d7a30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/DeviceOrientationMonitor.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.components.sensors; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; + +import org.thoughtcrime.securesms.util.ServiceUtil; + +public final class DeviceOrientationMonitor implements DefaultLifecycleObserver { + + private static final float MAGNITUDE_MAXIMUM = 1.5f; + private static final float MAGNITUDE_MINIMUM = 0.75f; + private static final float LANDSCAPE_PITCH_MINIMUM = -0.5f; + private static final float LANDSCAPE_PITCH_MAXIMUM = 0.5f; + + private final SensorManager sensorManager; + private final EventListener eventListener = new EventListener(); + + private final float[] accelerometerReading = new float[3]; + private final float[] magnetometerReading = new float[3]; + + private final float[] rotationMatrix = new float[9]; + private final float[] orientationAngles = new float[3]; + + private final MutableLiveData orientation = new MutableLiveData<>(); + + public DeviceOrientationMonitor(@NonNull Context context) { + this.sensorManager = ServiceUtil.getSensorManager(context); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (accelerometer != null) { + sensorManager.registerListener(eventListener, + accelerometer, + SensorManager.SENSOR_DELAY_NORMAL, + SensorManager.SENSOR_DELAY_UI); + } + Sensor magneticField = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD); + if (magneticField != null) { + sensorManager.registerListener(eventListener, + magneticField, + SensorManager.SENSOR_DELAY_NORMAL, + SensorManager.SENSOR_DELAY_UI); + } + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + sensorManager.unregisterListener(eventListener); + } + + public LiveData getOrientation() { + return Transformations.distinctUntilChanged(orientation); + } + + private void updateOrientationAngles() { + boolean success = SensorManager.getRotationMatrix(rotationMatrix, null, accelerometerReading, magnetometerReading); + if (!success) { + SensorUtil.getRotationMatrixWithoutMagneticSensorData(rotationMatrix, accelerometerReading); + } + SensorManager.getOrientation(rotationMatrix, orientationAngles); + + float pitch = orientationAngles[1]; + float roll = orientationAngles[2]; + float mag = (float) Math.sqrt(Math.pow(pitch, 2) + Math.pow(roll, 2)); + + if (mag > MAGNITUDE_MAXIMUM || mag < MAGNITUDE_MINIMUM) { + return; + } + + if (pitch > LANDSCAPE_PITCH_MINIMUM && pitch < LANDSCAPE_PITCH_MAXIMUM) { + orientation.setValue(roll > 0 ? Orientation.LANDSCAPE_RIGHT_EDGE : Orientation.LANDSCAPE_LEFT_EDGE); + } else { + orientation.setValue(Orientation.PORTRAIT_BOTTOM_EDGE); + } + } + + private final class EventListener implements SensorEventListener { + + @Override + public void onSensorChanged(SensorEvent event) { + if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { + System.arraycopy(event.values, 0, accelerometerReading, 0, accelerometerReading.length); + } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) { + System.arraycopy(event.values, 0, magnetometerReading, 0, magnetometerReading.length); + } + + updateOrientationAngles(); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/sensors/Orientation.java b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/Orientation.java new file mode 100644 index 00000000..5f59b4e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/Orientation.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.components.sensors; + +import androidx.annotation.NonNull; + +public enum Orientation { + PORTRAIT_BOTTOM_EDGE(0), + LANDSCAPE_LEFT_EDGE(90), + LANDSCAPE_RIGHT_EDGE(270); + + private final int degrees; + + Orientation(int degrees) { + this.degrees = degrees; + } + + public int getDegrees() { + return degrees; + } + + public static @NonNull Orientation fromDegrees(int degrees) { + for (Orientation orientation : Orientation.values()) { + if (orientation.degrees == degrees) { + return orientation; + } + } + + return PORTRAIT_BOTTOM_EDGE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/sensors/SensorUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/SensorUtil.java new file mode 100644 index 00000000..853af2e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/sensors/SensorUtil.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.components.sensors; + +public final class SensorUtil { + + private SensorUtil() { } + + public static void getRotationMatrixWithoutMagneticSensorData(float[] rotationMatrix, float[] accelerometerReading) { + double gx, gy, gz; + gx = accelerometerReading[0] / 9.81f; + gy = accelerometerReading[1] / 9.81f; + gz = accelerometerReading[2] / 9.81f; + + float pitch = (float) -Math.atan(gy / Math.sqrt(gx * gx + gz * gz)); + float roll = (float) -Math.atan(gx / Math.sqrt(gy * gy + gz * gz)); + float azimuth = 0; + + float[] fakeMagnetometerReading = { azimuth, pitch, roll }; + + System.arraycopy(getRotationMatrixForOrientation(fakeMagnetometerReading), 0, rotationMatrix, 0, rotationMatrix.length); + } + + private static float[] getRotationMatrixForOrientation(float[] o) { + float[] xM = new float[9]; + float[] yM = new float[9]; + float[] zM = new float[9]; + + float sinX = (float) Math.sin(o[1]); + float cosX = (float) Math.cos(o[1]); + float sinY = (float) Math.sin(o[2]); + float cosY = (float) Math.cos(o[2]); + float sinZ = (float) Math.sin(o[0]); + float cosZ = (float) Math.cos(o[0]); + + xM[0] = 1.0f; + xM[1] = 0.0f; + xM[2] = 0.0f; + + xM[3] = 0.0f; + xM[4] = cosX; + xM[5] = sinX; + + xM[6] = 0.0f; + xM[7] = -sinX; + xM[8] = cosX; + + yM[0] = cosY; + yM[1] = 0.0f; + yM[2] = sinY; + + yM[3] = 0.0f; + yM[4] = 1.0f; + yM[5] = 0.0f; + + yM[6] = -sinY; + yM[7] = 0.0f; + yM[8] = cosY; + + zM[0] = cosZ; + zM[1] = sinZ; + zM[2] = 0.0f; + + zM[3] = -sinZ; + zM[4] = cosZ; + zM[5] = 0.0f; + + zM[6] = 0.0f; + zM[7] = 0.0f; + zM[8] = 1.0f; + + float[] resultMatrix = matrixMultiplication(xM, yM); + resultMatrix = matrixMultiplication(zM, resultMatrix); + return resultMatrix; + } + + private static float[] matrixMultiplication(float[] A, float[] B) { + float[] result = new float[9]; + + result[0] = A[0] * B[0] + A[1] * B[3] + A[2] * B[6]; + result[1] = A[0] * B[1] + A[1] * B[4] + A[2] * B[7]; + result[2] = A[0] * B[2] + A[1] * B[5] + A[2] * B[8]; + + result[3] = A[3] * B[0] + A[4] * B[3] + A[5] * B[6]; + result[4] = A[3] * B[1] + A[4] * B[4] + A[5] * B[7]; + result[5] = A[3] * B[2] + A[4] * B[5] + A[5] * B[8]; + + result[6] = A[6] * B[0] + A[7] * B[3] + A[8] * B[6]; + result[7] = A[6] * B[1] + A[7] * B[4] + A[8] * B[7]; + result[8] = A[6] * B[2] + A[7] * B[5] + A[8] * B[8]; + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java new file mode 100644 index 00000000..a9769f39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.components.settings; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +/** + * Reusable adapter for generic settings list. + */ +public class BaseSettingsAdapter extends MappingAdapter { + public void configureSingleSelect(@NonNull SingleSelectSetting.SingleSelectSelectionChangedListener selectionChangedListener) { + registerFactory(SingleSelectSetting.Item.class, + new LayoutFactory<>(v -> new SingleSelectSetting.ViewHolder(v, selectionChangedListener), R.layout.single_select_item)); + } + + public void configureCustomizableSingleSelect(@NonNull CustomizableSingleSelectSetting.CustomizableSingleSelectionListener selectionListener) { + registerFactory(CustomizableSingleSelectSetting.Item.class, + new LayoutFactory<>(v -> new CustomizableSingleSelectSetting.ViewHolder(v, selectionListener), R.layout.customizable_single_select_item)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java new file mode 100644 index 00000000..02a7fd9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.components.settings; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingModelList; + +import java.io.Serializable; +import java.util.Objects; + +/** + * A simple settings screen that takes its configuration via {@link Configuration}. + */ +public class BaseSettingsFragment extends Fragment { + + private static final String CONFIGURATION_ARGUMENT = "current_selection"; + + private RecyclerView recycler; + + public static @NonNull BaseSettingsFragment create(@NonNull Configuration configuration) { + BaseSettingsFragment fragment = new BaseSettingsFragment(); + + Bundle arguments = new Bundle(); + arguments.putSerializable(CONFIGURATION_ARGUMENT, configuration); + fragment.setArguments(arguments); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.base_settings_fragment, container, false); + + recycler = view.findViewById(R.id.base_settings_list); + recycler.setItemAnimator(null); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + BaseSettingsAdapter adapter = new BaseSettingsAdapter(); + + recycler.setLayoutManager(new LinearLayoutManager(requireContext())); + recycler.setAdapter(adapter); + + Configuration configuration = (Configuration) Objects.requireNonNull(requireArguments().getSerializable(CONFIGURATION_ARGUMENT)); + configuration.configure(requireActivity(), adapter); + configuration.setArguments(getArguments()); + configuration.configureAdapter(adapter); + + adapter.submitList(configuration.getSettings()); + } + + /** + * A configuration for a settings screen. Utilizes serializable to hide + * reflection of instantiating from a fragment argument. + */ + public static abstract class Configuration implements Serializable { + protected transient FragmentActivity activity; + protected transient BaseSettingsAdapter adapter; + + public void configure(@NonNull FragmentActivity activity, @NonNull BaseSettingsAdapter adapter) { + this.activity = activity; + this.adapter = adapter; + } + + /** + * Retrieve any runtime information from the fragment's arguments. + */ + public void setArguments(@Nullable Bundle arguments) {} + + protected void updateSettingsList() { + adapter.submitList(getSettings()); + } + + public abstract void configureAdapter(@NonNull BaseSettingsAdapter adapter); + + public abstract @NonNull MappingModelList getSettings(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java new file mode 100644 index 00000000..70c227ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.components.settings; + +import android.view.View; +import android.widget.RadioButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.Group; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +import java.util.Objects; + +/** + * Adds ability to customize a value for a single select (radio) setting. + */ +public class CustomizableSingleSelectSetting { + + public interface CustomizableSingleSelectionListener extends SingleSelectSetting.SingleSelectSelectionChangedListener { + void onCustomizeClicked(@NonNull Item item); + } + + public static class ViewHolder extends MappingViewHolder { + private final TextView summaryText; + private final View customize; + private final RadioButton radio; + private final SingleSelectSetting.ViewHolder delegate; + private final Group customizeGroup; + private final CustomizableSingleSelectionListener selectionListener; + + public ViewHolder(@NonNull View itemView, @NonNull CustomizableSingleSelectionListener selectionListener) { + super(itemView); + this.selectionListener = selectionListener; + + radio = findViewById(R.id.customizable_single_select_radio); + summaryText = findViewById(R.id.customizable_single_select_summary); + customize = findViewById(R.id.customizable_single_select_customize); + customizeGroup = findViewById(R.id.customizable_single_select_customize_group); + + delegate = new SingleSelectSetting.ViewHolder(itemView, selectionListener) { + @Override + protected void setChecked(boolean checked) { + radio.setChecked(checked); + } + }; + } + + @Override + public void bind(@NonNull Item model) { + delegate.bind(model.singleSelectItem); + customizeGroup.setVisibility(radio.isChecked() ? View.VISIBLE : View.GONE); + customize.setOnClickListener(v -> selectionListener.onCustomizeClicked(model)); + if (model.getCustomValue() != null) { + summaryText.setText(model.getSummaryText()); + } + } + } + + public static class Item implements MappingModel { + private SingleSelectSetting.Item singleSelectItem; + private Object customValue; + private String summaryText; + + public Item(@NonNull T item, @Nullable String text, boolean isSelected, @Nullable Object customValue, @Nullable String summaryText) { + this.customValue = customValue; + this.summaryText = summaryText; + + singleSelectItem = new SingleSelectSetting.Item(item, text, isSelected); + } + + public @Nullable Object getCustomValue() { + return customValue; + } + + public @Nullable String getSummaryText() { + return summaryText; + } + + @Override + public boolean areItemsTheSame(@NonNull Item newItem) { + return singleSelectItem.areItemsTheSame(newItem.singleSelectItem); + } + + @Override + public boolean areContentsTheSame(@NonNull Item newItem) { + return singleSelectItem.areContentsTheSame(newItem.singleSelectItem) && Objects.equals(customValue, newItem.customValue) && Objects.equals(summaryText, newItem.summaryText); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java new file mode 100644 index 00000000..7300bcd8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.components.settings; + +import android.view.View; +import android.widget.CheckedTextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +import java.util.Objects; + +/** + * Single select (radio) setting option + */ +public class SingleSelectSetting { + + public interface SingleSelectSelectionChangedListener { + void onSelectionChanged(@NonNull Object selection); + } + + public static class ViewHolder extends MappingViewHolder { + + protected final CheckedTextView text; + protected final SingleSelectSelectionChangedListener selectionChangedListener; + + public ViewHolder(@NonNull View itemView, @NonNull SingleSelectSelectionChangedListener selectionChangedListener) { + super(itemView); + this.selectionChangedListener = selectionChangedListener; + this.text = findViewById(R.id.single_select_item_text); + } + + @Override + public void bind(@NonNull Item model) { + text.setText(model.text); + setChecked(model.isSelected); + itemView.setOnClickListener(v -> selectionChangedListener.onSelectionChanged(model.item)); + } + + protected void setChecked(boolean checked) { + text.setChecked(checked); + } + } + + public static class Item implements MappingModel { + private final String text; + private final Object item; + private final boolean isSelected; + + public Item(@NonNull T item, @Nullable String text, boolean isSelected) { + this.item = item; + this.text = text != null ? text : item.toString(); + this.isSelected = isSelected; + } + + public @NonNull String getText() { + return text; + } + + public @NonNull Object getItem() { + return item; + } + + @Override + public boolean areItemsTheSame(@NonNull Item newItem) { + return item.equals(newItem.item); + } + + @Override + public boolean areContentsTheSame(@NonNull Item newItem) { + return Objects.equals(text, newItem.text) && isSelected == newItem.isSelected; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java new file mode 100644 index 00000000..9e77d034 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentBitmapDecoder.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.subsampling; + + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; + +import com.davemorrissey.labs.subscaleview.decoder.ImageDecoder; +import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder; + +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.InputStream; + +public class AttachmentBitmapDecoder implements ImageDecoder{ + + public AttachmentBitmapDecoder() {} + + @Override + public Bitmap decode(Context context, Uri uri) throws Exception { + if (!PartAuthority.isLocalUri(uri)) { + return new SkiaImageDecoder().decode(context, uri); + } + + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + + Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options); + + if (bitmap == null) { + throw new RuntimeException("Skia image region decoder returned null bitmap - image format may not be supported"); + } + + return bitmap; + } finally { + if (inputStream != null) inputStream.close(); + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java new file mode 100644 index 00000000..239f0906 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/subsampling/AttachmentRegionDecoder.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.components.subsampling; + + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; + +import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder; +import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.InputStream; + +public class AttachmentRegionDecoder implements ImageRegionDecoder { + + private static final String TAG = AttachmentRegionDecoder.class.getSimpleName(); + + private SkiaImageRegionDecoder passthrough; + + private BitmapRegionDecoder bitmapRegionDecoder; + + @Override + public Point init(Context context, Uri uri) throws Exception { + Log.d(TAG, "Init!"); + if (!PartAuthority.isLocalUri(uri)) { + passthrough = new SkiaImageRegionDecoder(); + return passthrough.init(context, uri); + } + + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + + this.bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false); + inputStream.close(); + + return new Point(bitmapRegionDecoder.getWidth(), bitmapRegionDecoder.getHeight()); + } + + @Override + public Bitmap decodeRegion(Rect rect, int sampleSize) { + Log.d(TAG, "Decode region: " + rect); + + if (passthrough != null) { + return passthrough.decodeRegion(rect, sampleSize); + } + + synchronized(this) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + options.inPreferredConfig = Bitmap.Config.RGB_565; + + Bitmap bitmap = bitmapRegionDecoder.decodeRegion(rect, options); + + if (bitmap == null) { + throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported"); + } + + return bitmap; + } + } + + public boolean isReady() { + Log.d(TAG, "isReady"); + return (passthrough != null && passthrough.isReady()) || + (bitmapRegionDecoder != null && !bitmapRegionDecoder.isRecycled()); + } + + public void recycle() { + if (passthrough != null) { + passthrough.recycle(); + passthrough = null; + } else { + bitmapRegionDecoder.recycle(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java b/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java new file mode 100644 index 00000000..ae679ddc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/ExtendedOnPageChangedListener.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.components.viewpager; + + +import androidx.viewpager.widget.ViewPager; + +public abstract class ExtendedOnPageChangedListener implements ViewPager.OnPageChangeListener { + + private Integer currentPage = null; + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + + } + + @Override + public void onPageSelected(int position) { + if (currentPage != null && currentPage != position) onPageUnselected(currentPage); + currentPage = position; + } + + public abstract void onPageUnselected(int position); + + @Override + public void onPageScrollStateChanged(int state) { + + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java b/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java new file mode 100644 index 00000000..ef07b4f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/viewpager/HackyViewPager.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.viewpager; + + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import androidx.viewpager.widget.ViewPager; + +import org.signal.core.util.logging.Log; + +/** + * Hacky fix for http://code.google.com/p/android/issues/detail?id=18990 + *

+ * ScaleGestureDetector seems to mess up the touch events, which means that + * ViewGroups which make use of onInterceptTouchEvent throw a lot of + * IllegalArgumentException: pointerIndex out of range. + *

+ * There's not much I can do in my code for now, but we can mask the result by + * just catching the problem and ignoring it. + * + * @author Chris Banes + */ +public class HackyViewPager extends ViewPager { + + private static final String TAG = HackyViewPager.class.getSimpleName(); + + public HackyViewPager(Context context) { + super(context); + } + + public HackyViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + try { + return super.onInterceptTouchEvent(ev); + } catch (IllegalArgumentException e) { + Log.w(TAG, e); + return false; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java new file mode 100644 index 00000000..df0ef955 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -0,0 +1,270 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.ComponentName; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.RemoteException; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.signal.core.util.logging.Log; + +import java.util.Objects; + +/** + * Encapsulates control of voice note playback from an Activity component. + * + * This class assumes that it will be created within the scope of Activity#onCreate + * + * The workhorse of this repository is the ProgressEventHandler, which will supply a + * steady stream of update events to the set callback. + */ +public class VoiceNoteMediaController implements DefaultLifecycleObserver { + + public static final String EXTRA_MESSAGE_ID = "voice.note.message_id"; + public static final String EXTRA_PROGRESS = "voice.note.playhead"; + public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single"; + + private static final String TAG = Log.tag(VoiceNoteMediaController.class); + + private MediaBrowserCompat mediaBrowser; + private AppCompatActivity activity; + private ProgressEventHandler progressEventHandler; + private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); + + private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback(); + + public VoiceNoteMediaController(@NonNull AppCompatActivity activity) { + this.activity = activity; + this.mediaBrowser = new MediaBrowserCompat(activity, + new ComponentName(activity, VoiceNotePlaybackService.class), + new ConnectionCallback(), + null); + + activity.getLifecycle().addObserver(this); + } + + public LiveData getVoiceNotePlaybackState() { + return voiceNotePlaybackState; + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + mediaBrowser.connect(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + activity.setVolumeControlStream(AudioManager.STREAM_MUSIC); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + clearProgressEventHandler(); + + if (MediaControllerCompat.getMediaController(activity) != null) { + MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback); + } + mediaBrowser.disconnect(); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + activity.getLifecycle().removeObserver(this); + activity = null; + } + + private static boolean isPlayerActive(@NonNull PlaybackStateCompat playbackStateCompat) { + return playbackStateCompat.getState() == PlaybackStateCompat.STATE_BUFFERING || + playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING; + } + + private @NonNull MediaControllerCompat getMediaController() { + return MediaControllerCompat.getMediaController(activity); + } + + + public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { + startPlayback(audioSlideUri, messageId, progress, false); + } + + public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { + startPlayback(audioSlideUri, messageId, progress, true); + } + + /** + * Tells the Media service to begin playback of a given audio slide. If the audio + * slide is currently playing, we jump to the desired position and then begin playback. + * + * @param audioSlideUri The Uri of the desired audio slide + * @param messageId The Message id of the given audio slide + * @param progress The desired progress % to seek to. + * @param singlePlayback The player will only play back the specified Uri, and not build a playlist. + */ + private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) { + if (isCurrentTrack(audioSlideUri)) { + long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION); + + getMediaController().getTransportControls().seekTo((long) (duration * progress)); + getMediaController().getTransportControls().play(); + } else { + Bundle extras = new Bundle(); + extras.putLong(EXTRA_MESSAGE_ID, messageId); + extras.putDouble(EXTRA_PROGRESS, progress); + extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback); + + getMediaController().getTransportControls().playFromUri(audioSlideUri, extras); + } + } + + /** + * Pauses playback if the given audio slide is playing. + * + * @param audioSlideUri The Uri of the audio slide to pause. + */ + public void pausePlayback(@NonNull Uri audioSlideUri) { + if (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().pause(); + } + } + + /** + * Seeks to a given position if th given audio slide is playing. This call + * is ignored if the given audio slide is not currently playing. + * + * @param audioSlideUri The Uri of the audio slide to seek. + * @param progress The progress percentage to seek to. + */ + public void seekToPosition(@NonNull Uri audioSlideUri, double progress) { + if (isCurrentTrack(audioSlideUri)) { + long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION); + + getMediaController().getTransportControls().pause(); + getMediaController().getTransportControls().seekTo((long) (duration * progress)); + getMediaController().getTransportControls().play(); + } + } + + /** + * Stops playback if the given audio slide is playing + * + * @param audioSlideUri The Uri of the audio slide to stop + */ + public void stopPlaybackAndReset(@NonNull Uri audioSlideUri) { + if (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().stop(); + } + } + + private boolean isCurrentTrack(@NonNull Uri uri) { + MediaMetadataCompat metadataCompat = getMediaController().getMetadata(); + + return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri); + } + + private void notifyProgressEventHandler() { + if (progressEventHandler == null) { + progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState); + progressEventHandler.sendEmptyMessage(0); + } + } + + private void clearProgressEventHandler() { + if (progressEventHandler != null) { + progressEventHandler = null; + } + } + + private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback { + @Override + public void onConnected() { + try { + MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); + MediaControllerCompat mediaController = new MediaControllerCompat(activity, token); + + MediaControllerCompat.setMediaController(activity, mediaController); + + mediaController.registerCallback(mediaControllerCompatCallback); + + mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState()); + } catch (RemoteException e) { + Log.w(TAG, "onConnected: Failed to set media controller", e); + } + } + } + + private static class ProgressEventHandler extends Handler { + + private final MediaControllerCompat mediaController; + private final MutableLiveData voiceNotePlaybackState; + + private ProgressEventHandler(@NonNull MediaControllerCompat mediaController, + @NonNull MutableLiveData voiceNotePlaybackState) + { + super(Looper.getMainLooper()); + + this.mediaController = mediaController; + this.voiceNotePlaybackState = voiceNotePlaybackState; + } + + @Override + public void handleMessage(@NonNull Message msg) { + MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); + if (isPlayerActive(mediaController.getPlaybackState()) && + mediaMetadataCompat != null && + mediaMetadataCompat.getDescription() != null && + mediaMetadataCompat.getDescription().getMediaUri() != null) + { + + Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()); + boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI); + VoiceNotePlaybackState previousState = voiceNotePlaybackState.getValue(); + long position = mediaController.getPlaybackState().getPosition(); + long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); + + if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) { + if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) { + position = previousState.getPlayheadPositionMillis(); + } + + if (duration <= 0 && previousState.getTrackDuration() > 0) { + duration = previousState.getTrackDuration(); + } + } + + if (duration > 0 && position >= 0 && position <= duration) { + voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset)); + } + + sendEmptyMessageDelayed(0, 50); + } else { + voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); + } + } + } + + private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + if (isPlayerActive(state)) { + notifyProgressEventHandler(); + } else { + clearProgressEventHandler(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java new file mode 100644 index 00000000..da1985fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; +import java.util.Objects; + +/** + * Factory responsible for building out MediaDescriptionCompat objects for voice notes. + */ +class VoiceNoteMediaDescriptionCompatFactory { + + public static final String EXTRA_MESSAGE_POSITION = "voice.note.extra.MESSAGE_POSITION"; + public static final String EXTRA_THREAD_RECIPIENT_ID = "voice.note.extra.RECIPIENT_ID"; + public static final String EXTRA_AVATAR_RECIPIENT_ID = "voice.note.extra.SENDER_ID"; + public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID"; + public static final String EXTRA_COLOR = "voice.note.extra.COLOR"; + public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID"; + + private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class); + + private VoiceNoteMediaDescriptionCompatFactory() {} + + /** + * Build out a MediaDescriptionCompat for a given voice note. Expects to be run + * on a background thread. + * + * @param context Context. + * @param messageRecord The MessageRecord of the given voice note. + * + * @return A MediaDescriptionCompat with all the details the service expects. + */ + @WorkerThread + static MediaDescriptionCompat buildMediaDescription(@NonNull Context context, + @NonNull MessageRecord messageRecord) + { + int startingPosition = DatabaseFactory.getMmsSmsDatabase(context) + .getMessagePositionInConversation(messageRecord.getThreadId(), + messageRecord.getDateReceived()); + + Recipient threadRecipient = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context) + .getRecipientForThreadId(messageRecord.getThreadId())); + Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient(); + Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender; + + Bundle extras = new Bundle(); + extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize()); + extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize()); + extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition); + extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId()); + extras.putString(EXTRA_COLOR, threadRecipient.getColor().serialize()); + extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId()); + + NotificationPrivacyPreference preference = TextSecurePreferences.getNotificationPrivacy(context); + + String title; + if (preference.isDisplayContact() && threadRecipient.isGroup()) { + title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s, + sender.getDisplayName(context), + threadRecipient.getDisplayName(context)); + } else if (preference.isDisplayContact()) { + title = sender.getDisplayName(context); + } else { + title = context.getString(R.string.MessageNotifier_signal_message); + } + + String subtitle = null; + if (preference.isDisplayContact()) { + subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message, + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), + messageRecord.getDateReceived())); + } + + Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri(); + + return new MediaDescriptionCompat.Builder() + .setMediaUri(uri) + .setTitle(title) + .setSubtitle(subtitle) + .setExtras(extras) + .build(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java new file mode 100644 index 00000000..cdb6b365 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.Context; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; + +/** + * This class is responsible for creating a MediaSource object for a given MediaDescriptionCompat + */ +final class VoiceNoteMediaSourceFactory { + + private final Context context; + + VoiceNoteMediaSourceFactory(Context context) { + this.context = context; + } + + /** + * Creates a MediaSource for a given MediaDescriptionCompat + * + * @param description The description to build from + * + * @return A preparable MediaSource + */ + public @Nullable MediaSource createMediaSource(MediaDescriptionCompat description) { + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); + + return new ExtractorMediaSource.Factory(attachmentDataSourceFactory) + .setExtractorsFactory(extractorsFactory) + .createMediaSource(description.getMediaUri()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java new file mode 100644 index 00000000..8301a98c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.components.voice; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; + +public class VoiceNoteNotificationControlDispatcher extends DefaultControlDispatcher { + + private final VoiceNoteQueueDataAdapter dataAdapter; + + public VoiceNoteNotificationControlDispatcher(@NonNull VoiceNoteQueueDataAdapter dataAdapter) { + this.dataAdapter = dataAdapter; + } + + @Override + public boolean dispatchSeekTo(Player player, int windowIndex, long positionMs) { + boolean isQueueToneIndex = windowIndex % 2 == 1; + boolean isSeekingToStart = positionMs == C.TIME_UNSET; + + if (isQueueToneIndex && isSeekingToStart) { + int nextVoiceNoteWindowIndex = player.getCurrentWindowIndex() < windowIndex ? windowIndex + 1 : windowIndex - 1; + + if (dataAdapter.size() <= nextVoiceNoteWindowIndex) { + return super.dispatchSeekTo(player, windowIndex, positionMs); + } else { + return super.dispatchSeekTo(player, nextVoiceNoteWindowIndex, positionMs); + } + } else { + return super.dispatchSeekTo(player, windowIndex, positionMs); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java new file mode 100644 index 00000000..926ad973 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java @@ -0,0 +1,167 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.RemoteException; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.PlayerNotificationManager; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Objects; + +class VoiceNoteNotificationManager { + + private static final short NOW_PLAYING_NOTIFICATION_ID = 32221; + + private final Context context; + private final MediaControllerCompat controller; + private final PlayerNotificationManager notificationManager; + + VoiceNoteNotificationManager(@NonNull Context context, + @NonNull MediaSessionCompat.Token token, + @NonNull PlayerNotificationManager.NotificationListener listener, + @NonNull VoiceNoteQueueDataAdapter dataAdapter) + { + this.context = context; + + try { + controller = new MediaControllerCompat(context, token); + } catch (RemoteException e) { + throw new IllegalArgumentException("Could not create a controller with given token"); + } + + notificationManager = PlayerNotificationManager.createWithNotificationChannel(context, + NotificationChannels.VOICE_NOTES, + R.string.NotificationChannel_voice_notes, + NOW_PLAYING_NOTIFICATION_ID, + new DescriptionAdapter()); + + notificationManager.setMediaSessionToken(token); + notificationManager.setSmallIcon(R.drawable.ic_notification); + notificationManager.setRewindIncrementMs(0); + notificationManager.setFastForwardIncrementMs(0); + notificationManager.setNotificationListener(listener); + notificationManager.setColorized(true); + notificationManager.setControlDispatcher(new VoiceNoteNotificationControlDispatcher(dataAdapter)); + } + + public void hideNotification() { + notificationManager.setPlayer(null); + } + + public void showNotification(@NonNull Player player) { + notificationManager.setPlayer(player); + } + + private final class DescriptionAdapter implements PlayerNotificationManager.MediaDescriptionAdapter { + + private RecipientId cachedRecipientId; + private Bitmap cachedBitmap; + + @Override + public String getCurrentContentTitle(Player player) { + if (hasMetadata()) { + return Objects.toString(controller.getMetadata().getDescription().getTitle(), null); + } else { + return null; + } + } + + @Override + public @Nullable PendingIntent createCurrentContentIntent(Player player) { + if (!hasMetadata()) return null; + + String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID); + if (serializedRecipientId == null) { + return null; + } + + RecipientId recipientId = RecipientId.from(serializedRecipientId); + int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION); + long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID); + + MaterialColor color; + try { + color = MaterialColor.fromSerialized(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR)); + } catch (MaterialColor.UnknownColorException e) { + color = ContactColors.UNKNOWN_COLOR; + } + + notificationManager.setColor(color.toNotificationColor(context)); + + Intent conversationActivity = ConversationIntents.createBuilder(context, recipientId, threadId) + .withStartingPosition(startingPosition) + .build(); + + conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + return PendingIntent.getActivity(context, + 0, + conversationActivity, + PendingIntent.FLAG_CANCEL_CURRENT); + } + + @Override + public String getCurrentContentText(Player player) { + if (hasMetadata()) { + return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null); + } else { + return null; + } + } + + @Override + public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) { + if (!hasMetadata() || !TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact()) { + cachedBitmap = null; + cachedRecipientId = null; + return null; + } + + String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_AVATAR_RECIPIENT_ID); + if (serializedRecipientId == null) { + return null; + } + + RecipientId currentRecipientId = RecipientId.from(serializedRecipientId); + + if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) { + return cachedBitmap; + } else { + cachedRecipientId = currentRecipientId; + SignalExecutors.BOUNDED.execute(() -> { + try { + cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId)); + callback.onBitmap(cachedBitmap); + } catch (Exception e) { + cachedBitmap = null; + } + }); + + return null; + } + } + + private boolean hasMetadata() { + return controller.getMetadata() != null && controller.getMetadata().getDescription() != null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java new file mode 100644 index 00000000..1978fa27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -0,0 +1,277 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.ResultReceiver; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.session.PlaybackStateCompat; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI + */ +final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackPreparer { + + private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class); + private static final Executor EXECUTOR = Executors.newSingleThreadExecutor(); + private static final long LIMIT = 5; + + public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg"); + public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg"); + + private final Context context; + private final SimpleExoPlayer player; + private final VoiceNoteQueueDataAdapter queueDataAdapter; + private final VoiceNoteMediaSourceFactory mediaSourceFactory; + private final ConcatenatingMediaSource dataSource; + + private boolean canLoadMore; + private Uri latestUri = Uri.EMPTY; + + VoiceNotePlaybackPreparer(@NonNull Context context, + @NonNull SimpleExoPlayer player, + @NonNull VoiceNoteQueueDataAdapter queueDataAdapter, + @NonNull VoiceNoteMediaSourceFactory mediaSourceFactory) + { + this.context = context; + this.player = player; + this.queueDataAdapter = queueDataAdapter; + this.mediaSourceFactory = mediaSourceFactory; + this.dataSource = new ConcatenatingMediaSource(); + } + + @Override + public long getSupportedPrepareActions() { + return PlaybackStateCompat.ACTION_PLAY_FROM_URI; + } + + @Override + public void onPrepare() { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepare"); + } + + @Override + public void onPrepareFromMediaId(String mediaId, Bundle extras) { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId"); + } + + @Override + public void onPrepareFromSearch(String query, Bundle extras) { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch"); + } + + @Override + public void onPrepareFromUri(final Uri uri, Bundle extras) { + Log.d(TAG, "onPrepareFromUri: " + uri); + + long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID); + double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0); + boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false); + + canLoadMore = false; + latestUri = uri; + + SimpleTask.run(EXECUTOR, + () -> { + if (singlePlayback) { + return loadMediaDescriptionForSinglePlayback(messageId); + } else { + return loadMediaDescriptionsForConsecutivePlayback(messageId); + } + }, + descriptions -> { + queueDataAdapter.clear(); + dataSource.clear(); + + if (Util.hasItems(descriptions) && Objects.equals(latestUri, uri)) { + applyDescriptionsToQueue(descriptions); + + int window = Math.max(0, queueDataAdapter.indexOf(uri)); + + player.addListener(new Player.EventListener() { + @Override + public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) { + if (timeline.getWindowCount() >= window) { + player.seekTo(window, (long) (player.getDuration() * progress)); + player.removeListener(this); + } + } + }); + + player.prepare(dataSource); + canLoadMore = !singlePlayback; + } + }); + } + + @Override + public String[] getCommands() { + return new String[0]; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + } + + @MainThread + private void applyDescriptionsToQueue(@NonNull List descriptions) { + for (MediaDescriptionCompat description : descriptions) { + int holderIndex = queueDataAdapter.indexOf(description.getMediaUri()); + MediaDescriptionCompat next = createNextClone(description); + int currentIndex = player.getCurrentWindowIndex(); + + if (holderIndex != -1) { + queueDataAdapter.remove(holderIndex); + + if (!queueDataAdapter.isEmpty()) { + queueDataAdapter.remove(holderIndex); + } + + queueDataAdapter.add(holderIndex, createNextClone(description)); + queueDataAdapter.add(holderIndex, description); + + if (currentIndex != holderIndex) { + dataSource.removeMediaSource(holderIndex); + dataSource.addMediaSource(holderIndex, mediaSourceFactory.createMediaSource(description)); + } + + if (currentIndex != holderIndex + 1) { + if (dataSource.getSize() > 1) { + dataSource.removeMediaSource(holderIndex + 1); + } + + dataSource.addMediaSource(holderIndex + 1, mediaSourceFactory.createMediaSource(next)); + } + } else { + int insertLocation = queueDataAdapter.indexAfter(description); + + queueDataAdapter.add(insertLocation, next); + queueDataAdapter.add(insertLocation, description); + + dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(next)); + dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(description)); + } + } + + int lastIndex = queueDataAdapter.size() - 1; + MediaDescriptionCompat last = queueDataAdapter.getMediaDescription(lastIndex); + + if (Objects.equals(last.getMediaUri(), NEXT_URI)) { + queueDataAdapter.remove(lastIndex); + dataSource.removeMediaSource(lastIndex); + + if (queueDataAdapter.size() > 1) { + MediaDescriptionCompat end = createEndClone(last); + + queueDataAdapter.add(lastIndex, end); + dataSource.addMediaSource(lastIndex, mediaSourceFactory.createMediaSource(end)); + } + } + + if (queueDataAdapter.size() != dataSource.getSize()) { + throw new IllegalStateException("QueueDataAdapter and DataSource size inconsistency."); + } + } + + private @NonNull MediaDescriptionCompat createEndClone(@NonNull MediaDescriptionCompat source) { + return buildUpon(source).setMediaId("end").setMediaUri(END_URI).build(); + } + + private @NonNull MediaDescriptionCompat createNextClone(@NonNull MediaDescriptionCompat source) { + return buildUpon(source).setMediaId("next").setMediaUri(NEXT_URI).build(); + } + + private @NonNull MediaDescriptionCompat.Builder buildUpon(@NonNull MediaDescriptionCompat source) { + return new MediaDescriptionCompat.Builder() + .setSubtitle(source.getSubtitle()) + .setDescription(source.getDescription()) + .setTitle(source.getTitle()) + .setIconUri(source.getIconUri()) + .setIconBitmap(source.getIconBitmap()) + .setMediaId(source.getMediaId()) + .setExtras(source.getExtras()); + } + + public void loadMoreVoiceNotes() { + if (!canLoadMore) { + return; + } + + MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(player.getCurrentWindowIndex()); + if (Objects.equals(mediaDescriptionCompat, VoiceNoteQueueDataAdapter.EMPTY)) { + return; + } + + long messageId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + + SimpleTask.run(EXECUTOR, + () -> loadMediaDescriptionsForConsecutivePlayback(messageId), + descriptions -> { + if (Util.hasItems(descriptions) && canLoadMore) { + applyDescriptionsToQueue(descriptions); + } + }); + } + + private @NonNull List loadMediaDescriptionForSinglePlayback(long messageId) { + try { + MessageRecord messageRecord = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + + if (!MessageRecordUtil.hasAudio(messageRecord)) { + Log.w(TAG, "Message does not contain audio."); + return Collections.emptyList(); + } + + return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord)); + } catch (NoSuchMessageException e) { + Log.w(TAG, "Could not find message.", e); + return Collections.emptyList(); + } + } + + @WorkerThread + private @NonNull List loadMediaDescriptionsForConsecutivePlayback(long messageId) { + try { + List recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT); + + return Stream.of(buildFilteredMessageRecordList(recordsAfter)) + .map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record)) + .toList(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "Could not find message.", e); + return Collections.emptyList(); + } + } + + private static @NonNull List buildFilteredMessageRecordList(@NonNull List recordsAfter) { + return Stream.of(recordsAfter) + .takeWhile(MessageRecordUtil::hasAudio) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java new file mode 100644 index 00000000..7f59b58e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -0,0 +1,251 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.app.Notification; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.os.Bundle; +import android.os.Process; +import android.os.RemoteException; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.media.MediaBrowserServiceCompat; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.ui.PlayerNotificationManager; + +import org.signal.core.util.logging.Log; + +import java.util.Collections; +import java.util.List; + +/** + * Android Service responsible for playback of voice notes. + */ +public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { + + private static final String TAG = Log.tag(VoiceNotePlaybackService.class); + private static final String EMPTY_ROOT_ID = "empty-root-id"; + private static final int LOAD_MORE_THRESHOLD = 2; + + private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY | + PlaybackStateCompat.ACTION_PAUSE | + PlaybackStateCompat.ACTION_SEEK_TO | + PlaybackStateCompat.ACTION_STOP | + PlaybackStateCompat.ACTION_PLAY_PAUSE; + + private MediaSessionCompat mediaSession; + private MediaSessionConnector mediaSessionConnector; + private PlaybackStateCompat.Builder stateBuilder; + private SimpleExoPlayer player; + private BecomingNoisyReceiver becomingNoisyReceiver; + private VoiceNoteNotificationManager voiceNoteNotificationManager; + private VoiceNoteQueueDataAdapter queueDataAdapter; + private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer; + private VoiceNoteProximityManager voiceNoteProximityManager; + private boolean isForegroundService; + + private final LoadControl loadControl = new DefaultLoadControl.Builder() + .setBufferDurationsMs(Integer.MAX_VALUE, + Integer.MAX_VALUE, + Integer.MAX_VALUE, + Integer.MAX_VALUE) + .createDefaultLoadControl(); + + @Override + public void onCreate() { + super.onCreate(); + + mediaSession = new MediaSessionCompat(this, TAG); + stateBuilder = new PlaybackStateCompat.Builder() + .setActions(SUPPORTED_ACTIONS); + mediaSessionConnector = new MediaSessionConnector(mediaSession, null); + becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken()); + player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl); + queueDataAdapter = new VoiceNoteQueueDataAdapter(); + voiceNoteNotificationManager = new VoiceNoteNotificationManager(this, + mediaSession.getSessionToken(), + new VoiceNoteNotificationManagerListener(), + queueDataAdapter); + + VoiceNoteMediaSourceFactory mediaSourceFactory = new VoiceNoteMediaSourceFactory(this); + + voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory); + voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter); + + mediaSession.setPlaybackState(stateBuilder.build()); + + player.addListener(new VoiceNotePlayerEventListener()); + player.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build(), true); + + mediaSessionConnector.setPlayer(player, voiceNotePlaybackPreparer); + mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession, queueDataAdapter)); + + setSessionToken(mediaSession.getSessionToken()); + + mediaSession.setActive(true); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + + player.stop(true); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mediaSession.setActive(false); + mediaSession.release(); + becomingNoisyReceiver.unregister(); + player.release(); + } + + @Override + public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { + if (clientUid == Process.myUid()) { + return new BrowserRoot(EMPTY_ROOT_ID, null); + } else { + return null; + } + } + + @Override + public void onLoadChildren(@NonNull String parentId, @NonNull Result> result) { + result.sendResult(Collections.emptyList()); + } + + private class VoiceNotePlayerEventListener implements Player.EventListener { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + switch (playbackState) { + case Player.STATE_BUFFERING: + case Player.STATE_READY: + voiceNoteNotificationManager.showNotification(player); + + if (!playWhenReady) { + stopForeground(false); + becomingNoisyReceiver.unregister(); + voiceNoteProximityManager.onPlayerEnded(); + } else { + becomingNoisyReceiver.register(); + voiceNoteProximityManager.onPlayerReady(); + } + break; + default: + voiceNoteProximityManager.onPlayerEnded(); + becomingNoisyReceiver.unregister(); + voiceNoteNotificationManager.hideNotification(); + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + int currentWindowIndex = player.getCurrentWindowIndex(); + if (currentWindowIndex == C.INDEX_UNSET) { + return; + } + + if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) { + MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex); + Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri()); + } + + boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD || + currentWindowIndex + LOAD_MORE_THRESHOLD >= queueDataAdapter.size(); + + if (isWithinThreshold && currentWindowIndex % 2 == 0) { + voiceNotePlaybackPreparer.loadMoreVoiceNotes(); + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + Log.w(TAG, "ExoPlayer error occurred:", error); + voiceNoteProximityManager.onPlayerError(); + } + } + + private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener { + + @Override + public void onNotificationStarted(int notificationId, Notification notification) { + if (!isForegroundService) { + ContextCompat.startForegroundService(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class)); + startForeground(notificationId, notification); + isForegroundService = true; + } + } + + @Override + public void onNotificationCancelled(int notificationId) { + stopForeground(true); + isForegroundService = false; + stopSelf(); + } + } + + /** + * Receiver to pause playback when things become noisy. + */ + private static class BecomingNoisyReceiver extends BroadcastReceiver { + private static final IntentFilter NOISY_INTENT_FILTER = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY); + + private final Context context; + private final MediaControllerCompat controller; + + private boolean registered; + + private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) { + this.context = context; + try { + this.controller = new MediaControllerCompat(context, token); + } catch (RemoteException e) { + throw new IllegalArgumentException("Failed to create controller from token", e); + } + } + + void register() { + if (!registered) { + context.registerReceiver(this, NOISY_INTENT_FILTER); + registered = true; + } + } + + void unregister() { + if (registered) { + context.unregisterReceiver(this); + registered = false; + } + } + + public void onReceive(Context context, @NonNull Intent intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { + controller.getTransportControls().pause(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java new file mode 100644 index 00000000..fd4658a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +/** + * Domain-level state object representing the state of the currently playing voice note. + */ +public class VoiceNotePlaybackState { + + public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false); + + private final Uri uri; + private final long playheadPositionMillis; + private final long trackDuration; + private final boolean autoReset; + + public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis, long trackDuration, boolean autoReset) { + this.uri = uri; + this.playheadPositionMillis = playheadPositionMillis; + this.trackDuration = trackDuration; + this.autoReset = autoReset; + } + + /** + * @return Uri of the currently playing AudioSlide + */ + public Uri getUri() { + return uri; + } + + /** + * @return The last known playhead position + */ + public long getPlayheadPositionMillis() { + return playheadPositionMillis; + } + + /** + * @return The track duration in ms + */ + public long getTrackDuration() { + return trackDuration; + } + + /** + * @return true if we should reset the currently playing clip. + */ + public boolean isAutoReset() { + return autoReset; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java new file mode 100644 index 00000000..5ca43b05 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.media.AudioManager; +import android.os.Build; +import android.os.PowerManager; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.util.Util; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.util.concurrent.TimeUnit; + +class VoiceNoteProximityManager implements SensorEventListener { + + private static final String TAG = Log.tag(VoiceNoteProximityManager.class); + + private static final float PROXIMITY_THRESHOLD = 5f; + + private final SimpleExoPlayer player; + private final AudioManager audioManager; + private final SensorManager sensorManager; + private final Sensor proximitySensor; + private final PowerManager.WakeLock wakeLock; + private final VoiceNoteQueueDataAdapter queueDataAdapter; + + private long startTime; + + VoiceNoteProximityManager(@NonNull Context context, + @NonNull SimpleExoPlayer player, + @NonNull VoiceNoteQueueDataAdapter queueDataAdapter) + { + this.player = player; + this.audioManager = ServiceUtil.getAudioManager(context); + this.sensorManager = ServiceUtil.getSensorManager(context); + this.proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); + this.queueDataAdapter = queueDataAdapter; + + if (Build.VERSION.SDK_INT >= 21) { + this.wakeLock = ServiceUtil.getPowerManager(context).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG); + } else { + this.wakeLock = null; + } + } + + void onPlayerReady() { + startTime = System.currentTimeMillis(); + sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); + } + + void onPlayerEnded() { + sensorManager.unregisterListener(this); + + if (wakeLock != null && wakeLock.isHeld() && Build.VERSION.SDK_INT >= 21) { + wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); + } + } + + void onPlayerError() { + onPlayerEnded(); + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (event.sensor.getType() != Sensor.TYPE_PROXIMITY || player.getPlaybackState() != Player.STATE_READY) { + return; + } + + final int desiredStreamType; + if (event.values[0] < PROXIMITY_THRESHOLD && event.values[0] != proximitySensor.getMaximumRange()) { + desiredStreamType = AudioManager.STREAM_VOICE_CALL; + } else { + desiredStreamType = AudioManager.STREAM_MUSIC; + } + + final int currentStreamType = Util.getStreamTypeForAudioUsage(player.getAudioAttributes().usage); + + final long threadId; + final int windowIndex = player.getCurrentWindowIndex(); + + if (queueDataAdapter.isEmpty() || windowIndex == C.INDEX_UNSET) { + threadId = -1; + } else { + MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(windowIndex); + + if (mediaDescriptionCompat.getExtras() == null) { + threadId = -1; + } else { + threadId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1); + } + } + + if (desiredStreamType == AudioManager.STREAM_VOICE_CALL && + desiredStreamType != currentStreamType && + !audioManager.isWiredHeadsetOn() && + threadId != -1 && + ApplicationDependencies.getMessageNotifier().getVisibleThread() == threadId) + { + if (wakeLock != null && !wakeLock.isHeld()) { + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)); + } + + player.setPlayWhenReady(false); + player.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_VOICE_COMMUNICATION) + .build()); + player.setPlayWhenReady(true); + + startTime = System.currentTimeMillis(); + } else if (desiredStreamType == AudioManager.STREAM_MUSIC && + desiredStreamType != currentStreamType && + System.currentTimeMillis() - startTime > 500) + { + if (wakeLock != null) { + if (wakeLock.isHeld()) { + wakeLock.release(); + } + + player.setPlayWhenReady(false); + player.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build(), + true); + } + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java new file mode 100644 index 00000000..9827fd79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.net.Uri; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; + +import org.signal.core.util.logging.Log; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +/** + * DataAdapter which maintains the current queue of MediaDescriptionCompat objects. + */ +@MainThread +final class VoiceNoteQueueDataAdapter implements TimelineQueueEditor.QueueDataAdapter { + + private static final String TAG = Log.tag(VoiceNoteQueueDataAdapter.class); + + public static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build(); + + private final List descriptions = new LinkedList<>(); + + @Override + public MediaDescriptionCompat getMediaDescription(int position) { + if (descriptions.size() <= position) { + Log.i(TAG, "getMediaDescription: Returning EMPTY MediaDescriptionCompat for index " + position); + return EMPTY; + } + + return descriptions.get(position); + } + + @Override + public void add(int position, MediaDescriptionCompat description) { + descriptions.add(position, description); + } + + @Override + public void remove(int position) { + descriptions.remove(position); + } + + @Override + public void move(int from, int to) { + MediaDescriptionCompat description = descriptions.remove(from); + descriptions.add(to, description); + } + + int size() { + return descriptions.size(); + } + + int indexOf(@NonNull Uri uri) { + for (int i = 0; i < descriptions.size(); i++) { + if (Objects.equals(uri, descriptions.get(i).getMediaUri())) { + return i; + } + } + + return -1; + } + + int indexAfter(@NonNull MediaDescriptionCompat target) { + if (isEmpty()) { + return 0; + } + + long targetMessageId = target.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + for (int i = 0; i < descriptions.size(); i++) { + long descriptionMessageId = descriptions.get(i).getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + + if (descriptionMessageId > targetMessageId) { + return i; + } + } + + return descriptions.size(); + } + + boolean isEmpty() { + return descriptions.isEmpty(); + } + + void clear() { + descriptions.clear(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java new file mode 100644 index 00000000..b4b86ab1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator; + +/** + * Navigator to help support seek forward and back. + */ +final class VoiceNoteQueueNavigator extends TimelineQueueNavigator { + + private final TimelineQueueEditor.QueueDataAdapter queueDataAdapter; + + public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession, @NonNull TimelineQueueEditor.QueueDataAdapter queueDataAdapter) { + super(mediaSession); + this.queueDataAdapter = queueDataAdapter; + } + + @Override + public MediaDescriptionCompat getMediaDescription(Player player, int windowIndex) { + return queueDataAdapter.getMediaDescription(windowIndex); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java new file mode 100644 index 00000000..bb569cdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.RadioButton; +import android.widget.TextView; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +final class AudioOutputAdapter extends RecyclerView.Adapter { + + private final OnAudioOutputChangedListener onAudioOutputChangedListener; + private final List audioOutputs; + + private WebRtcAudioOutput selected; + + AudioOutputAdapter(@NonNull OnAudioOutputChangedListener onAudioOutputChangedListener, + @NonNull List audioOutputs) { + this.audioOutputs = audioOutputs; + this.onAudioOutputChangedListener = onAudioOutputChangedListener; + } + + public void setSelectedOutput(@NonNull WebRtcAudioOutput selected) { + this.selected = selected; + + notifyDataSetChanged(); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_radio_item, parent, false); + + return new ViewHolder(view, this::handlePositionSelected); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(audioOutputs.get(position), selected); + } + + @Override + public int getItemCount() { + return audioOutputs.size(); + } + + private void handlePositionSelected(int position) { + WebRtcAudioOutput mode = audioOutputs.get(position); + + if (mode != selected) { + setSelectedOutput(mode); + onAudioOutputChangedListener.audioOutputChanged(selected); + } + } + + static class ViewHolder extends RecyclerView.ViewHolder implements CompoundButton.OnCheckedChangeListener { + + private final RadioButton radioButton; + private final Consumer onPressed; + + + public ViewHolder(@NonNull View itemView, @NonNull Consumer onPressed) { + super(itemView); + + this.radioButton = itemView.findViewById(R.id.radio); + this.onPressed = onPressed; + } + + @CallSuper + void bind(@NonNull WebRtcAudioOutput audioOutput, @Nullable WebRtcAudioOutput selected) { + radioButton.setText(audioOutput.getLabelRes()); + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0); + radioButton.setOnCheckedChangeListener(null); + radioButton.setChecked(audioOutput == selected); + radioButton.setOnCheckedChangeListener(this); + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + int adapterPosition = getAdapterPosition(); + if (adapterPosition != RecyclerView.NO_POSITION) { + onPressed.accept(adapterPosition); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java new file mode 100644 index 00000000..d75bb7ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.graphics.Point; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.webrtc.EglBase; +import org.webrtc.VideoFrame; +import org.webrtc.VideoSink; + +import java.util.WeakHashMap; + +public class BroadcastVideoSink implements VideoSink { + + private final EglBase eglBase; + private final WeakHashMap sinks; + private final WeakHashMap requestingSizes; + private boolean dirtySizes; + + public BroadcastVideoSink(@Nullable EglBase eglBase) { + this.eglBase = eglBase; + this.sinks = new WeakHashMap<>(); + this.requestingSizes = new WeakHashMap<>(); + this.dirtySizes = true; + } + + public @Nullable EglBase getEglBase() { + return eglBase; + } + + public synchronized void addSink(@NonNull VideoSink sink) { + sinks.put(sink, true); + } + + public synchronized void removeSink(@NonNull VideoSink sink) { + sinks.remove(sink); + } + + @Override + public synchronized void onFrame(@NonNull VideoFrame videoFrame) { + for (VideoSink sink : sinks.keySet()) { + sink.onFrame(videoFrame); + } + } + + void putRequestingSize(@NonNull Object object, @NonNull Point size) { + synchronized (requestingSizes) { + requestingSizes.put(object, size); + dirtySizes = true; + } + } + + void removeRequestingSize(@NonNull Object object) { + synchronized (requestingSizes) { + requestingSizes.remove(object); + dirtySizes = true; + } + } + + public @NonNull RequestedSize getMaxRequestingSize() { + int width = 0; + int height = 0; + + synchronized (requestingSizes) { + for (Point size : requestingSizes.values()) { + if (width < size.x) { + width = size.x; + height = size.y; + } + } + } + + return new RequestedSize(width, height); + } + + public void newSizeRequested() { + dirtySizes = false; + } + + public boolean needsNewRequestingSize() { + return dirtySizes; + } + + public static class RequestedSize { + private final int width; + private final int height; + + private RequestedSize(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java new file mode 100644 index 00000000..2fad73cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.SetUtil; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Represents the delta between two lists of CallParticipant objects. This is used along with + * {@link CallParticipantsListUpdatePopupWindow} to display in-call notifications to the user + * whenever remote participants leave or reconnect to the call. + */ +public final class CallParticipantListUpdate { + + private final Set added; + private final Set removed; + + CallParticipantListUpdate(@NonNull Set added, @NonNull Set removed) { + this.added = added; + this.removed = removed; + } + + public @NonNull Set getAdded() { + return added; + } + + public @NonNull Set getRemoved() { + return removed; + } + + public boolean hasNoChanges() { + return added.isEmpty() && removed.isEmpty(); + } + + public boolean hasSingleChange() { + return added.size() + removed.size() == 1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CallParticipantListUpdate that = (CallParticipantListUpdate) o; + return added.equals(that.added) && removed.equals(that.removed); + } + + @Override + public int hashCode() { + return Objects.hash(added, removed); + } + + /** + * Generates a new Update Object for given lists. This new update object will ignore any participants + * that have the demux id set to {@link CallParticipantId#DEFAULT_ID}. + * + * @param oldList The old list of CallParticipants + * @param newList The new (or current) list of CallParticipants + */ + public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List oldList, + @NonNull List newList) + { + Set oldParticipants = Stream.of(oldList) + .filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID) + .map(CallParticipantListUpdate::createWrapper) + .collect(Collectors.toSet()); + Set newParticipants = Stream.of(newList) + .filter(p -> p.getCallParticipantId().getDemuxId() != CallParticipantId.DEFAULT_ID) + .map(CallParticipantListUpdate::createWrapper) + .collect(Collectors.toSet()); + Set added = SetUtil.difference(newParticipants, oldParticipants); + Set removed = SetUtil.difference(oldParticipants, newParticipants); + + return new CallParticipantListUpdate(added, removed); + } + + @VisibleForTesting + static Wrapper createWrapper(@NonNull CallParticipant callParticipant) { + return new Wrapper(callParticipant); + } + + static final class Wrapper { + private final CallParticipant callParticipant; + + private Wrapper(@NonNull CallParticipant callParticipant) { + this.callParticipant = callParticipant; + } + + public @NonNull CallParticipant getCallParticipant() { + return callParticipant; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Wrapper wrapper = (Wrapper) o; + return callParticipant.getCallParticipantId().equals(wrapper.callParticipant.getCallParticipantId()); + } + + @Override + public int hashCode() { + return Objects.hash(callParticipant.getCallParticipantId()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java new file mode 100644 index 00000000..45285f29 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; +import androidx.core.widget.ImageViewCompat; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.webrtc.RendererCommon; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Encapsulates views needed to show a call participant including their + * avatar in full screen or pip mode, and their video feed. + */ +public class CallParticipantView extends ConstraintLayout { + + private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + + private static final long DELAY_SHOWING_MISSING_MEDIA_KEYS = TimeUnit.SECONDS.toMillis(5); + private static final int SMALL_AVATAR = ViewUtil.dpToPx(96); + private static final int LARGE_AVATAR = ViewUtil.dpToPx(112); + + private RecipientId recipientId; + private boolean infoMode; + private Runnable missingMediaKeysUpdater; + + private AppCompatImageView backgroundAvatar; + private AvatarImageView avatar; + private TextureViewRenderer renderer; + private ImageView pipAvatar; + private ContactPhoto contactPhoto; + private View audioMuted; + private View infoOverlay; + private EmojiTextView infoMessage; + private Button infoMoreInfo; + private AppCompatImageView infoIcon; + + public CallParticipantView(@NonNull Context context) { + super(context); + onFinishInflate(); + } + + public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + backgroundAvatar = findViewById(R.id.call_participant_background_avatar); + avatar = findViewById(R.id.call_participant_item_avatar); + pipAvatar = findViewById(R.id.call_participant_item_pip_avatar); + renderer = findViewById(R.id.call_participant_renderer); + audioMuted = findViewById(R.id.call_participant_mic_muted); + infoOverlay = findViewById(R.id.call_participant_info_overlay); + infoIcon = findViewById(R.id.call_participant_info_icon); + infoMessage = findViewById(R.id.call_participant_info_message); + infoMoreInfo = findViewById(R.id.call_participant_info_more_info); + + avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER); + useLargeAvatar(); + } + + void setMirror(boolean mirror) { + renderer.setMirror(mirror); + } + + void setScalingType(@NonNull RendererCommon.ScalingType scalingType) { + renderer.setScalingType(scalingType); + } + + void setCallParticipant(@NonNull CallParticipant participant) { + boolean participantChanged = recipientId == null || !recipientId.equals(participant.getRecipient().getId()); + recipientId = participant.getRecipient().getId(); + infoMode = participant.getRecipient().isBlocked() || isMissingMediaKeys(participant); + + if (infoMode) { + renderer.setVisibility(View.GONE); + renderer.attachBroadcastVideoSink(null); + audioMuted.setVisibility(View.GONE); + avatar.setVisibility(View.GONE); + pipAvatar.setVisibility(View.GONE); + + infoOverlay.setVisibility(View.VISIBLE); + + ImageViewCompat.setImageTintList(infoIcon, ContextCompat.getColorStateList(getContext(), R.color.core_white)); + + if (participant.getRecipient().isBlocked()) { + infoIcon.setImageResource(R.drawable.ic_block_tinted_24); + infoMessage.setText(getContext().getString(R.string.CallParticipantView__s_is_blocked, participant.getRecipient().getShortDisplayName(getContext()))); + infoMoreInfo.setOnClickListener(v -> showBlockedDialog(participant.getRecipient())); + } else { + infoIcon.setImageResource(R.drawable.ic_error_solid_24); + infoMessage.setText(getContext().getString(R.string.CallParticipantView__cant_receive_audio_video_from_s, participant.getRecipient().getShortDisplayName(getContext()))); + infoMoreInfo.setOnClickListener(v -> showNoMediaKeysDialog(participant.getRecipient())); + } + } else { + infoOverlay.setVisibility(View.GONE); + + renderer.setVisibility(participant.isVideoEnabled() ? View.VISIBLE : View.GONE); + + if (participant.isVideoEnabled()) { + if (participant.getVideoSink().getEglBase() != null) { + renderer.init(participant.getVideoSink().getEglBase()); + } + renderer.attachBroadcastVideoSink(participant.getVideoSink()); + } else { + renderer.attachBroadcastVideoSink(null); + } + + audioMuted.setVisibility(participant.isMicrophoneEnabled() ? View.GONE : View.VISIBLE); + } + + if (participantChanged || !Objects.equals(contactPhoto, participant.getRecipient().getContactPhoto())) { + avatar.setAvatarUsingProfile(participant.getRecipient()); + AvatarUtil.loadBlurredIconIntoImageView(participant.getRecipient(), backgroundAvatar); + setPipAvatar(participant.getRecipient()); + contactPhoto = participant.getRecipient().getContactPhoto(); + } + } + + private boolean isMissingMediaKeys(@NonNull CallParticipant participant) { + if (missingMediaKeysUpdater != null) { + Util.cancelRunnableOnMain(missingMediaKeysUpdater); + missingMediaKeysUpdater = null; + } + + if (!participant.isMediaKeysReceived()) { + long time = System.currentTimeMillis() - participant.getAddedToCallTime(); + if (time > DELAY_SHOWING_MISSING_MEDIA_KEYS) { + return true; + } else { + missingMediaKeysUpdater = () -> { + if (recipientId.equals(participant.getRecipient().getId())) { + setCallParticipant(participant); + } + }; + Util.runOnMainDelayed(missingMediaKeysUpdater, DELAY_SHOWING_MISSING_MEDIA_KEYS - time); + } + } + return false; + } + + void setRenderInPip(boolean shouldRenderInPip) { + if (infoMode) { + infoMessage.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE); + infoMoreInfo.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE); + return; + } + + avatar.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE); + pipAvatar.setVisibility(shouldRenderInPip ? View.VISIBLE : View.GONE); + } + + void useLargeAvatar() { + changeAvatarParams(LARGE_AVATAR); + } + + void useSmallAvatar() { + changeAvatarParams(SMALL_AVATAR); + } + + void releaseRenderer() { + renderer.release(); + } + + private void changeAvatarParams(int dimension) { + ViewGroup.LayoutParams params = avatar.getLayoutParams(); + if (params.height != dimension) { + params.height = dimension; + params.width = dimension; + avatar.setLayoutParams(params); + } + } + + private void setPipAvatar(@NonNull Recipient recipient) { + ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(Recipient.self(), Recipient.self().getProfileAvatar()) + : recipient.getContactPhoto(); + FallbackContactPhoto fallbackPhoto = recipient.getFallbackContactPhoto(FALLBACK_PHOTO_PROVIDER); + + GlideApp.with(this) + .load(contactPhoto) + .fallback(fallbackPhoto.asCallCard(getContext())) + .error(fallbackPhoto.asCallCard(getContext())) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(pipAvatar); + + pipAvatar.setScaleType(contactPhoto == null ? ImageView.ScaleType.CENTER_INSIDE : ImageView.ScaleType.CENTER_CROP); + pipAvatar.setBackgroundColor(recipient.getColor().toActionBarColor(getContext())); + } + + private void showBlockedDialog(@NonNull Recipient recipient) { + new AlertDialog.Builder(getContext()) + .setTitle(getContext().getString(R.string.CallParticipantView__s_is_blocked, recipient.getShortDisplayName(getContext()))) + .setMessage(R.string.CallParticipantView__you_wont_receive_their_audio_or_video) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + private void showNoMediaKeysDialog(@NonNull Recipient recipient) { + new AlertDialog.Builder(getContext()) + .setTitle(getContext().getString(R.string.CallParticipantView__cant_receive_audio_and_video_from_s, recipient.getShortDisplayName(getContext()))) + .setMessage(R.string.CallParticipantView__this_may_be_Because_they_have_not_verified_your_safety_number_change) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return super.getPhotoForRecipientWithoutName(); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + ResourceContactPhoto photo = new ResourceContactPhoto(R.drawable.ic_profile_outline_120); + photo.setScaleType(ImageView.ScaleType.CENTER_CROP); + return photo; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java new file mode 100644 index 00000000..997f4fd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.cardview.widget.CardView; + +import com.google.android.flexbox.AlignItems; +import com.google.android.flexbox.FlexboxLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Collections; +import java.util.List; + +/** + * Can dynamically render a collection of call participants, adjusting their + * sizing and layout depending on the total number of participants. + */ +public class CallParticipantsLayout extends FlexboxLayout { + + private static final int MULTIPLE_PARTICIPANT_SPACING = ViewUtil.dpToPx(3); + private static final int CORNER_RADIUS = ViewUtil.dpToPx(10); + + private List callParticipants = Collections.emptyList(); + private CallParticipant focusedParticipant = null; + private boolean shouldRenderInPip; + + public CallParticipantsLayout(@NonNull Context context) { + super(context); + } + + public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + void update(@NonNull List callParticipants, @NonNull CallParticipant focusedParticipant, boolean shouldRenderInPip) { + this.callParticipants = callParticipants; + this.focusedParticipant = focusedParticipant; + this.shouldRenderInPip = shouldRenderInPip; + updateLayout(); + } + + private void updateLayout() { + int previousChildCount = getChildCount(); + + if (shouldRenderInPip && Util.hasItems(callParticipants)) { + updateChildrenCount(1); + update(0, 1, focusedParticipant); + } else { + int count = callParticipants.size(); + updateChildrenCount(count); + + for (int i = 0; i < count; i++) { + update(i, count, callParticipants.get(i)); + } + } + + if (previousChildCount != getChildCount()) { + updateMarginsForLayout(); + } + } + + private void updateMarginsForLayout() { + MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams(); + if (callParticipants.size() > 1 && !shouldRenderInPip) { + layoutParams.setMargins(MULTIPLE_PARTICIPANT_SPACING, ViewUtil.getStatusBarHeight(this), MULTIPLE_PARTICIPANT_SPACING, 0); + } else { + layoutParams.setMargins(0, 0, 0, 0); + } + setLayoutParams(layoutParams); + } + + private void updateChildrenCount(int count) { + int childCount = getChildCount(); + if (childCount < count) { + for (int i = childCount; i < count; i++) { + addCallParticipantView(); + } + } else if (childCount > count) { + for (int i = count; i < childCount; i++) { + CallParticipantView callParticipantView = getChildAt(count).findViewById(R.id.group_call_participant); + callParticipantView.releaseRenderer(); + removeViewAt(count); + } + } + } + + private void update(int index, int count, @NonNull CallParticipant participant) { + View view = getChildAt(index); + CardView cardView = view.findViewById(R.id.group_call_participant_card_wrapper); + CallParticipantView callParticipantView = view.findViewById(R.id.group_call_participant); + + callParticipantView.setCallParticipant(participant); + callParticipantView.setRenderInPip(shouldRenderInPip); + + if (count > 1) { + view.setPadding(MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING); + cardView.setRadius(CORNER_RADIUS); + } else { + view.setPadding(0, 0, 0, 0); + cardView.setRadius(0); + } + + if (count > 2) { + callParticipantView.useSmallAvatar(); + } else { + callParticipantView.useLargeAvatar(); + } + + setChildLayoutParams(view, index, getChildCount()); + } + + private void addCallParticipantView() { + View view = LayoutInflater.from(getContext()).inflate(R.layout.group_call_participant_item, this, false); + FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) view.getLayoutParams(); + + params.setAlignSelf(AlignItems.STRETCH); + view.setLayoutParams(params); + addView(view); + } + + private void setChildLayoutParams(@NonNull View child, int childPosition, int childCount) { + FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) child.getLayoutParams(); + if (childCount < 3) { + params.setFlexBasisPercent(1f); + } else { + if ((childCount % 2) != 0 && childPosition == childCount - 1) { + params.setFlexBasisPercent(1f); + } else { + params.setFlexBasisPercent(0.5f); + } + } + child.setLayoutParams(params); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java new file mode 100644 index 00000000..6cde4260 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java @@ -0,0 +1,188 @@ +package org.thoughtcrime.securesms.components.webrtc; + + +import android.content.Context; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupWindow; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class CallParticipantsListUpdatePopupWindow extends PopupWindow { + + private static final long DURATION = TimeUnit.SECONDS.toMillis(2); + + private final ViewGroup parent; + private final AvatarImageView avatarImageView; + private final TextView descriptionTextView; + + private final Set pendingAdditions = new HashSet<>(); + private final Set pendingRemovals = new HashSet<>(); + + private boolean isEnabled = true; + + public CallParticipantsListUpdatePopupWindow(@NonNull ViewGroup parent) { + super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_participant_list_update, parent, false), + ViewGroup.LayoutParams.MATCH_PARENT, + ViewUtil.dpToPx(94)); + + this.parent = parent; + this.avatarImageView = getContentView().findViewById(R.id.avatar); + this.descriptionTextView = getContentView().findViewById(R.id.description); + + setOnDismissListener(this::showPending); + setAnimationStyle(R.style.PopupAnimation); + } + + public void addCallParticipantListUpdate(@NonNull CallParticipantListUpdate update) { + pendingAdditions.addAll(update.getAdded()); + pendingAdditions.removeAll(update.getRemoved()); + + pendingRemovals.addAll(update.getRemoved()); + pendingRemovals.removeAll(update.getAdded()); + + if (!isShowing()) { + showPending(); + } + } + + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + + if (!isEnabled) { + dismiss(); + } + } + + private void showPending() { + if (!pendingAdditions.isEmpty()) { + showAdditions(); + } else if (!pendingRemovals.isEmpty()) { + showRemovals(); + } + } + + private void showAdditions() { + setAvatar(getNextRecipient(pendingAdditions.iterator())); + setDescription(pendingAdditions, true); + pendingAdditions.clear(); + show(); + } + + private void showRemovals() { + setAvatar(getNextRecipient(pendingRemovals.iterator())); + setDescription(pendingRemovals, false); + pendingRemovals.clear(); + show(); + } + + private void show() { + if (!isEnabled) { + return; + } + + showAtLocation(parent, Gravity.TOP | Gravity.START, 0, 0); + measureChild(); + update(); + getContentView().postDelayed(this::dismiss, DURATION); + } + + private void measureChild() { + getContentView().measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + } + + private void setAvatar(@Nullable Recipient recipient) { + avatarImageView.setAvatarUsingProfile(recipient); + avatarImageView.setVisibility(recipient == null ? View.GONE : View.VISIBLE); + } + + private void setDescription(@NonNull Set wrappers, boolean isAdded) { + if (wrappers.isEmpty()) { + descriptionTextView.setText(""); + } else { + setDescriptionForRecipients(wrappers, isAdded); + } + } + + private void setDescriptionForRecipients(@NonNull Set recipients, boolean isAdded) { + Iterator iterator = recipients.iterator(); + Context context = getContentView().getContext(); + String description; + + switch (recipients.size()) { + case 0: + throw new IllegalArgumentException("Recipients must contain 1 or more entries"); + case 1: + description = context.getString(getOneMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator)); + break; + case 2: + description = context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator)); + break; + case 3: + description = context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator), getNextDisplayName(iterator)); + break; + default: + description = context.getString(getManyMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator), recipients.size() - 2); + } + + descriptionTextView.setText(description); + } + + private @NonNull Recipient getNextRecipient(@NonNull Iterator wrapperIterator) { + return wrapperIterator.next().getCallParticipant().getRecipient(); + } + + private @NonNull String getNextDisplayName(@NonNull Iterator wrapperIterator) { + CallParticipantListUpdate.Wrapper wrapper = wrapperIterator.next(); + + return wrapper.getCallParticipant().getRecipientDisplayName(getContentView().getContext()); + } + + private static @StringRes int getOneMemberDescriptionResourceId(boolean isAdded) { + if (isAdded) { + return R.string.CallParticipantsListUpdatePopupWindow__s_joined; + } else { + return R.string.CallParticipantsListUpdatePopupWindow__s_left; + } + } + + private static @StringRes int getTwoMemberDescriptionResourceId(boolean isAdded) { + if (isAdded) { + return R.string.CallParticipantsListUpdatePopupWindow__s_and_s_joined; + } else { + return R.string.CallParticipantsListUpdatePopupWindow__s_and_s_left; + } + } + + private static @StringRes int getThreeMemberDescriptionResourceId(boolean isAdded) { + if (isAdded) { + return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_joined; + } else { + return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_s_left; + } + } + + private static @StringRes int getManyMemberDescriptionResourceId(boolean isAdded) { + if (isAdded) { + return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_joined; + } else { + return R.string.CallParticipantsListUpdatePopupWindow__s_s_and_d_others_left; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java new file mode 100644 index 00000000..0842fe59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java @@ -0,0 +1,311 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.ComparatorCompat; +import com.annimon.stream.OptionalLong; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents the state of all participants, remote and local, combined with view state + * needed to properly render the participants. The view state primarily consists of + * if we are in System PIP mode and if we should show our video for an outgoing call. + */ +public final class CallParticipantsState { + + private static final int SMALL_GROUP_MAX = 6; + + public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED, + WebRtcViewModel.GroupCallState.IDLE, + new ParticipantCollection(SMALL_GROUP_MAX), + CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(null), false), + null, + WebRtcLocalRenderState.GONE, + false, + false, + false, + OptionalLong.empty()); + + private final WebRtcViewModel.State callState; + private final WebRtcViewModel.GroupCallState groupCallState; + private final ParticipantCollection remoteParticipants; + private final CallParticipant localParticipant; + private final CallParticipant focusedParticipant; + private final WebRtcLocalRenderState localRenderState; + private final boolean isInPipMode; + private final boolean showVideoForOutgoing; + private final boolean isViewingFocusedParticipant; + private final OptionalLong remoteDevicesCount; + + public CallParticipantsState(@NonNull WebRtcViewModel.State callState, + @NonNull WebRtcViewModel.GroupCallState groupCallState, + @NonNull ParticipantCollection remoteParticipants, + @NonNull CallParticipant localParticipant, + @Nullable CallParticipant focusedParticipant, + @NonNull WebRtcLocalRenderState localRenderState, + boolean isInPipMode, + boolean showVideoForOutgoing, + boolean isViewingFocusedParticipant, + OptionalLong remoteDevicesCount) + { + this.callState = callState; + this.groupCallState = groupCallState; + this.remoteParticipants = remoteParticipants; + this.localParticipant = localParticipant; + this.localRenderState = localRenderState; + this.focusedParticipant = focusedParticipant; + this.isInPipMode = isInPipMode; + this.showVideoForOutgoing = showVideoForOutgoing; + this.isViewingFocusedParticipant = isViewingFocusedParticipant; + this.remoteDevicesCount = remoteDevicesCount; + } + + public @NonNull WebRtcViewModel.State getCallState() { + return callState; + } + + public @NonNull WebRtcViewModel.GroupCallState getGroupCallState() { + return groupCallState; + } + + public @NonNull List getGridParticipants() { + return remoteParticipants.getGridParticipants(); + } + + public @NonNull List getListParticipants() { + List listParticipants = new ArrayList<>(); + + if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) { + listParticipants.addAll(getAllRemoteParticipants()); + listParticipants.remove(focusedParticipant); + } else { + listParticipants.addAll(remoteParticipants.getListParticipants()); + } + + listParticipants.add(CallParticipant.EMPTY); + Collections.reverse(listParticipants); + + return listParticipants; + } + + public @NonNull String getRemoteParticipantsDescription(@NonNull Context context) { + switch (remoteParticipants.size()) { + case 0: + return context.getString(R.string.WebRtcCallView__no_one_else_is_here); + case 1: + if (callState == WebRtcViewModel.State.CALL_PRE_JOIN && groupCallState.isNotIdle()) { + return context.getString(R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants.get(0).getShortRecipientDisplayName(context)); + } else { + return remoteParticipants.get(0).getRecipientDisplayName(context); + } + case 2: + return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call, + remoteParticipants.get(0).getShortRecipientDisplayName(context), + remoteParticipants.get(1).getShortRecipientDisplayName(context)); + default: + int others = remoteParticipants.size() - 2; + return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call, + others, + remoteParticipants.get(0).getShortRecipientDisplayName(context), + remoteParticipants.get(1).getShortRecipientDisplayName(context), + others); + } + } + + public @NonNull List getAllRemoteParticipants() { + return remoteParticipants.getAllParticipants(); + } + + public @NonNull CallParticipant getLocalParticipant() { + return localParticipant; + } + + public @Nullable CallParticipant getFocusedParticipant() { + return focusedParticipant; + } + + public @NonNull WebRtcLocalRenderState getLocalRenderState() { + return localRenderState; + } + + public boolean isLargeVideoGroup() { + return getAllRemoteParticipants().size() > SMALL_GROUP_MAX; + } + + public boolean isInPipMode() { + return isInPipMode; + } + + public boolean needsNewRequestSizes() { + return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize()); + } + + public @NonNull OptionalLong getRemoteDevicesCount() { + return remoteDevicesCount; + } + + public @NonNull OptionalLong getParticipantCount() { + boolean includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; + + return remoteDevicesCount.map(l -> l + (includeSelf ? 1L : 0L)) + .or(() -> includeSelf ? OptionalLong.of(1L) : OptionalLong.empty()); + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, + @NonNull WebRtcViewModel webRtcViewModel, + boolean enableVideo) + { + boolean newShowVideoForOutgoing = oldState.showVideoForOutgoing; + if (enableVideo) { + newShowVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING; + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) { + newShowVideoForOutgoing = false; + } + + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(webRtcViewModel.getLocalParticipant(), + oldState.isInPipMode, + newShowVideoForOutgoing, + webRtcViewModel.getGroupState().isNotIdle(), + webRtcViewModel.getState(), + webRtcViewModel.getRemoteParticipants().size(), + oldState.isViewingFocusedParticipant, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); + + List participantsByLastSpoke = new ArrayList<>(webRtcViewModel.getRemoteParticipants()); + Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke()))); + + CallParticipant focused = participantsByLastSpoke.isEmpty() ? null : participantsByLastSpoke.get(0); + + return new CallParticipantsState(webRtcViewModel.getState(), + webRtcViewModel.getGroupState(), + oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()), + webRtcViewModel.getLocalParticipant(), + focused, + localRenderState, + oldState.isInPipMode, + newShowVideoForOutgoing, + oldState.isViewingFocusedParticipant, + webRtcViewModel.getRemoteDevicesCount()); + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) { + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + isInPip, + oldState.showVideoForOutgoing, + oldState.getGroupCallState().isNotIdle(), + oldState.callState, + oldState.getAllRemoteParticipants().size(), + oldState.isViewingFocusedParticipant, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); + + CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); + + return new CallParticipantsState(oldState.callState, + oldState.groupCallState, + oldState.remoteParticipants, + oldState.localParticipant, + focused, + localRenderState, + isInPip, + oldState.showVideoForOutgoing, + oldState.isViewingFocusedParticipant, + oldState.remoteDevicesCount); + } + + public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) { + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.getGroupCallState().isNotIdle(), + oldState.callState, + oldState.getAllRemoteParticipants().size(), + oldState.isViewingFocusedParticipant, + expanded); + + return new CallParticipantsState(oldState.callState, + oldState.groupCallState, + oldState.remoteParticipants, + oldState.localParticipant, + oldState.focusedParticipant, + localRenderState, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.isViewingFocusedParticipant, + oldState.remoteDevicesCount); + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) { + CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); + + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.getGroupCallState().isNotIdle(), + oldState.callState, + oldState.getAllRemoteParticipants().size(), + selectedPage == SelectedPage.FOCUSED, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); + + return new CallParticipantsState(oldState.callState, + oldState.groupCallState, + oldState.remoteParticipants, + oldState.localParticipant, + focused, + localRenderState, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + selectedPage == SelectedPage.FOCUSED, + oldState.remoteDevicesCount); + } + + private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant, + boolean isInPip, + boolean showVideoForOutgoing, + boolean isNonIdleGroupCall, + @NonNull WebRtcViewModel.State callState, + int numberOfRemoteParticipants, + boolean isViewingFocusedParticipant, + boolean isExpanded) + { + boolean displayLocal = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled()); + WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE; + + if (isExpanded && (localParticipant.isVideoEnabled() || isNonIdleGroupCall)) { + return WebRtcLocalRenderState.EXPANDED; + } else if (displayLocal || showVideoForOutgoing) { + if (callState == WebRtcViewModel.State.CALL_CONNECTED) { + if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) { + localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE; + } else if (numberOfRemoteParticipants == 1) { + localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE; + } else { + localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO; + } + } else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) { + localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO; + } + } else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) { + localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO; + } + + return localRenderState; + } + + public enum SelectedPage { + GRID, + FOCUSED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java new file mode 100644 index 00000000..188e91d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Utility for showing and hiding safety number change notifications during a group call. + */ +public final class GroupCallSafetyNumberChangeNotificationUtil { + + public static final String GROUP_CALLING_NOTIFICATION_TAG = "group_calling"; + + private GroupCallSafetyNumberChangeNotificationUtil() { + } + + public static void showNotification(@NonNull Context context, @NonNull Recipient recipient) { + Intent contentIntent = new Intent(context, WebRtcCallActivity.class); + contentIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, 0); + + Notification safetyNumberChangeNotification = new NotificationCompat.Builder(context, NotificationChannels.CALLS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(recipient.getDisplayName(context)) + .setContentText(context.getString(R.string.GroupCallSafetyNumberChangeNotification__someone_has_joined_this_call_with_a_safety_number_that_has_changed)) + .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(R.string.GroupCallSafetyNumberChangeNotification__someone_has_joined_this_call_with_a_safety_number_that_has_changed))) + .setContentIntent(pendingIntent) + .build(); + + NotificationManagerCompat.from(context).notify(GROUP_CALLING_NOTIFICATION_TAG, recipient.hashCode(), safetyNumberChangeNotification); + } + + public static void cancelNotification(@NonNull Context context, @NonNull Recipient recipient) { + NotificationManagerCompat.from(context).cancel(GROUP_CALLING_NOTIFICATION_TAG, recipient.hashCode()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OnAudioOutputChangedListener.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OnAudioOutputChangedListener.java new file mode 100644 index 00000000..b70a7792 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OnAudioOutputChangedListener.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.components.webrtc; + +public interface OnAudioOutputChangedListener { + void audioOutputChanged(WebRtcAudioOutput audioOutput); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OrientationAwareVideoSink.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OrientationAwareVideoSink.java new file mode 100644 index 00000000..2ba80864 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/OrientationAwareVideoSink.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; + +import org.webrtc.VideoFrame; +import org.webrtc.VideoSink; + +public final class OrientationAwareVideoSink implements VideoSink { + + private final VideoSink delegate; + + public OrientationAwareVideoSink(@NonNull VideoSink delegate) { + this.delegate = delegate; + } + + @Override + public void onFrame(VideoFrame videoFrame) { + if (videoFrame.getRotatedHeight() < videoFrame.getRotatedWidth()) { + delegate.onFrame(new VideoFrame(videoFrame.getBuffer(), 270, videoFrame.getTimestampNs())); + } else { + delegate.onFrame(videoFrame); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PercentFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PercentFrameLayout.java new file mode 100644 index 00000000..2a4cca85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PercentFrameLayout.java @@ -0,0 +1,129 @@ +/* + * Copyright 2015 The WebRTC Project Authors. All rights reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +/** + * Simple container that confines the children to a subrectangle specified as percentage values of + * the container size. The children are centered horizontally and vertically inside the confined + * space. + */ +public class PercentFrameLayout extends ViewGroup { + private int xPercent = 0; + private int yPercent = 0; + private int widthPercent = 100; + private int heightPercent = 100; + + private boolean square = false; + private boolean hidden = false; + + public PercentFrameLayout(Context context) { + super(context); + } + + public PercentFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public PercentFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setSquare(boolean square) { + this.square = square; + } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public boolean isHidden() { + return this.hidden; + } + + public void setPosition(int xPercent, int yPercent, int widthPercent, int heightPercent) { + this.xPercent = xPercent; + this.yPercent = yPercent; + this.widthPercent = widthPercent; + this.heightPercent = heightPercent; + } + + @Override + public boolean shouldDelayChildPressedState() { + return false; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int width = getDefaultSize(Integer.MAX_VALUE, widthMeasureSpec); + final int height = getDefaultSize(Integer.MAX_VALUE, heightMeasureSpec); + + setMeasuredDimension(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + + int childWidth = width * widthPercent / 100; + int childHeight = height * heightPercent / 100; + + if (square) { + if (width > height) childWidth = childHeight; + else childHeight = childWidth; + } + + if (hidden) { + childWidth = 1; + childHeight = 1; + } + + int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.AT_MOST); + int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.AT_MOST); + + for (int i = 0; i < getChildCount(); ++i) { + final View child = getChildAt(i); + if (child.getVisibility() != GONE) { + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int width = right - left; + final int height = bottom - top; + // Sub-rectangle specified by percentage values. + final int subWidth = width * widthPercent / 100; + final int subHeight = height * heightPercent / 100; + final int subLeft = left + width * xPercent / 100; + final int subTop = top + height * yPercent / 100; + + + for (int i = 0; i < getChildCount(); ++i) { + final View child = getChildAt(i); + if (child.getVisibility() != GONE) { + final int childWidth = child.getMeasuredWidth(); + final int childHeight = child.getMeasuredHeight(); + // Center child both vertically and horizontally. + int childLeft = subLeft + (subWidth - childWidth) / 2; + int childTop = subTop + (subHeight - childHeight) / 2; + + if (hidden) { + childLeft = 0; + childTop = 0; + } + + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java new file mode 100644 index 00000000..d216407e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java @@ -0,0 +1,197 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +/** + * Helps manage the expansion and shrinking of the in-app pip. + */ +@MainThread +final class PictureInPictureExpansionHelper { + + private State state = State.IS_SHRUNKEN; + + public boolean isExpandedOrExpanding() { + return state == State.IS_EXPANDED || state == State.IS_EXPANDING; + } + + public boolean isShrunkenOrShrinking() { + return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING; + } + + public void expand(@NonNull View toExpand, @NonNull Callback callback) { + if (isExpandedOrExpanding()) { + return; + } + + performExpandAnimation(toExpand, new Callback() { + @Override + public void onAnimationWillStart() { + state = State.IS_EXPANDING; + callback.onAnimationWillStart(); + } + + @Override + public void onPictureInPictureExpanded() { + callback.onPictureInPictureExpanded(); + } + + @Override + public void onPictureInPictureNotVisible() { + callback.onPictureInPictureNotVisible(); + } + + @Override + public void onAnimationHasFinished() { + state = State.IS_EXPANDED; + callback.onAnimationHasFinished(); + } + }); + } + + public void shrink(@NonNull View toExpand, @NonNull Callback callback) { + if (isShrunkenOrShrinking()) { + return; + } + + performShrinkAnimation(toExpand, new Callback() { + @Override + public void onAnimationWillStart() { + state = State.IS_SHRINKING; + callback.onAnimationWillStart(); + } + + @Override + public void onPictureInPictureExpanded() { + callback.onPictureInPictureExpanded(); + } + + @Override + public void onPictureInPictureNotVisible() { + callback.onPictureInPictureNotVisible(); + } + + @Override + public void onAnimationHasFinished() { + state = State.IS_SHRUNKEN; + callback.onAnimationHasFinished(); + } + }); + } + + private void performExpandAnimation(@NonNull View target, @NonNull Callback callback) { + ViewGroup parent = (ViewGroup) target.getParent(); + + float x = target.getX(); + float y = target.getY(); + float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth(); + float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight(); + float scale = Math.max(scaleX, scaleY); + + callback.onAnimationWillStart(); + + target.animate() + .setDuration(200) + .x((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f) + .y((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f) + .scaleX(scale) + .scaleY(scale) + .withEndAction(() -> { + callback.onPictureInPictureExpanded(); + target.animate() + .setDuration(100) + .alpha(0f) + .withEndAction(() -> { + callback.onPictureInPictureNotVisible(); + + target.setX(x); + target.setY(y); + target.setScaleX(0f); + target.setScaleY(0f); + target.setAlpha(1f); + + target.animate() + .setDuration(200) + .scaleX(1f) + .scaleY(1f) + .withEndAction(callback::onAnimationHasFinished); + }); + }); + } + + private void performShrinkAnimation(@NonNull View target, @NonNull Callback callback) { + ViewGroup parent = (ViewGroup) target.getParent(); + + float x = target.getX(); + float y = target.getY(); + float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth(); + float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight(); + float scale = Math.max(scaleX, scaleY); + + callback.onAnimationWillStart(); + + target.animate() + .setDuration(200) + .scaleX(0f) + .scaleY(0f) + .withEndAction(() -> { + target.setX((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f); + target.setY((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f); + target.setAlpha(0f); + target.setScaleX(scale); + target.setScaleY(scale); + + callback.onPictureInPictureNotVisible(); + + target.animate() + .setDuration(100) + .alpha(1f) + .withEndAction(() -> { + callback.onPictureInPictureExpanded(); + + target.animate() + .scaleX(1f) + .scaleY(1f) + .x(x) + .y(y) + .withEndAction(callback::onAnimationHasFinished); + }); + }); + } + + enum State { + IS_EXPANDING, + IS_EXPANDED, + IS_SHRINKING, + IS_SHRUNKEN + } + + public interface Callback { + /** + * Called when an animation (shrink or expand) will begin. This happens before any animation + * is executed. + */ + void onAnimationWillStart(); + + /** + * Called when the PiP is covering the whole screen. This is when any staging / teardown of the + * large local renderer should occur. + */ + void onPictureInPictureExpanded(); + + /** + * Called when the PiP is not visible on the screen anymore. This is when any staging / teardown + * of the pip should occur. + */ + void onPictureInPictureNotVisible(); + + /** + * Called when the animation is complete. Useful for e.g. adjusting the pip's final location to + * make sure it is respecting the screen space available. + */ + void onAnimationHasFinished(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java new file mode 100644 index 00000000..d5dc2f7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -0,0 +1,359 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.animation.Animator; +import android.annotation.SuppressLint; +import android.graphics.Point; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.core.view.GestureDetectorCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Queue; + +public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener { + + private static final float DECELERATION_RATE = 0.99f; + private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator(); + private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator(); + + private final ViewGroup parent; + private final View child; + private final int framePadding; + private final Queue runAfterFling; + + private int pipWidth; + private int pipHeight; + private int activePointerId = MotionEvent.INVALID_POINTER_ID; + private float lastTouchX; + private float lastTouchY; + private boolean isDragging; + private boolean isAnimating; + private int extraPaddingTop; + private int extraPaddingBottom; + private double projectionX; + private double projectionY; + private VelocityTracker velocityTracker; + private int maximumFlingVelocity; + private boolean isLockedToBottomEnd; + private Interpolator interpolator; + + @SuppressLint("ClickableViewAccessibility") + public static PictureInPictureGestureHelper applyTo(@NonNull View child) { + TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent(); + PictureInPictureGestureHelper helper = new PictureInPictureGestureHelper(parent, child); + GestureDetectorCompat gestureDetector = new GestureDetectorCompat(child.getContext(), helper); + + parent.setOnInterceptTouchEventListener((event) -> { + final int action = event.getAction(); + final int pointerIndex = (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; + + if (pointerIndex > 0) { + return false; + } + + if (helper.velocityTracker == null) { + helper.velocityTracker = VelocityTracker.obtain(); + } + + helper.velocityTracker.addMovement(event); + + return false; + }); + + parent.setOnTouchListener((v, event) -> { + if (helper.velocityTracker != null) { + helper.velocityTracker.recycle(); + helper.velocityTracker = null; + } + + return false; + }); + + child.setOnTouchListener((v, event) -> { + boolean handled = gestureDetector.onTouchEvent(event); + + if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + if (!handled) { + handled = helper.onGestureFinished(event); + } + + if (helper.velocityTracker != null) { + helper.velocityTracker.recycle(); + helper.velocityTracker = null; + } + } + + return handled; + }); + + return helper; + } + + private PictureInPictureGestureHelper(@NonNull ViewGroup parent, @NonNull View child) { + this.parent = parent; + this.child = child; + this.framePadding = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_frame_padding); + this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width); + this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height); + this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity(); + this.runAfterFling = new LinkedList<>(); + this.interpolator = ADJUST_INTERPOLATOR; + } + + public void clearVerticalBoundaries() { + setVerticalBoundaries(parent.getTop(), parent.getMeasuredHeight() + parent.getTop()); + } + + public void setVerticalBoundaries(int topBoundary, int bottomBoundary) { + extraPaddingTop = topBoundary - parent.getTop(); + extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary; + + adjustPip(); + } + + private boolean onGestureFinished(MotionEvent e) { + final int pointerIndex = e.findPointerIndex(activePointerId); + + if (e.getActionIndex() == pointerIndex) { + onFling(e, e, 0, 0); + return true; + } + + return false; + } + + public void adjustPip() { + pipWidth = child.getMeasuredWidth(); + pipHeight = child.getMeasuredHeight(); + + if (isAnimating) { + interpolator = ADJUST_INTERPOLATOR; + + fling(); + } else if (!isDragging) { + interpolator = ADJUST_INTERPOLATOR; + + onFling(null, null, 0, 0); + } + } + + public void lockToBottomEnd() { + isLockedToBottomEnd = true; + } + + public void enableCorners() { + isLockedToBottomEnd = false; + } + + public void performAfterFling(@NonNull Runnable runnable) { + if (isAnimating) { + runAfterFling.add(runnable); + } else { + runnable.run(); + } + } + + @Override + public boolean onDown(MotionEvent e) { + activePointerId = e.getPointerId(0); + lastTouchX = e.getX(0) + child.getX(); + lastTouchY = e.getY(0) + child.getY(); + isDragging = true; + pipWidth = child.getMeasuredWidth(); + pipHeight = child.getMeasuredHeight(); + interpolator = FLING_INTERPOLATOR; + + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + int pointerIndex = e2.findPointerIndex(activePointerId); + + if (pointerIndex == -1) { + fling(); + return false; + } + + float x = e2.getX(pointerIndex) + child.getX(); + float y = e2.getY(pointerIndex) + child.getY(); + float dx = x - lastTouchX; + float dy = y - lastTouchY; + + child.setTranslationX(child.getTranslationX() + dx); + child.setTranslationY(child.getTranslationY() + dy); + + lastTouchX = x; + lastTouchY = y; + + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (velocityTracker != null) { + velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); + + projectionX = child.getX() + project(velocityTracker.getXVelocity()); + projectionY = child.getY() + project(velocityTracker.getYVelocity()); + } else { + projectionX = child.getX(); + projectionY = child.getY(); + } + + fling(); + + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + isDragging = false; + + child.performClick(); + return true; + } + + private void fling() { + Point projection = new Point((int) projectionX, (int) projectionY); + Point nearestCornerPosition = findNearestCornerPosition(projection); + + isAnimating = true; + isDragging = false; + + child.animate() + .translationX(getTranslationXForPoint(nearestCornerPosition)) + .translationY(getTranslationYForPoint(nearestCornerPosition)) + .setDuration(250) + .setInterpolator(interpolator) + .setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + isAnimating = false; + + Iterator afterFlingRunnables = runAfterFling.iterator(); + while (afterFlingRunnables.hasNext()) { + Runnable runnable = afterFlingRunnables.next(); + + runnable.run(); + afterFlingRunnables.remove(); + } + } + }) + .start(); + } + + private Point findNearestCornerPosition(Point projection) { + if (isLockedToBottomEnd) { + return ViewUtil.isLtr(parent) ? calculateBottomRightCoordinates(parent) + : calculateBottomLeftCoordinates(parent); + } + + Point maxPoint = null; + double maxDistance = Double.MAX_VALUE; + + for (Point point : Arrays.asList(calculateTopLeftCoordinates(), + calculateTopRightCoordinates(parent), + calculateBottomLeftCoordinates(parent), + calculateBottomRightCoordinates(parent))) + { + double distance = distance(point, projection); + + if (distance < maxDistance) { + maxDistance = distance; + maxPoint = point; + } + } + + return maxPoint; + } + + private float getTranslationXForPoint(Point destination) { + return destination.x - child.getLeft(); + } + + private float getTranslationYForPoint(Point destination) { + return destination.y - child.getTop(); + } + + private Point calculateTopLeftCoordinates() { + return new Point(framePadding, + framePadding + extraPaddingTop); + } + + private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) { + return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, + framePadding + extraPaddingTop); + } + + private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) { + return new Point(framePadding, + parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom); + } + + private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) { + return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, + parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom); + } + + private static float project(float initialVelocity) { + return (initialVelocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE); + } + + private static double distance(Point a, Point b) { + return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); + } + + /** Borrowed from ScrollView */ + private static class ViscousFluidInterpolator implements Interpolator { + /** Controls the viscous fluid effect (how much of it). */ + private static final float VISCOUS_FLUID_SCALE = 8.0f; + + private static final float VISCOUS_FLUID_NORMALIZE; + private static final float VISCOUS_FLUID_OFFSET; + + static { + + // must be set to 1.0 (used in viscousFluid()) + VISCOUS_FLUID_NORMALIZE = 1.0f / viscousFluid(1.0f); + // account for very small floating-point error + VISCOUS_FLUID_OFFSET = 1.0f - VISCOUS_FLUID_NORMALIZE * viscousFluid(1.0f); + } + + private static float viscousFluid(float x) { + x *= VISCOUS_FLUID_SCALE; + if (x < 1.0f) { + x -= (1.0f - (float)Math.exp(-x)); + } else { + float start = 0.36787944117f; // 1/e == exp(-1) + x = 1.0f - (float)Math.exp(1.0f - x); + x = start + x * (1.0f - start); + } + return x; + } + + @Override + public float getInterpolation(float input) { + final float interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input); + if (interpolated > 0) { + return interpolated + VISCOUS_FLUID_OFFSET; + } + return interpolated; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/SurfaceTextureEglRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/SurfaceTextureEglRenderer.java new file mode 100644 index 00000000..00d1fcfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/SurfaceTextureEglRenderer.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.graphics.SurfaceTexture; +import android.view.TextureView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.webrtc.EglBase; +import org.webrtc.EglRenderer; +import org.webrtc.RendererCommon; +import org.webrtc.ThreadUtils; +import org.webrtc.VideoFrame; + +import java.util.concurrent.CountDownLatch; + +/** + * This class is a modified copy of {@link org.webrtc.SurfaceViewRenderer} designed to work with a + * {@link SurfaceTexture} to facilitate easier animation, rounding, elevation, etc. + */ +public class SurfaceTextureEglRenderer extends EglRenderer implements TextureView.SurfaceTextureListener { + + private static final String TAG = Log.tag(SurfaceTextureEglRenderer.class); + + private final Object layoutLock = new Object(); + + private RendererCommon.RendererEvents rendererEvents; + private boolean isFirstFrameRendered; + private boolean isRenderingPaused; + private int rotatedFrameWidth; + private int rotatedFrameHeight; + private int frameRotation; + + public SurfaceTextureEglRenderer(@NonNull String name) { + super(name); + } + + public void init(@Nullable EglBase.Context sharedContext, @Nullable RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) { + ThreadUtils.checkIsOnMainThread(); + this.rendererEvents = rendererEvents; + synchronized (this.layoutLock) { + this.isFirstFrameRendered = false; + this.rotatedFrameWidth = 0; + this.rotatedFrameHeight = 0; + this.frameRotation = 0; + } + + super.init(sharedContext, configAttributes, drawer); + } + + @Override + public void init(@Nullable EglBase.Context sharedContext, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) { + this.init(sharedContext, null, configAttributes, drawer); + } + + @Override + public void setFpsReduction(float fps) { + synchronized(this.layoutLock) { + this.isRenderingPaused = fps == 0.0F; + } + + super.setFpsReduction(fps); + } + + @Override + public void disableFpsReduction() { + synchronized(this.layoutLock) { + this.isRenderingPaused = false; + } + + super.disableFpsReduction(); + } + + @Override + public void pauseVideo() { + synchronized(this.layoutLock) { + this.isRenderingPaused = true; + } + + super.pauseVideo(); + } + + @Override + public void onFrame(@NonNull VideoFrame frame) { + this.updateFrameDimensionsAndReportEvents(frame); + super.onFrame(frame); + } + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + ThreadUtils.checkIsOnMainThread(); + createEglSurface(surface); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + ThreadUtils.checkIsOnMainThread(); + Log.d(TAG, "onSurfaceTextureSizeChanged: size: " + width + "x" + height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + ThreadUtils.checkIsOnMainThread(); + + CountDownLatch completionLatch = new CountDownLatch(1); + + releaseEglSurface(completionLatch::countDown); + ThreadUtils.awaitUninterruptibly(completionLatch); + + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + } + + private void updateFrameDimensionsAndReportEvents(VideoFrame frame) { + synchronized(this.layoutLock) { + if (!this.isRenderingPaused) { + if (!this.isFirstFrameRendered) { + this.isFirstFrameRendered = true; + Log.d(TAG, "Reporting first rendered frame."); + if (this.rendererEvents != null) { + this.rendererEvents.onFirstFrameRendered(); + } + } + + if (this.rotatedFrameWidth != frame.getRotatedWidth() || this.rotatedFrameHeight != frame.getRotatedHeight() || this.frameRotation != frame.getRotation()) { + Log.d(TAG, "Reporting frame resolution changed to " + frame.getBuffer().getWidth() + "x" + frame.getBuffer().getHeight() + " with rotation " + frame.getRotation()); + if (this.rendererEvents != null) { + this.rendererEvents.onFrameResolutionChanged(frame.getBuffer().getWidth(), frame.getBuffer().getHeight(), frame.getRotation()); + } + + this.rotatedFrameWidth = frame.getRotatedWidth(); + this.rotatedFrameHeight = frame.getRotatedHeight(); + this.frameRotation = frame.getRotation(); + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java new file mode 100644 index 00000000..e2f67ed4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java @@ -0,0 +1,321 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.TextureView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.webrtc.EglBase; +import org.webrtc.EglRenderer; +import org.webrtc.GlRectDrawer; +import org.webrtc.RendererCommon; +import org.webrtc.ThreadUtils; +import org.webrtc.VideoFrame; +import org.webrtc.VideoSink; + +/** + * This class is a modified version of {@link org.webrtc.SurfaceViewRenderer} which is based on {@link TextureView} + */ +public class TextureViewRenderer extends TextureView implements TextureView.SurfaceTextureListener, VideoSink, RendererCommon.RendererEvents { + + private static final String TAG = Log.tag(TextureViewRenderer.class); + + private final SurfaceTextureEglRenderer eglRenderer; + private final RendererCommon.VideoLayoutMeasure videoLayoutMeasure = new RendererCommon.VideoLayoutMeasure(); + + private RendererCommon.RendererEvents rendererEvents; + private int rotatedFrameWidth; + private int rotatedFrameHeight; + private boolean enableFixedSize; + private int surfaceWidth; + private int surfaceHeight; + private boolean isInitialized; + private BroadcastVideoSink attachedVideoSink; + private Lifecycle lifecycle; + + public TextureViewRenderer(@NonNull Context context) { + super(context); + this.eglRenderer = new SurfaceTextureEglRenderer(getResourceName()); + this.setSurfaceTextureListener(this); + } + + public TextureViewRenderer(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + this.eglRenderer = new SurfaceTextureEglRenderer(getResourceName()); + this.setSurfaceTextureListener(this); + } + + public void init(@NonNull EglBase eglBase) { + if (isInitialized) return; + + isInitialized = true; + + this.init(eglBase.getEglBaseContext(), null, EglBase.CONFIG_PLAIN, new GlRectDrawer()); + } + + public void init(@NonNull EglBase.Context sharedContext, @Nullable RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) { + ThreadUtils.checkIsOnMainThread(); + + this.rendererEvents = rendererEvents; + this.rotatedFrameWidth = 0; + this.rotatedFrameHeight = 0; + + this.eglRenderer.init(sharedContext, this, configAttributes, drawer); + + this.lifecycle = ViewUtil.getActivityLifecycle(this); + if (lifecycle != null) { + lifecycle.addObserver(new DefaultLifecycleObserver() { + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + release(); + } + }); + } + } + + public void attachBroadcastVideoSink(@Nullable BroadcastVideoSink videoSink) { + if (attachedVideoSink == videoSink) { + return; + } + + eglRenderer.clearImage(); + + if (attachedVideoSink != null) { + attachedVideoSink.removeSink(this); + attachedVideoSink.removeRequestingSize(this); + } + + if (videoSink != null) { + videoSink.addSink(this); + videoSink.putRequestingSize(this, new Point(getWidth(), getHeight())); + } else { + clearImage(); + } + + attachedVideoSink = videoSink; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (lifecycle == null || lifecycle.getCurrentState() == Lifecycle.State.DESTROYED) { + release(); + } + } + + public void release() { + eglRenderer.release(); + if (attachedVideoSink != null) { + attachedVideoSink.removeSink(this); + attachedVideoSink.removeRequestingSize(this); + } + } + + public void addFrameListener(@NonNull EglRenderer.FrameListener listener, float scale, @NonNull RendererCommon.GlDrawer drawerParam) { + eglRenderer.addFrameListener(listener, scale, drawerParam); + } + + public void addFrameListener(@NonNull EglRenderer.FrameListener listener, float scale) { + eglRenderer.addFrameListener(listener, scale); + } + + public void removeFrameListener(@NonNull EglRenderer.FrameListener listener) { + eglRenderer.removeFrameListener(listener); + } + + public void setEnableHardwareScaler(boolean enabled) { + ThreadUtils.checkIsOnMainThread(); + + enableFixedSize = enabled; + + updateSurfaceSize(); + } + + public void setMirror(boolean mirror) { + eglRenderer.setMirror(mirror); + } + + public void setScalingType(@NonNull RendererCommon.ScalingType scalingType) { + ThreadUtils.checkIsOnMainThread(); + + videoLayoutMeasure.setScalingType(scalingType); + + requestLayout(); + } + + public void setScalingType(@NonNull RendererCommon.ScalingType scalingTypeMatchOrientation, + @NonNull RendererCommon.ScalingType scalingTypeMismatchOrientation) + { + ThreadUtils.checkIsOnMainThread(); + + videoLayoutMeasure.setScalingType(scalingTypeMatchOrientation, scalingTypeMismatchOrientation); + + requestLayout(); + } + + public void setFpsReduction(float fps) { + eglRenderer.setFpsReduction(fps); + } + + public void disableFpsReduction() { + eglRenderer.disableFpsReduction(); + } + + public void pauseVideo() { + eglRenderer.pauseVideo(); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + ThreadUtils.checkIsOnMainThread(); + + widthSpec = MeasureSpec.makeMeasureSpec(resolveSizeAndState(0, widthSpec, 0), MeasureSpec.AT_MOST); + heightSpec = MeasureSpec.makeMeasureSpec(resolveSizeAndState(0, heightSpec, 0), MeasureSpec.AT_MOST); + + Point size = videoLayoutMeasure.measure(widthSpec, heightSpec, this.rotatedFrameWidth, this.rotatedFrameHeight); + + setMeasuredDimension(size.x, size.y); + + Log.d(TAG, "onMeasure(). New size: " + size.x + "x" + size.y); + + if (attachedVideoSink != null) { + attachedVideoSink.putRequestingSize(this, size); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + ThreadUtils.checkIsOnMainThread(); + + eglRenderer.setLayoutAspectRatio((float)(right - left) / (float)(bottom - top)); + + updateSurfaceSize(); + } + + private void updateSurfaceSize() { + ThreadUtils.checkIsOnMainThread(); + + if (!isAvailable()) { + return; + } + + if (this.enableFixedSize && this.rotatedFrameWidth != 0 && this.rotatedFrameHeight != 0 && this.getWidth() != 0 && this.getHeight() != 0) { + + float layoutAspectRatio = (float)this.getWidth() / (float)this.getHeight(); + float frameAspectRatio = (float)this.rotatedFrameWidth / (float)this.rotatedFrameHeight; + + int drawnFrameWidth; + int drawnFrameHeight; + + if (frameAspectRatio > layoutAspectRatio) { + drawnFrameWidth = (int)((float)this.rotatedFrameHeight * layoutAspectRatio); + drawnFrameHeight = this.rotatedFrameHeight; + } else { + drawnFrameWidth = this.rotatedFrameWidth; + drawnFrameHeight = (int)((float)this.rotatedFrameWidth / layoutAspectRatio); + } + + int width = Math.min(this.getWidth(), drawnFrameWidth); + int height = Math.min(this.getHeight(), drawnFrameHeight); + + Log.d(TAG, "updateSurfaceSize. Layout size: " + this.getWidth() + "x" + this.getHeight() + ", frame size: " + this.rotatedFrameWidth + "x" + this.rotatedFrameHeight + ", requested surface size: " + width + "x" + height + ", old surface size: " + this.surfaceWidth + "x" + this.surfaceHeight); + + if (width != this.surfaceWidth || height != this.surfaceHeight) { + this.surfaceWidth = width; + this.surfaceHeight = height; + getSurfaceTexture().setDefaultBufferSize(width, height); + } + } else { + this.surfaceWidth = this.surfaceHeight = 0; + this.getSurfaceTexture().setDefaultBufferSize(getMeasuredWidth(), getMeasuredHeight()); + } + } + + @Override + public void onFirstFrameRendered() { + if (this.rendererEvents != null) { + this.rendererEvents.onFirstFrameRendered(); + } + } + + @Override + public void onFrameResolutionChanged(int videoWidth, int videoHeight, int rotation) { + if (this.rendererEvents != null) { + this.rendererEvents.onFrameResolutionChanged(videoWidth, videoHeight, rotation); + } + + int rotatedWidth = rotation != 0 && rotation != 180 ? videoHeight : videoWidth; + int rotatedHeight = rotation != 0 && rotation != 180 ? videoWidth : videoHeight; + this.postOrRun(() -> { + this.rotatedFrameWidth = rotatedWidth; + this.rotatedFrameHeight = rotatedHeight; + this.updateSurfaceSize(); + this.requestLayout(); + }); + } + + @Override + public void onFrame(VideoFrame videoFrame) { + if (isAttachedToWindow()) { + eglRenderer.onFrame(videoFrame); + } + } + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + ThreadUtils.checkIsOnMainThread(); + + surfaceWidth = 0; + surfaceHeight = 0; + + updateSurfaceSize(); + + eglRenderer.onSurfaceTextureAvailable(surface, width, height); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + eglRenderer.onSurfaceTextureSizeChanged(surface, width, height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + return eglRenderer.onSurfaceTextureDestroyed(surface); + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + } + + private String getResourceName() { + try { + return this.getResources().getResourceEntryName(this.getId()); + } catch (Resources.NotFoundException var2) { + return ""; + } + } + + public void clearImage() { + this.eglRenderer.clearImage(); + } + + private void postOrRun(Runnable r) { + if (Thread.currentThread() == Looper.getMainLooper().getThread()) { + r.run(); + } else { + this.post(r); + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAnswerDeclineButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAnswerDeclineButton.java new file mode 100644 index 00000000..7940e2d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAnswerDeclineButton.java @@ -0,0 +1,374 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ArgbEvaluator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityManager; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.AccessibilityUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +public final class WebRtcAnswerDeclineButton extends LinearLayout implements AccessibilityManager.TouchExplorationStateChangeListener { + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(WebRtcAnswerDeclineButton.class); + + private static final int TOTAL_TIME = 1000; + private static final int SHAKE_TIME = 200; + + private static final int UP_TIME = (TOTAL_TIME - SHAKE_TIME) / 2; + private static final int DOWN_TIME = (TOTAL_TIME - SHAKE_TIME) / 2; + private static final int FADE_OUT_TIME = 300; + private static final int FADE_IN_TIME = 100; + private static final int SHIMMER_TOTAL = UP_TIME + SHAKE_TIME; + + private static final int ANSWER_THRESHOLD = 112; + private static final int DECLINE_THRESHOLD = 56; + + private AnswerDeclineListener listener; + @Nullable private DragToAnswer dragToAnswerListener; + private AccessibilityManager accessibilityManager; + private boolean ringAnimation; + + public WebRtcAnswerDeclineButton(Context context) { + super(context); + initialize(); + } + + public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + setOrientation(LinearLayout.VERTICAL); + setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + accessibilityManager = ServiceUtil.getAccessibilityManager(getContext()); + + createView(accessibilityManager.isTouchExplorationEnabled()); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + accessibilityManager.addTouchExplorationStateChangeListener(this); + } + + @Override + protected void onDetachedFromWindow() { + accessibilityManager.removeTouchExplorationStateChangeListener(this); + super.onDetachedFromWindow(); + } + + private void createView(boolean isTouchExplorationEnabled) { + if (isTouchExplorationEnabled) { + inflate(getContext(), R.layout.webrtc_answer_decline_button_accessible, this); + + findViewById(R.id.answer).setOnClickListener((view) -> listener.onAnswered()); + findViewById(R.id.reject).setOnClickListener((view) -> listener.onDeclined()); + } else { + inflate(getContext(), R.layout.webrtc_answer_decline_button, this); + + ImageView answer = findViewById(R.id.answer); + + dragToAnswerListener = new DragToAnswer(answer, this); + + answer.setOnTouchListener(dragToAnswerListener); + + if (ringAnimation) { + startRingingAnimation(); + } + } + } + + public void setAnswerDeclineListener(AnswerDeclineListener listener) { + this.listener = listener; + } + + public void startRingingAnimation() { + ringAnimation = true; + if (dragToAnswerListener != null) { + dragToAnswerListener.startRingingAnimation(); + } + } + + public void stopRingingAnimation() { + ringAnimation = false; + if (dragToAnswerListener != null) { + dragToAnswerListener.stopRingingAnimation(); + } + } + + @Override + public void onTouchExplorationStateChanged(boolean enabled) { + removeAllViews(); + createView(enabled); + } + + private class DragToAnswer implements View.OnTouchListener { + + private final TextView swipeUpText; + private final ImageView answer; + private final TextView swipeDownText; + + private final ImageView arrowOne; + private final ImageView arrowTwo; + private final ImageView arrowThree; + private final ImageView arrowFour; + + private float lastY; + + private boolean animating = false; + private boolean complete = false; + + private AnimatorSet animatorSet; + + private DragToAnswer(@NonNull ImageView answer, WebRtcAnswerDeclineButton view) { + this.swipeUpText = view.findViewById(R.id.swipe_up_text); + this.answer = answer; + this.swipeDownText = view.findViewById(R.id.swipe_down_text); + + this.arrowOne = view.findViewById(R.id.arrow_one); + this.arrowTwo = view.findViewById(R.id.arrow_two); + this.arrowThree = view.findViewById(R.id.arrow_three); + this.arrowFour = view.findViewById(R.id.arrow_four); + } + + private void startRingingAnimation() { + if (!animating) { + animating = true; + animateElements(0); + } + } + + private void stopRingingAnimation() { + if (animating) { + animating = false; + resetElements(); + } + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + resetElements(); + swipeUpText.animate().alpha(0).setDuration(200).start(); + swipeDownText.animate().alpha(0).setDuration(200).start(); + lastY = event.getRawY(); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + swipeUpText.clearAnimation(); + swipeDownText.clearAnimation(); + swipeUpText.setAlpha(1); + swipeDownText.setAlpha(1); + answer.setRotation(0); + + if (Build.VERSION.SDK_INT >= 21) { + answer.getDrawable().setTint(getResources().getColor(R.color.green_600)); + answer.getBackground().setTint(Color.WHITE); + } + + animating = true; + animateElements(0); + break; + case MotionEvent.ACTION_MOVE: + float difference = event.getRawY() - lastY; + + float differenceThreshold; + float percentageToThreshold; + int backgroundColor; + int foregroundColor; + + if (difference <= 0) { + differenceThreshold = ViewUtil.dpToPx(getContext(), ANSWER_THRESHOLD); + percentageToThreshold = Math.min(1, (difference * -1) / differenceThreshold); + backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.green_100), getResources().getColor(R.color.green_600)); + + if (percentageToThreshold > 0.5) { + foregroundColor = Color.WHITE; + } else { + foregroundColor = getResources().getColor(R.color.green_600); + } + + answer.setTranslationY(difference); + + if (percentageToThreshold == 1 && listener != null) { + answer.setVisibility(View.INVISIBLE); + lastY = event.getRawY(); + if (!complete) { + complete = true; + listener.onAnswered(); + } + } + } else { + differenceThreshold = ViewUtil.dpToPx(getContext(), DECLINE_THRESHOLD); + percentageToThreshold = Math.min(1, difference / differenceThreshold); + backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.red_100), getResources().getColor(R.color.red_600)); + + if (percentageToThreshold > 0.5) { + foregroundColor = Color.WHITE; + } else { + foregroundColor = getResources().getColor(R.color.green_600); + } + + answer.setRotation(135 * percentageToThreshold); + + if (percentageToThreshold == 1 && listener != null) { + answer.setVisibility(View.INVISIBLE); + lastY = event.getRawY(); + + if (!complete) { + complete = true; + listener.onDeclined(); + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + answer.getBackground().setTint(backgroundColor); + answer.getDrawable().setTint(foregroundColor); + } + + break; + } + + return true; + } + + private void animateElements(int delay) { + if (AccessibilityUtil.areAnimationsDisabled(getContext())) return; + + ObjectAnimator fabUp = getUpAnimation(answer); + ObjectAnimator fabDown = getDownAnimation(answer); + ObjectAnimator fabShake = getShakeAnimation(answer); + + animatorSet = new AnimatorSet(); + animatorSet.play(fabUp).with(getUpAnimation(swipeUpText)); + animatorSet.play(fabShake).after(fabUp); + animatorSet.play(fabDown).with(getDownAnimation(swipeUpText)).after(fabShake); + + animatorSet.play(getFadeOut(swipeDownText)).with(fabUp); + animatorSet.play(getFadeIn(swipeDownText)).after(fabDown); + + animatorSet.play(getShimmer(arrowFour, arrowThree, arrowTwo, arrowOne)); + + animatorSet.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) { + } + + @Override + public void onAnimationEnd(Animator animation) { + if (animating) animateElements(1000); + } + + @Override + public void onAnimationCancel(Animator animation) {} + @Override + public void onAnimationRepeat(Animator animation) {} + }); + + animatorSet.setStartDelay(delay); + animatorSet.start(); + } + + private void resetElements() { + animating = false; + complete = false; + + if (animatorSet != null) animatorSet.cancel(); + + swipeUpText.setTranslationY(0); + answer.setTranslationY(0); + swipeDownText.setAlpha(1); + } + } + + private static Animator getShimmer(View... targets) { + AnimatorSet animatorSet = new AnimatorSet(); + int evenDuration = SHIMMER_TOTAL / targets.length; + int interval = 75; + + for (int i=0;i OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET); + + private boolean isHeadsetAvailable; + private boolean isHandsetAvailable; + private int outputIndex; + private OnAudioOutputChangedListener audioOutputChangedListener; + private DialogInterface picker; + + public WebRtcAudioOutputToggleButton(@NonNull Context context) { + this(context, null); + } + + public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + super.setOnClickListener((v) -> { + List availableModes = buildOutputModeList(isHeadsetAvailable, isHandsetAvailable); + + if (availableModes.size() > 2 || !isHandsetAvailable) showPicker(availableModes); + else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_MODES.size()), true); + }); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + hidePicker(); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] extra = OUTPUT_ENUM[outputIndex]; + final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length); + mergeDrawableStates(drawableState, extra); + return drawableState; + } + + @Override + public void setOnClickListener(@Nullable OnClickListener l) { + throw new UnsupportedOperationException("This View does not support custom click listeners."); + } + + public void setControlAvailability(boolean isHandsetAvailable, boolean isHeadsetAvailable) { + this.isHandsetAvailable = isHandsetAvailable; + this.isHeadsetAvailable = isHeadsetAvailable; + } + + public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput, boolean notifyListener) { + int oldIndex = outputIndex; + outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.lastIndexOf(audioOutput)); + + if (oldIndex != outputIndex) { + refreshDrawableState(); + + if (notifyListener) { + notifyListener(); + } + } + } + + public void setOnAudioOutputChangedListener(@Nullable OnAudioOutputChangedListener listener) { + this.audioOutputChangedListener = listener; + } + + private void showPicker(@NonNull List availableModes) { + RecyclerView rv = new RecyclerView(getContext()); + AudioOutputAdapter adapter = new AudioOutputAdapter(audioOutput -> { + setAudioOutput(audioOutput, true); + hidePicker(); + }, + availableModes); + + adapter.setSelectedOutput(OUTPUT_MODES.get(outputIndex)); + + rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); + rv.setAdapter(adapter); + + picker = new AlertDialog.Builder(getContext(), R.style.Theme_Signal_AlertDialog_Dark_Cornered) + .setTitle(R.string.WebRtcAudioOutputToggle__audio_output) + .setView(rv) + .setCancelable(true) + .show(); + } + + @Override + protected Parcelable onSaveInstanceState() { + Parcelable parentState = super.onSaveInstanceState(); + Bundle bundle = new Bundle(); + + bundle.putParcelable(STATE_PARENT, parentState); + bundle.putInt(STATE_OUTPUT_INDEX, outputIndex); + bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable); + bundle.putBoolean(STATE_HANDSET_ENABLED, isHandsetAvailable); + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state instanceof Bundle) { + Bundle savedState = (Bundle) state; + + isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED); + isHandsetAvailable = savedState.getBoolean(STATE_HANDSET_ENABLED); + + setAudioOutput(OUTPUT_MODES.get( + resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX))), + false + ); + + super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT)); + } else { + super.onRestoreInstanceState(state); + } + } + + private void hidePicker() { + if (picker != null) { + picker.dismiss(); + picker = null; + } + } + + private void notifyListener() { + if (audioOutputChangedListener == null) return; + + audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex)); + } + + private static List buildOutputModeList(boolean isHeadsetAvailable, boolean isHandsetAvailable) { + List modes = new ArrayList(3); + + modes.add(WebRtcAudioOutput.SPEAKER); + + if (isHeadsetAvailable) { + modes.add(WebRtcAudioOutput.HEADSET); + } + + if (isHandsetAvailable) { + modes.add(WebRtcAudioOutput.HANDSET); + } + + return modes; + }; + + private int resolveAudioOutputIndex(int desiredAudioOutputIndex) { + if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) { + throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex); + } + if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable, isHandsetAvailable)) { + if (!isHandsetAvailable) { + return OUTPUT_MODES.lastIndexOf(WebRtcAudioOutput.SPEAKER); + } else { + return OUTPUT_MODES.indexOf(WebRtcAudioOutput.HANDSET); + } + } + + if (!isHeadsetAvailable) { + return desiredAudioOutputIndex % 2; + } + + return desiredAudioOutputIndex; + } + + private static boolean isIllegalAudioOutputIndex(int desiredAudioOutputIndex) { + return desiredAudioOutputIndex < 0 || desiredAudioOutputIndex > OUTPUT_MODES.size(); + } + + private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable, boolean isHandsetAvailable) { + return (OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable) || + (OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HANDSET && !isHandsetAvailable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java new file mode 100644 index 00000000..89889289 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.events.CallParticipant; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +class WebRtcCallParticipantsPage { + + private final List callParticipants; + private final CallParticipant focusedParticipant; + private final boolean isSpeaker; + private final boolean isRenderInPip; + + static WebRtcCallParticipantsPage forMultipleParticipants(@NonNull List callParticipants, + @NonNull CallParticipant focusedParticipant, + boolean isRenderInPip) + { + return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip); + } + + static WebRtcCallParticipantsPage forSingleParticipant(@NonNull CallParticipant singleParticipant, + boolean isRenderInPip) + { + return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip); + } + + private WebRtcCallParticipantsPage(@NonNull List callParticipants, + @NonNull CallParticipant focusedParticipant, + boolean isSpeaker, + boolean isRenderInPip) + { + this.callParticipants = callParticipants; + this.focusedParticipant = focusedParticipant; + this.isSpeaker = isSpeaker; + this.isRenderInPip = isRenderInPip; + } + + public @NonNull List getCallParticipants() { + return callParticipants; + } + + public @NonNull CallParticipant getFocusedParticipant() { + return focusedParticipant; + } + + public boolean isRenderInPip() { + return isRenderInPip; + } + + public boolean isSpeaker() { + return isSpeaker; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebRtcCallParticipantsPage that = (WebRtcCallParticipantsPage) o; + return isSpeaker == that.isSpeaker && + isRenderInPip == that.isRenderInPip && + focusedParticipant.equals(that.focusedParticipant) && + callParticipants.equals(that.callParticipants); + } + + @Override + public int hashCode() { + return Objects.hash(callParticipants, isSpeaker, focusedParticipant, isRenderInPip); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java new file mode 100644 index 00000000..06c3afd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +class WebRtcCallParticipantsPagerAdapter extends ListAdapter { + + private static final int VIEW_TYPE_MULTI = 0; + private static final int VIEW_TYPE_SINGLE = 1; + + private final Runnable onPageClicked; + + WebRtcCallParticipantsPagerAdapter(@NonNull Runnable onPageClicked) { + super(new DiffCallback()); + this.onPageClicked = onPageClicked; + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final ViewHolder viewHolder; + + switch (viewType) { + case VIEW_TYPE_SINGLE: + viewHolder = new SingleParticipantViewHolder((CallParticipantView) LayoutInflater.from(parent.getContext()) + .inflate(R.layout.call_participant_item, + parent, + false)); + break; + case VIEW_TYPE_MULTI: + viewHolder = new MultipleParticipantViewHolder((CallParticipantsLayout) LayoutInflater.from(parent.getContext()) + .inflate(R.layout.webrtc_call_participants_layout, + parent, + false)); + break; + default: + throw new IllegalArgumentException("Unsupported viewType: " + viewType); + } + + viewHolder.itemView.setOnClickListener(unused -> onPageClicked.run()); + + return viewHolder; + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + @Override + public int getItemViewType(int position) { + return getItem(position).isSpeaker() ? VIEW_TYPE_SINGLE : VIEW_TYPE_MULTI; + } + + static abstract class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(@NonNull View itemView) { + super(itemView); + } + + abstract void bind(WebRtcCallParticipantsPage page); + } + + private static class MultipleParticipantViewHolder extends ViewHolder { + + private final CallParticipantsLayout callParticipantsLayout; + + private MultipleParticipantViewHolder(@NonNull CallParticipantsLayout callParticipantsLayout) { + super(callParticipantsLayout); + this.callParticipantsLayout = callParticipantsLayout; + } + + @Override + void bind(WebRtcCallParticipantsPage page) { + callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip()); + } + } + + private static class SingleParticipantViewHolder extends ViewHolder { + + private final CallParticipantView callParticipantView; + + private SingleParticipantViewHolder(CallParticipantView callParticipantView) { + super(callParticipantView); + this.callParticipantView = callParticipantView; + + ViewGroup.LayoutParams params = callParticipantView.getLayoutParams(); + + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + + callParticipantView.setLayoutParams(params); + } + + + @Override + void bind(WebRtcCallParticipantsPage page) { + callParticipantView.setCallParticipant(page.getCallParticipants().get(0)); + callParticipantView.setRenderInPip(page.isRenderInPip()); + } + } + + private static final class DiffCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) { + return oldItem.isSpeaker() == newItem.isSpeaker(); + } + + @Override + public boolean areContentsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) { + return oldItem.equals(newItem); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java new file mode 100644 index 00000000..56df4ec7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; + +class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter { + + private static final int PARTICIPANT = 0; + private static final int EMPTY = 1; + + protected WebRtcCallParticipantsRecyclerAdapter() { + super(new DiffCallback()); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == PARTICIPANT) { + return new ParticipantViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.webrtc_call_participant_recycler_item, parent, false)); + } else { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.webrtc_call_participant_recycler_empty_item, parent, false)); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + @Override + public int getItemViewType(int position) { + return getItem(position) == CallParticipant.EMPTY ? EMPTY : PARTICIPANT; + } + + static class ViewHolder extends RecyclerView.ViewHolder { + ViewHolder(@NonNull View itemView) { + super(itemView); + } + + void bind(@NonNull CallParticipant callParticipant) {} + } + + private static class ParticipantViewHolder extends ViewHolder { + + private final CallParticipantView callParticipantView; + + ParticipantViewHolder(@NonNull View itemView) { + super(itemView); + callParticipantView = itemView.findViewById(R.id.call_participant); + } + + @Override + void bind(@NonNull CallParticipant callParticipant) { + callParticipantView.setCallParticipant(callParticipant); + callParticipantView.setRenderInPip(true); + } + } + + private static class DiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) { + return oldItem.getRecipient().equals(newItem.getRecipient()); + } + + @Override + public boolean areContentsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) { + return oldItem.equals(newItem); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java new file mode 100644 index 00000000..cdf046df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.media.AudioManager; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.identity.IdentityRecordList; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.util.Collections; +import java.util.List; + +class WebRtcCallRepository { + + private final Context context; + private final AudioManager audioManager; + + WebRtcCallRepository(@NonNull Context context) { + this.context = context; + this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication()); + } + + @NonNull WebRtcAudioOutput getAudioOutput() { + if (audioManager.isBluetoothScoOn()) { + return WebRtcAudioOutput.HEADSET; + } else if (audioManager.isSpeakerphoneOn()) { + return WebRtcAudioOutput.SPEAKER; + } else { + return WebRtcAudioOutput.HANDSET; + } + } + + @WorkerThread + void getIdentityRecords(@NonNull Recipient recipient, @NonNull Consumer consumer) { + SignalExecutors.BOUNDED.execute(() -> { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + List recipients; + + if (recipient.isGroup()) { + recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + } else { + recipients = Collections.singletonList(recipient); + } + + consumer.accept(identityDatabase.getIdentities(recipients)); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java new file mode 100644 index 00000000..2237967e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -0,0 +1,860 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.animation.Animator; +import android.animation.AnimatorInflater; +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.ColorMatrix; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewPropertyAnimator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.AnimationUtils; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.constraintlayout.widget.Guideline; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.AutoTransition; +import androidx.transition.Transition; +import androidx.transition.TransitionManager; +import androidx.transition.TransitionSet; +import androidx.viewpager2.widget.MarginPageTransformer; +import androidx.viewpager2.widget.ViewPager2; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.google.android.material.button.MaterialButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.ResizeAnimation; +import org.thoughtcrime.securesms.components.AccessibleToggleButton; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.util.BlurTransformation; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.Stub; +import org.webrtc.RendererCommon; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class WebRtcCallView extends FrameLayout { + + private static final long TRANSITION_DURATION_MILLIS = 250; + private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8; + private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16; + + public static final int FADE_OUT_DELAY = 5000; + public static final int PIP_RESIZE_DURATION = 300; + public static final int CONTROLS_HEIGHT = 98; + + private WebRtcAudioOutputToggleButton audioToggle; + private AccessibleToggleButton videoToggle; + private AccessibleToggleButton micToggle; + private ViewGroup smallLocalRenderFrame; + private CallParticipantView smallLocalRender; + private View largeLocalRenderFrame; + private TextureViewRenderer largeLocalRender; + private View largeLocalRenderNoVideo; + private ImageView largeLocalRenderNoVideoAvatar; + private TextView recipientName; + private TextView status; + private ConstraintLayout parent; + private ConstraintLayout participantsParent; + private ControlsListener controlsListener; + private RecipientId recipientId; + private ImageView answer; + private ImageView cameraDirectionToggle; + private PictureInPictureGestureHelper pictureInPictureGestureHelper; + private ImageView hangup; + private View answerWithAudio; + private View answerWithAudioLabel; + private View footerGradient; + private View startCallControls; + private ViewPager2 callParticipantsPager; + private RecyclerView callParticipantsRecycler; + private Toolbar toolbar; + private MaterialButton startCall; + private TextView participantCount; + private Stub groupCallSpeakerHint; + private Stub groupCallFullStub; + private View errorButton; + private int pagerBottomMarginDp; + private boolean controlsVisible = true; + + private WebRtcCallParticipantsPagerAdapter pagerAdapter; + private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; + private PictureInPictureExpansionHelper pictureInPictureExpansionHelper; + + + private final Set incomingCallViews = new HashSet<>(); + private final Set topViews = new HashSet<>(); + private final Set visibleViewSet = new HashSet<>(); + private final Set adjustableMarginsSet = new HashSet<>(); + private final Set rotatableControls = new HashSet<>(); + + private WebRtcControls controls = WebRtcControls.NONE; + private final Runnable fadeOutRunnable = () -> { + if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); + }; + + public WebRtcCallView(@NonNull Context context) { + this(context, null); + } + + public WebRtcCallView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + inflate(context, R.layout.webrtc_call_view, this); + } + + @SuppressWarnings("CodeBlock2Expr") + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + audioToggle = findViewById(R.id.call_screen_speaker_toggle); + videoToggle = findViewById(R.id.call_screen_video_toggle); + micToggle = findViewById(R.id.call_screen_audio_mic_toggle); + smallLocalRenderFrame = findViewById(R.id.call_screen_pip); + smallLocalRender = findViewById(R.id.call_screen_small_local_renderer); + largeLocalRenderFrame = findViewById(R.id.call_screen_large_local_renderer_frame); + largeLocalRender = findViewById(R.id.call_screen_large_local_renderer); + largeLocalRenderNoVideo = findViewById(R.id.call_screen_large_local_video_off); + largeLocalRenderNoVideoAvatar = findViewById(R.id.call_screen_large_local_video_off_avatar); + recipientName = findViewById(R.id.call_screen_recipient_name); + status = findViewById(R.id.call_screen_status); + parent = findViewById(R.id.call_screen); + participantsParent = findViewById(R.id.call_screen_participants_parent); + answer = findViewById(R.id.call_screen_answer_call); + cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle); + hangup = findViewById(R.id.call_screen_end_call); + answerWithAudio = findViewById(R.id.call_screen_answer_with_audio); + answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label); + footerGradient = findViewById(R.id.call_screen_footer_gradient); + startCallControls = findViewById(R.id.call_screen_start_call_controls); + callParticipantsPager = findViewById(R.id.call_screen_participants_pager); + callParticipantsRecycler = findViewById(R.id.call_screen_participants_recycler); + toolbar = findViewById(R.id.call_screen_toolbar); + startCall = findViewById(R.id.call_screen_start_call_start_call); + errorButton = findViewById(R.id.call_screen_error_cancel); + groupCallSpeakerHint = new Stub<>(findViewById(R.id.call_screen_group_call_speaker_hint)); + groupCallFullStub = new Stub<>(findViewById(R.id.group_call_call_full_view)); + + View topGradient = findViewById(R.id.call_screen_header_gradient); + View decline = findViewById(R.id.call_screen_decline_call); + View answerLabel = findViewById(R.id.call_screen_answer_call_label); + View declineLabel = findViewById(R.id.call_screen_decline_call_label); + View cancelStartCall = findViewById(R.id.call_screen_start_call_cancel); + + callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4))); + + pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls); + recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter(); + + callParticipantsPager.setAdapter(pagerAdapter); + callParticipantsRecycler.setAdapter(recyclerAdapter); + + callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + runIfNonNull(controlsListener, listener -> listener.onPageChanged(position == 0 ? CallParticipantsState.SelectedPage.GRID : CallParticipantsState.SelectedPage.FOCUSED)); + } + }); + + topViews.add(toolbar); + topViews.add(topGradient); + + incomingCallViews.add(answer); + incomingCallViews.add(answerLabel); + incomingCallViews.add(decline); + incomingCallViews.add(declineLabel); + incomingCallViews.add(footerGradient); + + adjustableMarginsSet.add(micToggle); + adjustableMarginsSet.add(cameraDirectionToggle); + adjustableMarginsSet.add(videoToggle); + adjustableMarginsSet.add(audioToggle); + + audioToggle.setOnAudioOutputChangedListener(outputMode -> { + runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode)); + }); + + videoToggle.setOnCheckedChangeListener((v, isOn) -> { + runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn)); + }); + + micToggle.setOnCheckedChangeListener((v, isOn) -> { + runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn)); + }); + + cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged)); + + hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed)); + decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed)); + + answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed)); + answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); + + pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); + pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(); + + smallLocalRenderFrame.setOnClickListener(v -> { + if (controlsListener != null) { + controlsListener.onLocalPictureInPictureClicked(); + } + }); + + startCall.setOnClickListener(v -> { + if (controlsListener != null) { + startCall.setEnabled(false); + controlsListener.onStartCall(videoToggle.isChecked()); + } + }); + cancelStartCall.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCancelStartCall)); + + ColorMatrix greyScaleMatrix = new ColorMatrix(); + greyScaleMatrix.setSaturation(0); + largeLocalRenderNoVideoAvatar.setAlpha(0.6f); + largeLocalRenderNoVideoAvatar.setColorFilter(new ColorMatrixColorFilter(greyScaleMatrix)); + + errorButton.setOnClickListener(v -> { + if (controlsListener != null) { + controlsListener.onCancelStartCall(); + } + }); + + rotatableControls.add(hangup); + rotatableControls.add(answer); + rotatableControls.add(answerWithAudio); + rotatableControls.add(audioToggle); + rotatableControls.add(micToggle); + rotatableControls.add(videoToggle); + rotatableControls.add(cameraDirectionToggle); + rotatableControls.add(decline); + rotatableControls.add(smallLocalRender.findViewById(R.id.call_participant_mic_muted)); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (controls.isFadeOutEnabled()) { + scheduleFadeOut(); + } + } + + @Override + protected boolean fitSystemWindows(Rect insets) { + Guideline statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline); + Guideline navigationBarGuideline = findViewById(R.id.call_screen_navigation_bar_guideline); + + statusBarGuideline.setGuidelineBegin(insets.top); + navigationBarGuideline.setGuidelineEnd(insets.bottom); + + return true; + } + + @Override + public void onWindowSystemUiVisibilityChanged(int visible) { + if ((visible & SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { + pictureInPictureGestureHelper.setVerticalBoundaries(toolbar.getBottom(), videoToggle.getTop()); + } else { + pictureInPictureGestureHelper.clearVerticalBoundaries(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + cancelFadeOut(); + } + + public void rotateControls(int degrees) { + for (View view : rotatableControls) { + view.animate().rotation(degrees); + } + } + + public void setControlsListener(@Nullable ControlsListener controlsListener) { + this.controlsListener = controlsListener; + } + + public void setMicEnabled(boolean isMicEnabled) { + micToggle.setChecked(isMicEnabled, false); + } + + public void updateCallParticipants(@NonNull CallParticipantsState state) { + List pages = new ArrayList<>(2); + + if (!state.getGridParticipants().isEmpty()) { + pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode())); + } + + if (state.getFocusedParticipant() != null && state.getAllRemoteParticipants().size() > 1) { + pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode())); + } + + if ((state.getGroupCallState().isNotIdle() && state.getRemoteDevicesCount().orElse(0) > 0) || state.getGroupCallState().isConnected()) { + recipientName.setText(state.getRemoteParticipantsDescription(getContext())); + } else if (state.getGroupCallState().isNotIdle()) { + recipientName.setText(getContext().getString(R.string.WebRtcCallView__s_group_call, Recipient.resolved(recipientId).getDisplayName(getContext()))); + } + + if (state.getGroupCallState().isNotIdle() && participantCount != null) { + participantCount.setText(state.getParticipantCount() + .mapToObj(String::valueOf).orElse("\u2014")); + participantCount.setEnabled(state.getParticipantCount().isPresent()); + } + + pagerAdapter.submitList(pages); + recyclerAdapter.submitList(state.getListParticipants()); + updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant()); + + if (state.isLargeVideoGroup() && !state.isInPipMode()) { + layoutParticipantsForLargeCount(); + } else { + layoutParticipantsForSmallCount(); + } + } + + public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) { + largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + + smallLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + largeLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + + if (localCallParticipant.getVideoSink().getEglBase() != null) { + largeLocalRender.init(localCallParticipant.getVideoSink().getEglBase()); + } + + videoToggle.setChecked(localCallParticipant.isVideoEnabled(), false); + smallLocalRender.setRenderInPip(true); + + if (state == WebRtcLocalRenderState.EXPANDED) { + expandPip(localCallParticipant, focusedParticipant); + return; + } else if ((state == WebRtcLocalRenderState.SMALL_RECTANGLE || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) { + shrinkPip(localCallParticipant); + return; + } else { + smallLocalRender.setCallParticipant(localCallParticipant); + smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + } + + switch (state) { + case GONE: + largeLocalRender.attachBroadcastVideoSink(null); + largeLocalRenderFrame.setVisibility(View.GONE); + smallLocalRenderFrame.setVisibility(View.GONE); + + break; + case SMALL_RECTANGLE: + smallLocalRenderFrame.setVisibility(View.VISIBLE); + animatePipToLargeRectangle(); + + largeLocalRender.attachBroadcastVideoSink(null); + largeLocalRenderFrame.setVisibility(View.GONE); + break; + case SMALLER_RECTANGLE: + smallLocalRenderFrame.setVisibility(View.VISIBLE); + animatePipToSmallRectangle(); + + largeLocalRender.attachBroadcastVideoSink(null); + largeLocalRenderFrame.setVisibility(View.GONE); + break; + case LARGE: + largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink()); + largeLocalRenderFrame.setVisibility(View.VISIBLE); + + largeLocalRenderNoVideo.setVisibility(View.GONE); + largeLocalRenderNoVideoAvatar.setVisibility(View.GONE); + + smallLocalRenderFrame.setVisibility(View.GONE); + break; + case LARGE_NO_VIDEO: + largeLocalRender.attachBroadcastVideoSink(null); + largeLocalRenderFrame.setVisibility(View.VISIBLE); + + largeLocalRenderNoVideo.setVisibility(View.VISIBLE); + largeLocalRenderNoVideoAvatar.setVisibility(View.VISIBLE); + + GlideApp.with(getContext().getApplicationContext()) + .load(new ProfileContactPhoto(localCallParticipant.getRecipient(), localCallParticipant.getRecipient().getProfileAvatar())) + .transform(new CenterCrop(), new BlurTransformation(getContext(), 0.25f, BlurTransformation.MAX_RADIUS)) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(largeLocalRenderNoVideoAvatar); + + smallLocalRenderFrame.setVisibility(View.GONE); + break; + } + } + + public void setRecipient(@NonNull Recipient recipient) { + if (recipient.getId() == recipientId) { + return; + } + + recipientId = recipient.getId(); + + if (recipient.isGroup()) { + if (toolbar.getMenu().findItem(R.id.menu_group_call_participants_list) == null) { + toolbar.inflateMenu(R.menu.group_call); + + View showParticipants = toolbar.getMenu().findItem(R.id.menu_group_call_participants_list).getActionView(); + showParticipants.setOnClickListener(unused -> showParticipantsList()); + + participantCount = showParticipants.findViewById(R.id.show_participants_menu_counter); + } + } else { + recipientName.setText(recipient.getDisplayName(getContext())); + } + } + + public void setStatus(@NonNull String status) { + this.status.setText(status); + } + + public void setStatusFromHangupType(@NonNull HangupMessage.Type hangupType) { + switch (hangupType) { + case NORMAL: + case NEED_PERMISSION: + status.setText(R.string.RedPhone_ending_call); + break; + case ACCEPTED: + status.setText(R.string.WebRtcCallActivity__answered_on_a_linked_device); + break; + case DECLINED: + status.setText(R.string.WebRtcCallActivity__declined_on_a_linked_device); + break; + case BUSY: + status.setText(R.string.WebRtcCallActivity__busy_on_a_linked_device); + break; + default: + throw new IllegalStateException("Unknown hangup type: " + hangupType); + } + } + + public void setStatusFromGroupCallState(@NonNull WebRtcViewModel.GroupCallState groupCallState) { + switch (groupCallState) { + case DISCONNECTED: + status.setText(R.string.WebRtcCallView__disconnected); + break; + case RECONNECTING: + status.setText(R.string.WebRtcCallView__reconnecting); + break; + case CONNECTED_AND_JOINING: + status.setText(R.string.WebRtcCallView__joining); + break; + case CONNECTING: + case CONNECTED_AND_JOINED: + case CONNECTED: + status.setText(""); + break; + } + } + + public void setWebRtcControls(@NonNull WebRtcControls webRtcControls) { + Set lastVisibleSet = new HashSet<>(visibleViewSet); + + visibleViewSet.clear(); + + if (webRtcControls.displayStartCallControls()) { + visibleViewSet.add(footerGradient); + visibleViewSet.add(startCallControls); + + startCall.setText(webRtcControls.getStartCallButtonText()); + startCall.setEnabled(webRtcControls.isStartCallEnabled()); + } + + if (webRtcControls.displayErrorControls()) { + visibleViewSet.add(footerGradient); + visibleViewSet.add(errorButton); + } + + if (webRtcControls.displayGroupCallFull()) { + groupCallFullStub.get().setVisibility(View.VISIBLE); + ((TextView) groupCallFullStub.get().findViewById(R.id.group_call_call_full_message)).setText(webRtcControls.getGroupCallFullMessage(getContext())); + } else if (groupCallFullStub.resolved()) { + groupCallFullStub.get().setVisibility(View.GONE); + } + + MenuItem item = toolbar.getMenu().findItem(R.id.menu_group_call_participants_list); + if (item != null) { + item.setVisible(webRtcControls.displayGroupMembersButton()); + item.setEnabled(webRtcControls.displayGroupMembersButton()); + } + + if (webRtcControls.displayTopViews()) { + visibleViewSet.addAll(topViews); + } + + if (webRtcControls.displayIncomingCallButtons()) { + visibleViewSet.addAll(incomingCallViews); + + status.setText(R.string.WebRtcCallView__signal_voice_call); + answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer)); + } + + if (webRtcControls.displayAnswerWithAudio()) { + visibleViewSet.add(answerWithAudio); + visibleViewSet.add(answerWithAudioLabel); + + status.setText(R.string.WebRtcCallView__signal_video_call); + answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video)); + } + + if (webRtcControls.displayAudioToggle()) { + visibleViewSet.add(audioToggle); + + audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(), + webRtcControls.enableHeadsetInAudioToggle()); + + audioToggle.setAudioOutput(webRtcControls.getAudioOutput(), false); + } + + if (webRtcControls.displayCameraToggle()) { + visibleViewSet.add(cameraDirectionToggle); + } + + if (webRtcControls.displayEndCall()) { + visibleViewSet.add(hangup); + visibleViewSet.add(footerGradient); + } + + if (webRtcControls.displayMuteAudio()) { + visibleViewSet.add(micToggle); + } + + if (webRtcControls.displayVideoToggle()) { + visibleViewSet.add(videoToggle); + } + + if (webRtcControls.displaySmallOngoingCallButtons()) { + updateButtonStateForSmallButtons(); + } else if (webRtcControls.displayLargeOngoingCallButtons()) { + updateButtonStateForLargeButtons(); + } + + if (webRtcControls.displayRemoteVideoRecycler()) { + callParticipantsRecycler.setVisibility(View.VISIBLE); + } else { + callParticipantsRecycler.setVisibility(View.GONE); + } + + if (webRtcControls.isFadeOutEnabled()) { + if (!controls.isFadeOutEnabled()) { + scheduleFadeOut(); + } + } else { + cancelFadeOut(); + + if (controlsListener != null) { + controlsListener.showSystemUI(); + } + } + + controls = webRtcControls; + + if (!visibleViewSet.equals(lastVisibleSet) || !controls.isFadeOutEnabled()) { + fadeInNewUiState(lastVisibleSet, webRtcControls.displaySmallOngoingCallButtons()); + post(() -> pictureInPictureGestureHelper.setVerticalBoundaries(toolbar.getBottom(), videoToggle.getTop())); + } + } + + public @NonNull View getVideoTooltipTarget() { + return videoToggle; + } + + public void showSpeakerViewHint() { + groupCallSpeakerHint.get().setVisibility(View.VISIBLE); + } + + public void hideSpeakerViewHint() { + if (groupCallSpeakerHint.resolved()) { + groupCallSpeakerHint.get().setVisibility(View.GONE); + } + } + + private void expandPip(@NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) { + pictureInPictureExpansionHelper.expand(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() { + @Override + public void onAnimationWillStart() { + largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink()); + } + + @Override + public void onPictureInPictureExpanded() { + largeLocalRenderFrame.setVisibility(View.VISIBLE); + largeLocalRenderNoVideo.setVisibility(View.GONE); + largeLocalRenderNoVideoAvatar.setVisibility(View.GONE); + } + + @Override + public void onPictureInPictureNotVisible() { + smallLocalRender.setCallParticipant(focusedParticipant); + smallLocalRender.setMirror(false); + } + + @Override + public void onAnimationHasFinished() { + pictureInPictureGestureHelper.adjustPip(); + } + }); + } + + private void shrinkPip(@NonNull CallParticipant localCallParticipant) { + pictureInPictureExpansionHelper.shrink(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() { + @Override + public void onAnimationWillStart() { + } + + @Override + public void onPictureInPictureExpanded() { + largeLocalRenderFrame.setVisibility(View.GONE); + largeLocalRender.attachBroadcastVideoSink(null); + } + + @Override + public void onPictureInPictureNotVisible() { + smallLocalRender.setCallParticipant(localCallParticipant); + smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + + if (!localCallParticipant.isVideoEnabled()) { + smallLocalRenderFrame.setVisibility(View.GONE); + } + } + + @Override + public void onAnimationHasFinished() { + pictureInPictureGestureHelper.adjustPip(); + } + }); + } + + private void animatePipToLargeRectangle() { + ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(90), ViewUtil.dpToPx(160)); + animation.setDuration(PIP_RESIZE_DURATION); + animation.setAnimationListener(new SimpleAnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + pictureInPictureGestureHelper.enableCorners(); + pictureInPictureGestureHelper.adjustPip(); + } + }); + + smallLocalRenderFrame.startAnimation(animation); + } + + private void animatePipToSmallRectangle() { + pictureInPictureGestureHelper.lockToBottomEnd(); + + pictureInPictureGestureHelper.performAfterFling(() -> { + ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)); + animation.setDuration(PIP_RESIZE_DURATION); + animation.setAnimationListener(new SimpleAnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + pictureInPictureGestureHelper.adjustPip(); + } + }); + + smallLocalRenderFrame.startAnimation(animation); + }); + } + + private void toggleControls() { + if (controls.isFadeOutEnabled() && toolbar.getVisibility() == VISIBLE) { + fadeOutControls(); + } else { + fadeInControls(); + } + } + + private void fadeOutControls() { + fadeControls(ConstraintSet.GONE); + controlsListener.onControlsFadeOut(); + } + + private void fadeInControls() { + fadeControls(ConstraintSet.VISIBLE); + + scheduleFadeOut(); + } + + private void layoutParticipantsForSmallCount() { + pagerBottomMarginDp = 0; + + layoutParticipants(); + } + + private void layoutParticipantsForLargeCount() { + pagerBottomMarginDp = 104; + + layoutParticipants(); + } + + private int withControlsHeight(int margin) { + if (margin == 0) { + return 0; + } + + return controlsVisible ? margin + CONTROLS_HEIGHT : margin; + } + + private void layoutParticipants() { + Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS); + + TransitionManager.beginDelayedTransition(participantsParent, transition); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(participantsParent); + + constraintSet.setMargin(R.id.call_screen_participants_pager, ConstraintSet.BOTTOM, ViewUtil.dpToPx(withControlsHeight(pagerBottomMarginDp))); + constraintSet.applyTo(participantsParent); + } + + private void fadeControls(int visibility) { + controlsVisible = visibility == VISIBLE; + + Transition transition = new AutoTransition().setOrdering(TransitionSet.ORDERING_TOGETHER) + .setDuration(TRANSITION_DURATION_MILLIS); + + TransitionManager.endTransitions(parent); + + if (controlsListener != null) { + if (controlsVisible) { + controlsListener.showSystemUI(); + } else { + controlsListener.hideSystemUI(); + } + } + + TransitionManager.beginDelayedTransition(parent, transition); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(parent); + + for (View view : visibleViewSet) { + constraintSet.setVisibility(view.getId(), visibility); + } + + constraintSet.applyTo(parent); + + layoutParticipants(); + } + + private void fadeInNewUiState(@NonNull Set previouslyVisibleViewSet, boolean useSmallMargins) { + Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS); + + TransitionManager.endTransitions(parent); + TransitionManager.beginDelayedTransition(parent, transition); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(parent); + + for (View view : SetUtil.difference(previouslyVisibleViewSet, visibleViewSet)) { + constraintSet.setVisibility(view.getId(), ConstraintSet.GONE); + } + + for (View view : visibleViewSet) { + constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE); + + if (adjustableMarginsSet.contains(view)) { + constraintSet.setMargin(view.getId(), + ConstraintSet.END, + ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP + : LARGE_ONGOING_CALL_BUTTON_MARGIN_DP)); + } + } + + constraintSet.applyTo(parent); + } + + private void scheduleFadeOut() { + cancelFadeOut(); + + if (getHandler() == null) return; + getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY); + } + + private void cancelFadeOut() { + if (getHandler() == null) return; + getHandler().removeCallbacks(fadeOutRunnable); + } + + private static void runIfNonNull(@Nullable T listener, @NonNull Consumer listenerConsumer) { + if (listener != null) { + listenerConsumer.accept(listener); + } + } + + private void updateButtonStateForLargeButtons() { + cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle); + hangup.setImageResource(R.drawable.webrtc_call_screen_hangup); + micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle); + videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle); + audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle); + } + + private void updateButtonStateForSmallButtons() { + cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle_small); + hangup.setImageResource(R.drawable.webrtc_call_screen_hangup_small); + micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small); + videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small); + audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small); + } + + private boolean showParticipantsList() { + controlsListener.onShowParticipantsList(); + return true; + } + + public interface ControlsListener { + void onStartCall(boolean isVideoCall); + void onCancelStartCall(); + void onControlsFadeOut(); + void showSystemUI(); + void hideSystemUI(); + void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); + void onVideoChanged(boolean isVideoEnabled); + void onMicChanged(boolean isMicEnabled); + void onCameraDirectionChanged(); + void onEndCallPressed(); + void onDenyCallPressed(); + void onAcceptCallWithVoiceOnlyPressed(); + void onAcceptCallPressed(); + void onShowParticipantsList(); + void onPageChanged(@NonNull CallParticipantsState.SelectedPage page); + void onLocalPictureInPictureClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java new file mode 100644 index 00000000..2dac451b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -0,0 +1,430 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; +import org.thoughtcrime.securesms.components.sensors.Orientation; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class WebRtcCallViewModel extends ViewModel { + + private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); + private final MutableLiveData isInPipMode = new MutableLiveData<>(false); + private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); + private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls); + private final SingleLiveEvent events = new SingleLiveEvent(); + private final MutableLiveData elapsed = new MutableLiveData<>(-1L); + private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); + private final MutableLiveData participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE); + private final SingleLiveEvent callParticipantListUpdate = new SingleLiveEvent<>(); + private final MutableLiveData> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList()); + private final LiveData safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new); + private final LiveData groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup); + private final LiveData> groupMembers = LiveDataUtil.skip(Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())), 1); + private final LiveData shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint); + private final LiveData orientation; + + private boolean canDisplayTooltipIfNeeded = true; + private boolean hasEnabledLocalVideo = false; + private long callConnectedTime = -1; + private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper()); + private boolean answerWithVideoAvailable = false; + private Runnable elapsedTimeRunnable = this::handleTick; + private boolean canEnterPipMode = false; + private List previousParticipantsList = Collections.emptyList(); + private boolean callStarting = false; + + private final WebRtcCallRepository repository = new WebRtcCallRepository(ApplicationDependencies.getApplication()); + + private WebRtcCallViewModel(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) { + orientation = LiveDataUtil.combineLatest(deviceOrientationMonitor.getOrientation(), webRtcControls, (deviceOrientation, controls) -> { + if (controls.canRotateControls()) { + return deviceOrientation; + } else { + return Orientation.PORTRAIT_BOTTOM_EDGE; + } + }); + } + + public LiveData getOrientation() { + return Transformations.distinctUntilChanged(orientation); + } + + public LiveData getMicrophoneEnabled() { + return Transformations.distinctUntilChanged(microphoneEnabled); + } + + public LiveData getWebRtcControls() { + return realWebRtcControls; + } + + public LiveRecipient getRecipient() { + return liveRecipient.getValue(); + } + + public void setRecipient(@NonNull Recipient recipient) { + liveRecipient.setValue(recipient.live()); + } + + public LiveData getEvents() { + return events; + } + + public LiveData getCallTime() { + return Transformations.map(elapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + } + + public LiveData getCallParticipantsState() { + return participantsState; + } + + public LiveData getCallParticipantListUpdate() { + return callParticipantListUpdate; + } + + public LiveData getSafetyNumberChangeEvent() { + return safetyNumberChangeEvent; + } + + public LiveData> getGroupMembers() { + return groupMembers; + } + + public LiveData shouldShowSpeakerHint() { + return shouldShowSpeakerHint; + } + + public boolean canEnterPipMode() { + return canEnterPipMode; + } + + public boolean isAnswerWithVideoAvailable() { + return answerWithVideoAvailable; + } + + public boolean isCallStarting() { + return callStarting; + } + + @MainThread + public void setIsInPipMode(boolean isInPipMode) { + this.isInPipMode.setValue(isInPipMode); + + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode)); + } + + @MainThread + public void setIsViewingFocusedParticipant(@NonNull CallParticipantsState.SelectedPage page) { + if (page == CallParticipantsState.SelectedPage.FOCUSED) { + SignalStore.tooltips().markGroupCallSpeakerViewSeen(); + } + + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), page)); + } + + public void onLocalPictureInPictureClicked() { + CallParticipantsState state = participantsState.getValue(); + if (state.getGroupCallState() != WebRtcViewModel.GroupCallState.IDLE) { + return; + } + + participantsState.setValue(CallParticipantsState.setExpanded(participantsState.getValue(), + state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED)); + } + + public void onDismissedVideoTooltip() { + canDisplayTooltipIfNeeded = false; + } + + @MainThread + public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel, boolean enableVideo) { + canEnterPipMode = !webRtcViewModel.getState().isPreJoinOrNetworkUnavailable(); + if (callStarting && webRtcViewModel.getState().isPassedPreJoin()) { + callStarting = false; + } + + CallParticipant localParticipant = webRtcViewModel.getLocalParticipant(); + + microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled()); + + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), webRtcViewModel, enableVideo)); + + if (webRtcViewModel.getGroupState().isConnected()) { + if (!containsPlaceholders(previousParticipantsList)) { + CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantsList, webRtcViewModel.getRemoteParticipants()); + callParticipantListUpdate.setValue(update); + } + + previousParticipantsList = webRtcViewModel.getRemoteParticipants(); + + identityChangedRecipients.setValue(webRtcViewModel.getIdentityChangedParticipants()); + } + + updateWebRtcControls(webRtcViewModel.getState(), + webRtcViewModel.getGroupState(), + localParticipant.getCameraState().isEnabled(), + webRtcViewModel.isRemoteVideoEnabled(), + webRtcViewModel.isRemoteVideoOffer(), + localParticipant.isMoreThanOneCameraAvailable(), + webRtcViewModel.isBluetoothAvailable(), + Util.hasItems(webRtcViewModel.getRemoteParticipants()), + repository.getAudioOutput(), + webRtcViewModel.getRemoteDevicesCount().orElse(0), + webRtcViewModel.getParticipantLimit()); + + if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) { + callConnectedTime = webRtcViewModel.getCallConnectedTime(); + startTimer(); + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) { + cancelTimer(); + callConnectedTime = -1; + } + + if (localParticipant.getCameraState().isEnabled()) { + canDisplayTooltipIfNeeded = false; + hasEnabledLocalVideo = true; + events.setValue(new Event.DismissVideoTooltip()); + } + + // If remote video is enabled and we a) haven't shown our video and b) have not dismissed the popup + if (canDisplayTooltipIfNeeded && webRtcViewModel.isRemoteVideoEnabled() && !hasEnabledLocalVideo) { + canDisplayTooltipIfNeeded = false; + events.setValue(new Event.ShowVideoTooltip()); + } + } + + private boolean containsPlaceholders(@NonNull List callParticipants) { + return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID); + } + + private void updateWebRtcControls(@NonNull WebRtcViewModel.State state, + @NonNull WebRtcViewModel.GroupCallState groupState, + boolean isLocalVideoEnabled, + boolean isRemoteVideoEnabled, + boolean isRemoteVideoOffer, + boolean isMoreThanOneCameraAvailable, + boolean isBluetoothAvailable, + boolean hasAtLeastOneRemote, + @NonNull WebRtcAudioOutput audioOutput, + long remoteDevicesCount, + @Nullable Long participantLimit) + { + final WebRtcControls.CallState callState; + + switch (state) { + case CALL_PRE_JOIN: + callState = WebRtcControls.CallState.PRE_JOIN; + break; + case CALL_INCOMING: + callState = WebRtcControls.CallState.INCOMING; + answerWithVideoAvailable = isRemoteVideoOffer; + break; + case CALL_OUTGOING: + case CALL_RINGING: + callState = WebRtcControls.CallState.OUTGOING; + break; + case CALL_ACCEPTED_ELSEWHERE: + case CALL_DECLINED_ELSEWHERE: + case CALL_ONGOING_ELSEWHERE: + case CALL_NEEDS_PERMISSION: + case CALL_BUSY: + case CALL_DISCONNECTED: + callState = WebRtcControls.CallState.ENDING; + break; + case NETWORK_FAILURE: + callState = WebRtcControls.CallState.ERROR; + break; + default: + callState = WebRtcControls.CallState.ONGOING; + } + + final WebRtcControls.GroupCallState groupCallState; + + switch (groupState) { + case DISCONNECTED: + groupCallState = WebRtcControls.GroupCallState.DISCONNECTED; + break; + case CONNECTING: + case RECONNECTING: + groupCallState = (participantLimit == null || remoteDevicesCount < participantLimit) ? WebRtcControls.GroupCallState.CONNECTING + : WebRtcControls.GroupCallState.FULL; + break; + case CONNECTED: + case CONNECTED_AND_JOINING: + case CONNECTED_AND_JOINED: + groupCallState = WebRtcControls.GroupCallState.CONNECTED; + break; + default: + groupCallState = WebRtcControls.GroupCallState.NONE; + break; + } + + webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled, + isRemoteVideoEnabled || isRemoteVideoOffer, + isMoreThanOneCameraAvailable, + isBluetoothAvailable, + Boolean.TRUE.equals(isInPipMode.getValue()), + hasAtLeastOneRemote, + callState, + groupCallState, + audioOutput, + participantLimit)); + } + + private @NonNull WebRtcControls getRealWebRtcControls(boolean isInPipMode, @NonNull WebRtcControls controls) { + return isInPipMode ? WebRtcControls.PIP : controls; + } + + private boolean shouldShowSpeakerHint(@NonNull CallParticipantsState state) { + return !state.isInPipMode() && + state.getRemoteDevicesCount().orElse(0) > 1 && + state.getGroupCallState().isConnected() && + !SignalStore.tooltips().hasSeenGroupCallSpeakerView(); + } + + private void startTimer() { + cancelTimer(); + + elapsedTimeHandler.post(elapsedTimeRunnable); + } + + private void handleTick() { + if (callConnectedTime == -1) { + return; + } + + long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000; + + elapsed.postValue(newValue); + + elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000); + } + + private void cancelTimer() { + elapsedTimeHandler.removeCallbacks(elapsedTimeRunnable); + } + + @Override + protected void onCleared() { + super.onCleared(); + cancelTimer(); + } + + public void startCall(boolean isVideoCall) { + callStarting = true; + Recipient recipient = getRecipient().get(); + if (recipient.isGroup()) { + repository.getIdentityRecords(recipient, identityRecords -> { + if (identityRecords.isUntrusted(false) || identityRecords.isUnverified(false)) { + List records = identityRecords.getUnverifiedRecords(); + records.addAll(identityRecords.getUntrustedRecords()); + events.postValue(new Event.ShowGroupCallSafetyNumberChange(records)); + } else { + events.postValue(new Event.StartCall(isVideoCall)); + } + }); + } else { + events.postValue(new Event.StartCall(isVideoCall)); + } + } + + public static abstract class Event { + private Event() { + } + + public static class ShowVideoTooltip extends Event { + } + + public static class DismissVideoTooltip extends Event { + } + + public static class StartCall extends Event { + private final boolean isVideoCall; + + public StartCall(boolean isVideoCall) { + this.isVideoCall = isVideoCall; + } + + public boolean isVideoCall() { + return isVideoCall; + } + } + + public static class ShowGroupCallSafetyNumberChange extends Event { + private final List identityRecords; + + public ShowGroupCallSafetyNumberChange(@NonNull List identityRecords) { + this.identityRecords = identityRecords; + } + + public @NonNull List getIdentityRecords() { + return identityRecords; + } + } + } + + public static class SafetyNumberChangeEvent { + private final boolean isInPipMode; + private final Collection recipientIds; + + private SafetyNumberChangeEvent(boolean isInPipMode, @NonNull Collection recipientIds) { + this.isInPipMode = isInPipMode; + this.recipientIds = recipientIds; + } + + public boolean isInPipMode() { + return isInPipMode; + } + + public @NonNull Collection getRecipientIds() { + return recipientIds; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final DeviceOrientationMonitor deviceOrientationMonitor; + + public Factory(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) { + this.deviceOrientationMonitor = deviceOrientationMonitor; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new WebRtcCallViewModel(deviceOrientationMonitor))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java new file mode 100644 index 00000000..7ef3732d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java @@ -0,0 +1,206 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +public final class WebRtcControls { + + public static final WebRtcControls NONE = new WebRtcControls(); + public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null); + + private final boolean isRemoteVideoEnabled; + private final boolean isLocalVideoEnabled; + private final boolean isMoreThanOneCameraAvailable; + private final boolean isBluetoothAvailable; + private final boolean isInPipMode; + private final boolean hasAtLeastOneRemote; + private final CallState callState; + private final GroupCallState groupCallState; + private final WebRtcAudioOutput audioOutput; + private final Long participantLimit; + + private WebRtcControls() { + this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null); + } + + WebRtcControls(boolean isLocalVideoEnabled, + boolean isRemoteVideoEnabled, + boolean isMoreThanOneCameraAvailable, + boolean isBluetoothAvailable, + boolean isInPipMode, + boolean hasAtLeastOneRemote, + @NonNull CallState callState, + @NonNull GroupCallState groupCallState, + @NonNull WebRtcAudioOutput audioOutput, + @Nullable Long participantLimit) + { + this.isLocalVideoEnabled = isLocalVideoEnabled; + this.isRemoteVideoEnabled = isRemoteVideoEnabled; + this.isBluetoothAvailable = isBluetoothAvailable; + this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable; + this.isInPipMode = isInPipMode; + this.hasAtLeastOneRemote = hasAtLeastOneRemote; + this.callState = callState; + this.groupCallState = groupCallState; + this.audioOutput = audioOutput; + this.participantLimit = participantLimit; + } + + boolean canRotateControls() { + return !isGroupCall(); + } + + boolean displayErrorControls() { + return isError(); + } + + boolean displayStartCallControls() { + return isPreJoin(); + } + + @StringRes int getStartCallButtonText() { + if (isGroupCall()) { + if (groupCallState == GroupCallState.FULL) { + return R.string.WebRtcCallView__call_is_full; + } else if (hasAtLeastOneRemote) { + return R.string.WebRtcCallView__join_call; + } + } + return R.string.WebRtcCallView__start_call; + } + + boolean isStartCallEnabled() { + return groupCallState != GroupCallState.FULL; + } + + boolean displayGroupCallFull() { + return groupCallState == GroupCallState.FULL; + } + + @NonNull String getGroupCallFullMessage(@NonNull Context context) { + if (participantLimit != null) { + return context.getString(R.string.WebRtcCallView__the_maximum_number_of_d_participants_has_been_Reached_for_this_call, participantLimit); + } + return ""; + } + + boolean displayGroupMembersButton() { + return groupCallState.isAtLeast(GroupCallState.CONNECTING); + } + + boolean displayEndCall() { + return isAtLeastOutgoing(); + } + + boolean displayMuteAudio() { + return isPreJoin() || isAtLeastOutgoing(); + } + + boolean displayVideoToggle() { + return isPreJoin() || isAtLeastOutgoing(); + } + + boolean displayAudioToggle() { + return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothAvailable); + } + + boolean displayCameraToggle() { + return (isPreJoin() || isAtLeastOutgoing()) && isLocalVideoEnabled && isMoreThanOneCameraAvailable; + } + + boolean displayRemoteVideoRecycler() { + return isOngoing(); + } + + boolean displayAnswerWithAudio() { + return isIncoming() && isRemoteVideoEnabled; + } + + boolean displayIncomingCallButtons() { + return isIncoming(); + } + + boolean enableHandsetInAudioToggle() { + return !isLocalVideoEnabled; + } + + boolean enableHeadsetInAudioToggle() { + return isBluetoothAvailable; + } + + boolean isFadeOutEnabled() { + return isAtLeastOutgoing() && isRemoteVideoEnabled; + } + + boolean displaySmallOngoingCallButtons() { + return isAtLeastOutgoing() && displayAudioToggle() && displayCameraToggle(); + } + + boolean displayLargeOngoingCallButtons() { + return isAtLeastOutgoing() && !(displayAudioToggle() && displayCameraToggle()); + } + + boolean displayTopViews() { + return !isInPipMode; + } + + @NonNull WebRtcAudioOutput getAudioOutput() { + return audioOutput; + } + + private boolean isError() { + return callState == CallState.ERROR; + } + + private boolean isPreJoin() { + return callState == CallState.PRE_JOIN; + } + + private boolean isOngoing() { + return callState == CallState.ONGOING; + } + + private boolean isIncoming() { + return callState == CallState.INCOMING; + } + + private boolean isAtLeastOutgoing() { + return callState.isAtLeast(CallState.OUTGOING); + } + + private boolean isGroupCall() { + return groupCallState != GroupCallState.NONE; + } + + public enum CallState { + NONE, + ERROR, + PRE_JOIN, + INCOMING, + OUTGOING, + ONGOING, + ENDING; + + boolean isAtLeast(@SuppressWarnings("SameParameterValue") @NonNull CallState other) { + return compareTo(other) >= 0; + } + } + + public enum GroupCallState { + NONE, + DISCONNECTED, + RECONNECTING, + CONNECTING, + FULL, + CONNECTED; + + boolean isAtLeast(@SuppressWarnings("SameParameterValue") @NonNull GroupCallState other) { + return compareTo(other) >= 0; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java new file mode 100644 index 00000000..f23df83e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.components.webrtc; + +public enum WebRtcLocalRenderState { + GONE, + SMALL_RECTANGLE, + SMALLER_RECTANGLE, + LARGE, + LARGE_NO_VIDEO, + EXPANDED +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java new file mode 100644 index 00000000..5d37f442 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; + +public class CallParticipantViewHolder extends RecipientViewHolder { + + private final ImageView videoMuted; + private final ImageView audioMuted; + + public CallParticipantViewHolder(@NonNull View itemView) { + super(itemView, null); + + videoMuted = itemView.findViewById(R.id.call_participant_video_muted); + audioMuted = itemView.findViewById(R.id.call_participant_audio_muted); + } + + @Override + public void bind(@NonNull CallParticipantViewState model) { + super.bind(model); + + videoMuted.setVisibility(model.getVideoMutedVisibility()); + audioMuted.setVisibility(model.getAudioMutedVisibility()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java new file mode 100644 index 00000000..b8c22b18 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +public final class CallParticipantViewState extends RecipientMappingModel { + + private final CallParticipant callParticipant; + + CallParticipantViewState(@NonNull CallParticipant callParticipant) { + this.callParticipant = callParticipant; + } + + @Override + public @NonNull Recipient getRecipient() { + return callParticipant.getRecipient(); + } + + @Override + public @NonNull String getName(@NonNull Context context) { + return callParticipant.getRecipientDisplayName(context); + } + + public int getVideoMutedVisibility() { + return callParticipant.isVideoEnabled() ? View.GONE : View.VISIBLE; + } + + public int getAudioMutedVisibility() { + return callParticipant.isMicrophoneEnabled() ? View.GONE : View.VISIBLE; + } + + @Override + public boolean areItemsTheSame(@NonNull CallParticipantViewState newItem) { + return callParticipant.getCallParticipantId().equals(newItem.callParticipant.getCallParticipantId()); + } + + @Override + public boolean areContentsTheSame(@NonNull CallParticipantViewState newItem) { + return super.areContentsTheSame(newItem) && + callParticipant.isVideoEnabled() == newItem.callParticipant.isVideoEnabled() && + callParticipant.isMicrophoneEnabled() == newItem.callParticipant.isMicrophoneEnabled(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java new file mode 100644 index 00000000..33002292 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +public class CallParticipantsListAdapter extends MappingAdapter { + + CallParticipantsListAdapter() { + registerFactory(CallParticipantsListHeader.class, new LayoutFactory<>(CallParticipantsListHeaderViewHolder::new, R.layout.call_participants_list_header)); + registerFactory(CallParticipantViewState.class, new LayoutFactory<>(CallParticipantViewHolder::new, R.layout.call_participants_list_item)); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java new file mode 100644 index 00000000..05a19ef4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.os.Bundle; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.OptionalLong; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; +import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.MappingModel; + +import java.util.ArrayList; +import java.util.List; + +public class CallParticipantsListDialog extends BottomSheetDialogFragment { + + private RecyclerView participantList; + private CallParticipantsListAdapter adapter; + + public static void show(@NonNull FragmentManager manager) { + CallParticipantsListDialog fragment = new CallParticipantsListDialog(); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + public static void dismiss(@NonNull FragmentManager manager) { + Fragment fragment = manager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + if (fragment instanceof CallParticipantsListDialog) { + ((CallParticipantsListDialog) fragment).dismissAllowingStateLoss(); + } + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet); + super.onCreate(savedInstanceState); + } + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + ContextThemeWrapper contextThemeWrapper = new ContextThemeWrapper(inflater.getContext(), R.style.TextSecure_DarkTheme); + LayoutInflater themedInflater = LayoutInflater.from(contextThemeWrapper); + + participantList = (RecyclerView) themedInflater.inflate(R.layout.call_participants_list_dialog, container, false); + + return participantList; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final WebRtcCallViewModel viewModel = ViewModelProviders.of(requireActivity()).get(WebRtcCallViewModel.class); + + initializeList(); + + viewModel.getCallParticipantsState().observe(getViewLifecycleOwner(), this::updateList); + } + + private void initializeList() { + adapter = new CallParticipantsListAdapter(); + + participantList.setLayoutManager(new LinearLayoutManager(requireContext())); + participantList.setAdapter(adapter); + } + + private void updateList(@NonNull CallParticipantsState callParticipantsState) { + List> items = new ArrayList<>(); + + boolean includeSelf = callParticipantsState.getGroupCallState() == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; + OptionalLong headerCount = callParticipantsState.getParticipantCount(); + + headerCount.executeIfPresent(count -> { + items.add(new CallParticipantsListHeader((int) count)); + + if (includeSelf) { + items.add(new CallParticipantViewState(callParticipantsState.getLocalParticipant())); + } + + for (CallParticipant callParticipant : callParticipantsState.getAllRemoteParticipants()) { + items.add(new CallParticipantViewState(callParticipant)); + } + }); + + adapter.submitList(items); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java new file mode 100644 index 00000000..5e46b446 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingModel; + +public class CallParticipantsListHeader implements MappingModel { + + private int participantCount; + + public CallParticipantsListHeader(int participantCount) { + this.participantCount = participantCount; + } + + @NonNull String getHeader(@NonNull Context context) { + return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call_d_people, participantCount, participantCount); + } + + @Override + public boolean areItemsTheSame(@NonNull CallParticipantsListHeader newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull CallParticipantsListHeader newItem) { + return participantCount == newItem.participantCount; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java new file mode 100644 index 00000000..3d5ec7c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class CallParticipantsListHeaderViewHolder extends MappingViewHolder { + + private final TextView headerText; + + public CallParticipantsListHeaderViewHolder(@NonNull View itemView) { + super(itemView); + headerText = findViewById(R.id.call_participants_list_header); + } + + @Override + public void bind(@NonNull CallParticipantsListHeader model) { + headerText.setText(model.getHeader(getContext())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ArrayListCursor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ArrayListCursor.java new file mode 100644 index 00000000..498db269 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ArrayListCursor.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.contacts; +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.database.AbstractCursor; +import android.database.CursorWindow; + +import java.util.ArrayList; + +/** + * A convenience class that presents a two-dimensional ArrayList + * as a Cursor. + */ +public class ArrayListCursor extends AbstractCursor { + private String[] mColumnNames; + private ArrayList[] mRows; + + @SuppressWarnings({"unchecked"}) + public ArrayListCursor(String[] columnNames, ArrayList rows) { + int colCount = columnNames.length; + boolean foundID = false; + // Add an _id column if not in columnNames + for (int i = 0; i < colCount; ++i) { + if (columnNames[i].compareToIgnoreCase("_id") == 0) { + mColumnNames = columnNames; + foundID = true; + break; + } + } + + if (!foundID) { + mColumnNames = new String[colCount + 1]; + System.arraycopy(columnNames, 0, mColumnNames, 0, columnNames.length); + mColumnNames[colCount] = "_id"; + } + + int rowCount = rows.size(); + mRows = new ArrayList[rowCount]; + + for (int i = 0; i < rowCount; ++i) { + mRows[i] = rows.get(i); + if (!foundID) { + mRows[i].add(i); + } + } + } + + @Override + public void fillWindow(int position, CursorWindow window) { + if (position < 0 || position > getCount()) { + return; + } + + window.acquireReference(); + try { + int oldpos = mPos; + mPos = position - 1; + window.clear(); + window.setStartPosition(position); + int columnNum = getColumnCount(); + window.setNumColumns(columnNum); + while (moveToNext() && window.allocRow()) { + for (int i = 0; i < columnNum; i++) { + final Object data = mRows[mPos].get(i); + if (data != null) { + if (data instanceof byte[]) { + byte[] field = (byte[]) data; + if (!window.putBlob(field, mPos, i)) { + window.freeLastRow(); + break; + } + } else { + String field = data.toString(); + if (!window.putString(field, mPos, i)) { + window.freeLastRow(); + break; + } + } + } else { + if (!window.putNull(mPos, i)) { + window.freeLastRow(); + break; + } + } + } + } + + mPos = oldpos; + } catch (IllegalStateException e){ + // simply ignore it + } finally { + window.releaseReference(); + } + } + + @Override + public int getCount() { + return mRows.length; + } + + public boolean deleteRow() { + return false; + } + + @Override + public String[] getColumnNames() { + return mColumnNames; + } + + @Override + public byte[] getBlob(int columnIndex) { + return (byte[]) mRows[mPos].get(columnIndex); + } + + @Override + public String getString(int columnIndex) { + Object cell = mRows[mPos].get(columnIndex); + return (cell == null) ? null : cell.toString(); + } + + @Override + public short getShort(int columnIndex) { + Number num = (Number) mRows[mPos].get(columnIndex); + return num.shortValue(); + } + + @Override + public int getInt(int columnIndex) { + Number num = (Number) mRows[mPos].get(columnIndex); + return num.intValue(); + } + + @Override + public long getLong(int columnIndex) { + Number num = (Number) mRows[mPos].get(columnIndex); + return num.longValue(); + } + + @Override + public float getFloat(int columnIndex) { + Number num = (Number) mRows[mPos].get(columnIndex); + return num.floatValue(); + } + + @Override + public double getDouble(int columnIndex) { + Number num = (Number) mRows[mPos].get(columnIndex); + return num.doubleValue(); + } + + @Override + public boolean isNull(int columnIndex) { + return mRows[mPos].get(columnIndex) == null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java new file mode 100644 index 00000000..aa104dc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -0,0 +1,378 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.MergeCursor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.PhoneLookup; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; + +/** + * This class was originally a layer of indirection between + * ContactAccessorNewApi and ContactAccessorOldApi, which corresponded + * to the API changes between 1.x and 2.x. + * + * Now that we no longer support 1.x, this class mostly serves as a place + * to encapsulate Contact-related logic. It's still a singleton, mostly + * just because that's how it's currently called from everywhere. + * + * @author Moxie Marlinspike + */ + +public class ContactAccessor { + + public static final String PUSH_COLUMN = "push"; + + private static final ContactAccessor instance = new ContactAccessor(); + + public static synchronized ContactAccessor getInstance() { + return instance; + } + + public Set getAllContactsWithNumbers(Context context) { + Set results = new HashSet<>(); + + try (Cursor cursor = context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER}, null ,null, null)) { + while (cursor != null && cursor.moveToNext()) { + if (!TextUtils.isEmpty(cursor.getString(0))) { + results.add(PhoneNumberFormatter.get(context).format(cursor.getString(0))); + } + } + } + + return results; + } + + public Cursor getAllSystemContacts(Context context) { + return context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LABEL, Phone.PHOTO_URI, Phone._ID, Phone.LOOKUP_KEY, Phone.TYPE}, null, null, null); + } + + public boolean isSystemContact(Context context, String number) { + Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); + String[] projection = new String[]{PhoneLookup.DISPLAY_NAME, PhoneLookup.LOOKUP_KEY, + PhoneLookup._ID, PhoneLookup.NUMBER}; + Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); + + try { + if (cursor != null && cursor.moveToFirst()) { + return true; + } + } finally { + if (cursor != null) cursor.close(); + } + + return false; + } + + public Collection getContactsWithPush(Context context) { + final ContentResolver resolver = context.getContentResolver(); + final String[] inProjection = new String[]{PhoneLookup._ID, PhoneLookup.DISPLAY_NAME}; + + final List registeredAddresses = Stream.of(DatabaseFactory.getRecipientDatabase(context).getRegistered()) + .map(Recipient::resolved) + .filter(r -> r.getE164().isPresent()) + .map(Recipient::requireE164) + .toList(); + final Collection lookupData = new ArrayList<>(registeredAddresses.size()); + + for (String registeredAddress : registeredAddresses) { + Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(registeredAddress)); + Cursor lookupCursor = resolver.query(uri, inProjection, null, null, null); + + try { + if (lookupCursor != null && lookupCursor.moveToFirst()) { + final ContactData contactData = new ContactData(lookupCursor.getLong(0), lookupCursor.getString(1)); + contactData.numbers.add(new NumberData("TextSecure", registeredAddress)); + lookupData.add(contactData); + } + } finally { + if (lookupCursor != null) + lookupCursor.close(); + } + } + + return lookupData; + } + + public String getNameFromContact(Context context, Uri uri) { + Cursor cursor = null; + + try { + cursor = context.getContentResolver().query(uri, new String[] {Contacts.DISPLAY_NAME}, + null, null, null); + + if (cursor != null && cursor.moveToFirst()) + return cursor.getString(0); + + } finally { + if (cursor != null) + cursor.close(); + } + + return null; + } + + public ContactData getContactData(Context context, Uri uri) { + return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment())); + } + + private ContactData getContactData(Context context, String displayName, long id) { + ContactData contactData = new ContactData(id, displayName); + Cursor numberCursor = null; + + try { + numberCursor = context.getContentResolver().query(Phone.CONTENT_URI, null, + Phone.CONTACT_ID + " = ?", + new String[] {contactData.id + ""}, null); + + while (numberCursor != null && numberCursor.moveToNext()) { + int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE)); + String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL)); + String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER)); + String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString(); + + contactData.numbers.add(new NumberData(typeLabel, number)); + } + } finally { + if (numberCursor != null) + numberCursor.close(); + } + + return contactData; + } + + public List getNumbersForThreadSearchFilter(Context context, String constraint) { + LinkedList numberList = new LinkedList<>(); + + try (Cursor cursor = DatabaseFactory.getRecipientDatabase(context).queryAllContacts(constraint)) { + while (cursor != null && cursor.moveToNext()) { + String phone = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.PHONE)); + String email = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.EMAIL)); + + numberList.add(Util.getFirstNonEmpty(phone, email)); + } + } + + GroupDatabase.Reader reader = null; + GroupRecord record; + + try { + reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(constraint, true, false); + + while ((record = reader.getNext()) != null) { + numberList.add(record.getId().toString()); + } + } finally { + if (reader != null) + reader.close(); + } + + if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && + !numberList.contains(TextSecurePreferences.getLocalNumber(context))) + { + numberList.add(TextSecurePreferences.getLocalNumber(context)); + } + + return numberList; + } + + public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) { + return Phone.getTypeLabel(mContext.getResources(), type, label); + } + + public static class NumberData implements Parcelable { + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public NumberData createFromParcel(Parcel in) { + return new NumberData(in); + } + + public NumberData[] newArray(int size) { + return new NumberData[size]; + } + }; + + public final String number; + public final String type; + + public NumberData(String type, String number) { + this.type = type; + this.number = number; + } + + public NumberData(Parcel in) { + number = in.readString(); + type = in.readString(); + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(number); + dest.writeString(type); + } + } + + public static class ContactData implements Parcelable { + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public ContactData createFromParcel(Parcel in) { + return new ContactData(in); + } + + public ContactData[] newArray(int size) { + return new ContactData[size]; + } + }; + + public final long id; + public final String name; + public final List numbers; + + public ContactData(long id, String name) { + this.id = id; + this.name = name; + this.numbers = new LinkedList(); + } + + public ContactData(Parcel in) { + id = in.readLong(); + name = in.readString(); + numbers = new LinkedList(); + in.readTypedList(numbers, NumberData.CREATOR); + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(name); + dest.writeTypedList(numbers); + } + } + + /*** + * If the code below looks shitty to you, that's because it was taken + * directly from the Android source, where shitty code is all you get. + */ + + public Cursor getCursorForRecipientFilter(CharSequence constraint, + ContentResolver mContentResolver) + { + final String SORT_ORDER = Contacts.TIMES_CONTACTED + " DESC," + + Contacts.DISPLAY_NAME + "," + + Contacts.Data.IS_SUPER_PRIMARY + " DESC," + + Phone.TYPE; + + final String[] PROJECTION_PHONE = { + Phone._ID, // 0 + Phone.CONTACT_ID, // 1 + Phone.TYPE, // 2 + Phone.NUMBER, // 3 + Phone.LABEL, // 4 + Phone.DISPLAY_NAME, // 5 + }; + + String phone = ""; + String cons = null; + + if (constraint != null) { + cons = constraint.toString(); + + if (RecipientsAdapter.usefulAsDigits(cons)) { + phone = PhoneNumberUtils.convertKeypadLettersToDigits(cons); + if (phone.equals(cons) && !PhoneNumberUtils.isWellFormedSmsAddress(phone)) { + phone = ""; + } else { + phone = phone.trim(); + } + } + } + Uri uri = Uri.withAppendedPath(Phone.CONTENT_FILTER_URI, Uri.encode(cons)); + String selection = String.format("%s=%s OR %s=%s OR %s=%s", + Phone.TYPE, + Phone.TYPE_MOBILE, + Phone.TYPE, + Phone.TYPE_WORK_MOBILE, + Phone.TYPE, + Phone.TYPE_MMS); + + Cursor phoneCursor = mContentResolver.query(uri, + PROJECTION_PHONE, + null, + null, + SORT_ORDER); + + if (phone.length() > 0) { + ArrayList result = new ArrayList(); + result.add(Integer.valueOf(-1)); // ID + result.add(Long.valueOf(-1)); // CONTACT_ID + result.add(Integer.valueOf(Phone.TYPE_CUSTOM)); // TYPE + result.add(phone); // NUMBER + + /* + * The "\u00A0" keeps Phone.getDisplayLabel() from deciding + * to display the default label ("Home") next to the transformation + * of the letters into numbers. + */ + result.add("\u00A0"); // LABEL + result.add(cons); // NAME + + ArrayList wrap = new ArrayList(); + wrap.add(result); + + ArrayListCursor translated = new ArrayListCursor(PROJECTION_PHONE, wrap); + + return new MergeCursor(new Cursor[] { translated, phoneCursor }); + } else { + return phoneCursor; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChip.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChip.java new file mode 100644 index 00000000..7a5b36d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChip.java @@ -0,0 +1,124 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.android.material.chip.Chip; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +public final class ContactChip extends Chip { + + @Nullable private SelectedContact contact; + + public ContactChip(Context context) { + super(context); + } + + public ContactChip(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ContactChip(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setContact(@NonNull SelectedContact contact) { + this.contact = contact; + } + + public @Nullable SelectedContact getContact() { + return contact; + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @Nullable Runnable onAvatarSet) { + if (recipient != null) { + requestManager.clear(this); + + Drawable fallbackContactPhotoDrawable = new HalfScaleDrawable(recipient.getFallbackContactPhotoDrawable(getContext(), false)); + ContactPhoto contactPhoto = recipient.getContactPhoto(); + + if (contactPhoto == null) { + setChipIcon(fallbackContactPhotoDrawable); + if (onAvatarSet != null) { + onAvatarSet.run(); + } + } else { + requestManager.load(contactPhoto) + .placeholder(fallbackContactPhotoDrawable) + .fallback(fallbackContactPhotoDrawable) + .error(fallbackContactPhotoDrawable) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + setChipIcon(resource); + if (onAvatarSet != null) { + onAvatarSet.run(); + } + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + setChipIcon(placeholder); + } + }); + } + } + } + + private static class HalfScaleDrawable extends Drawable { + + private final Drawable fallbackContactPhotoDrawable; + + HalfScaleDrawable(Drawable fallbackContactPhotoDrawable) { + this.fallbackContactPhotoDrawable = fallbackContactPhotoDrawable; + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + fallbackContactPhotoDrawable.setBounds(left, top, 2 * right - left, 2 * bottom - top); + } + + @Override + public void setBounds(@NonNull Rect bounds) { + super.setBounds(bounds); + } + + @Override + public void draw(@NonNull Canvas canvas) { + canvas.save(); + canvas.scale(0.5f, 0.5f); + fallbackContactPhotoDrawable.draw(canvas); + canvas.restore(); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManager.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManager.java new file mode 100644 index 00000000..27b9ec84 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManager.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.net.Uri; + +import java.util.List; + +public abstract class ContactIdentityManager { + + public static ContactIdentityManager getInstance(Context context) { + return new ContactIdentityManagerICS(context); + } + + protected final Context context; + + public ContactIdentityManager(Context context) { + this.context = context.getApplicationContext(); + } + + public abstract Uri getSelfIdentityUri(); + public abstract boolean isSelfIdentityAutoDetected(); + public abstract List getSelfIdentityRawContactIds(); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManagerICS.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManagerICS.java new file mode 100644 index 00000000..c81bacfd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactIdentityManagerICS.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.PhoneLookup; + +import java.util.LinkedList; +import java.util.List; + +class ContactIdentityManagerICS extends ContactIdentityManager { + + public ContactIdentityManagerICS(Context context) { + super(context); + } + + @Override + public Uri getSelfIdentityUri() { + String[] PROJECTION = new String[] { + PhoneLookup.DISPLAY_NAME, + PhoneLookup.LOOKUP_KEY, + PhoneLookup._ID, + }; + + Cursor cursor = null; + + try { + cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_URI, + PROJECTION, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + return Contacts.getLookupUri(cursor.getLong(2), cursor.getString(1)); + } + } finally { + if (cursor != null) + cursor.close(); + } + + return null; + } + + @Override + public boolean isSelfIdentityAutoDetected() { + return true; + } + + @Override + public List getSelfIdentityRawContactIds() { + List results = new LinkedList(); + + String[] PROJECTION = new String[] { + ContactsContract.Profile._ID + }; + + Cursor cursor = null; + + try { + cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI, + PROJECTION, null, null, null); + + if (cursor == null || cursor.getCount() == 0) + return null; + + while (cursor.moveToNext()) { + results.add(cursor.getLong(0)); + } + + return results; + } finally { + if (cursor != null) + cursor.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java new file mode 100644 index 00000000..3696efdc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -0,0 +1,212 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Repository for all contacts. Allows you to filter them via queries. + * + * Currently this is implemented to return cursors. This is to ease the migration between this class + * and the previous way we'd query contacts: {@link ContactsDatabase}. It's much easier in the + * short-term to mock the cursor interface rather than try to switch everything over to models. + */ +public class ContactRepository { + + private final RecipientDatabase recipientDatabase; + private final String noteToSelfTitle; + private final Context context; + + public static final String ID_COLUMN = "id"; + static final String NAME_COLUMN = "name"; + static final String NUMBER_COLUMN = "number"; + static final String NUMBER_TYPE_COLUMN = "number_type"; + static final String LABEL_COLUMN = "label"; + static final String CONTACT_TYPE_COLUMN = "contact_type"; + static final String ABOUT_COLUMN = "about"; + + static final int NORMAL_TYPE = 0; + static final int PUSH_TYPE = 1 << 0; + static final int NEW_PHONE_TYPE = 1 << 2; + static final int NEW_USERNAME_TYPE = 1 << 3; + static final int RECENT_TYPE = 1 << 4; + static final int DIVIDER_TYPE = 1 << 5; + + /** Maps the recipient results to the legacy contact column names */ + private static final List> SEARCH_CURSOR_MAPPERS = new ArrayList>() {{ + add(new Pair<>(ID_COLUMN, cursor -> CursorUtil.requireLong(cursor, RecipientDatabase.ID))); + + add(new Pair<>(NAME_COLUMN, cursor -> { + String system = CursorUtil.requireString(cursor, RecipientDatabase.SYSTEM_DISPLAY_NAME); + String profile = CursorUtil.requireString(cursor, RecipientDatabase.SEARCH_PROFILE_NAME); + + return Util.getFirstNonEmpty(system, profile); + })); + + add(new Pair<>(NUMBER_COLUMN, cursor -> { + String phone = CursorUtil.requireString(cursor, RecipientDatabase.PHONE); + String email = CursorUtil.requireString(cursor, RecipientDatabase.EMAIL); + + if (phone != null) { + phone = PhoneNumberFormatter.prettyPrint(phone); + } + + return Util.getFirstNonEmpty(phone, email); + })); + + add(new Pair<>(NUMBER_TYPE_COLUMN, cursor -> CursorUtil.requireInt(cursor, RecipientDatabase.SYSTEM_PHONE_TYPE))); + + add(new Pair<>(LABEL_COLUMN, cursor -> CursorUtil.requireString(cursor, RecipientDatabase.SYSTEM_PHONE_LABEL))); + + add(new Pair<>(CONTACT_TYPE_COLUMN, cursor -> { + int registered = CursorUtil.requireInt(cursor, RecipientDatabase.REGISTERED); + return registered == RecipientDatabase.RegisteredState.REGISTERED.getId() ? PUSH_TYPE : NORMAL_TYPE; + })); + + add(new Pair<>(ABOUT_COLUMN, cursor -> { + String aboutEmoji = CursorUtil.requireString(cursor, RecipientDatabase.ABOUT_EMOJI); + String about = CursorUtil.requireString(cursor, RecipientDatabase.ABOUT); + + if (!Util.isEmpty(aboutEmoji)) { + if (!Util.isEmpty(about)) { + return aboutEmoji + " " + about; + } else { + return aboutEmoji; + } + } else if (!Util.isEmpty(about)) { + return about; + } else { + return ""; + } + })); + }}; + + public ContactRepository(@NonNull Context context) { + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.noteToSelfTitle = context.getString(R.string.note_to_self); + this.context = context.getApplicationContext(); + } + + @WorkerThread + public Cursor querySignalContacts(@NonNull String query) { + return querySignalContacts(query, true); + } + + @WorkerThread + public Cursor querySignalContacts(@NonNull String query, boolean includeSelf) { + Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf) + : recipientDatabase.querySignalContacts(query, includeSelf); + + if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) { + Recipient self = Recipient.self(); + boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase()); + boolean numberMatch = self.getE164().isPresent() && self.requireE164().contains(query); + boolean shouldAdd = !nameMatch && !numberMatch; + + if (shouldAdd) { + MatrixCursor selfCursor = new MatrixCursor(RecipientDatabase.SEARCH_PROJECTION_NAMES); + selfCursor.addRow(new Object[]{ self.getId().serialize(), noteToSelfTitle, self.getE164().or(""), self.getEmail().orNull(), null, -1, RecipientDatabase.RegisteredState.REGISTERED.getId(), self.getAbout(), self.getAboutEmoji(), noteToSelfTitle, noteToSelfTitle }); + + cursor = cursor == null ? selfCursor : new MergeCursor(new Cursor[]{ cursor, selfCursor }); + } + } + + return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS); + } + + @WorkerThread + public Cursor queryNonSignalContacts(@NonNull String query) { + Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getNonSignalContacts() + : recipientDatabase.queryNonSignalContacts(query); + return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS); + } + + + /** + * This lets us mock the legacy cursor interface while using the new cursor, even though the data + * doesn't quite match up exactly. + */ + private static class SearchCursorWrapper extends CursorWrapper { + + private final Cursor wrapped; + private final String[] columnNames; + private final List> mappers; + private final Map positions; + + SearchCursorWrapper(Cursor cursor, @NonNull List> mappers) { + super(cursor); + + this.wrapped = cursor; + this.mappers = mappers; + this.positions = new HashMap<>(); + this.columnNames = new String[mappers.size()]; + + for (int i = 0; i < mappers.size(); i++) { + Pair pair = mappers.get(i); + + positions.put(pair.first(), i); + columnNames[i] = pair.first(); + } + } + + @Override + public int getColumnCount() { + return mappers.size(); + } + + @Override + public String[] getColumnNames() { + return columnNames; + } + + @Override + public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { + Integer index = positions.get(columnName); + + if (index != null) { + return index; + } else { + throw new IllegalArgumentException(); + } + } + + @Override + public String getString(int columnIndex) { + return String.valueOf(mappers.get(columnIndex).second().get(wrapped)); + } + + @Override + public int getInt(int columnIndex) { + return (int) mappers.get(columnIndex).second().get(wrapped); + } + + @Override + public long getLong(int columnIndex) { + return (long) mappers.get(columnIndex).second().get(wrapped); + } + } + + private interface ValueMapper { + T get(@NonNull Cursor cursor); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java new file mode 100644 index 00000000..be38fdd9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -0,0 +1,350 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.database.Cursor; +import android.provider.ContactsContract; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.HeaderViewHolder; +import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder; +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * List adapter to display all contacts and their related information + * + * @author Jake McGinty + */ +public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter + implements FastScrollAdapter, + StickyHeaderAdapter +{ + @SuppressWarnings("unused") + private final static String TAG = Log.tag(ContactSelectionListAdapter.class); + + private static final int VIEW_TYPE_CONTACT = 0; + private static final int VIEW_TYPE_DIVIDER = 1; + + public static final int PAYLOAD_SELECTION_CHANGE = 1; + + private final boolean multiSelect; + private final LayoutInflater layoutInflater; + private final ItemClickListener clickListener; + private final GlideRequests glideRequests; + private final Set currentContacts; + + private final SelectedContactSet selectedContacts = new SelectedContactSet(); + + public void clearSelectedContacts() { + selectedContacts.clear(); + } + + public boolean isSelectedContact(@NonNull SelectedContact contact) { + return selectedContacts.contains(contact); + } + + public void addSelectedContact(@NonNull SelectedContact contact) { + if (!selectedContacts.add(contact)) { + Log.i(TAG, "Contact was already selected, possibly by another identifier"); + } + } + + public void removeFromSelectedContacts(@NonNull SelectedContact selectedContact) { + int removed = selectedContacts.remove(selectedContact); + Log.i(TAG, String.format(Locale.US, "Removed %d selected contacts that matched", removed)); + } + + public abstract static class ViewHolder extends RecyclerView.ViewHolder { + + public ViewHolder(View itemView) { + super(itemView); + } + + public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible); + public abstract void unbind(@NonNull GlideRequests glideRequests); + public abstract void setChecked(boolean checked); + public abstract void setEnabled(boolean enabled); + } + + public static class ContactViewHolder extends ViewHolder { + ContactViewHolder(@NonNull final View itemView, + @Nullable final ItemClickListener clickListener) + { + super(itemView); + itemView.setOnClickListener(v -> { + if (clickListener != null) clickListener.onItemClick(getView()); + }); + } + + public ContactSelectionListItem getView() { + return (ContactSelectionListItem) itemView; + } + + public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkBoxVisible) { + getView().set(glideRequests, recipientId, type, name, number, label, about, color, checkBoxVisible); + } + + @Override + public void unbind(@NonNull GlideRequests glideRequests) { + getView().unbind(glideRequests); + } + + @Override + public void setChecked(boolean checked) { + getView().setChecked(checked); + } + + @Override + public void setEnabled(boolean enabled) { + getView().setEnabled(enabled); + } + } + + public static class DividerViewHolder extends ViewHolder { + + private final TextView label; + + DividerViewHolder(View itemView) { + super(itemView); + this.label = itemView.findViewById(R.id.label); + } + + @Override + public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible) { + this.label.setText(name); + } + + @Override + public void unbind(@NonNull GlideRequests glideRequests) {} + + @Override + public void setChecked(boolean checked) {} + + @Override + public void setEnabled(boolean enabled) {} + } + + static class HeaderViewHolder extends RecyclerView.ViewHolder { + HeaderViewHolder(View itemView) { + super(itemView); + } + } + + public ContactSelectionListAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @Nullable Cursor cursor, + @Nullable ItemClickListener clickListener, + boolean multiSelect, + @NonNull Set currentContacts) + { + super(context, cursor); + this.layoutInflater = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.multiSelect = multiSelect; + this.clickListener = clickListener; + this.currentContacts = currentContacts; + } + + @Override + public long getHeaderId(int i) { + if (!isActiveCursor()) return -1; + else if (i == -1) return -1; + + int contactType = getContactType(i); + + if (contactType == ContactRepository.DIVIDER_TYPE) return -1; + return Util.hashCode(getHeaderString(i), getContactType(i)); + } + + @Override + public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { + if (viewType == VIEW_TYPE_CONTACT) { + return new ContactViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false), clickListener); + } else { + return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false)); + } + } + + @Override + public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) { + String rawId = CursorUtil.requireString(cursor, ContactRepository.ID_COLUMN); + RecipientId id = rawId != null ? RecipientId.from(rawId) : null; + int contactType = CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN); + String name = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN); + String number = CursorUtil.requireString(cursor, ContactRepository.NUMBER_COLUMN); + int numberType = CursorUtil.requireInt(cursor, ContactRepository.NUMBER_TYPE_COLUMN); + String about = CursorUtil.requireString(cursor, ContactRepository.ABOUT_COLUMN); + String label = CursorUtil.requireString(cursor, ContactRepository.LABEL_COLUMN); + String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(), + numberType, label).toString(); + boolean isPush = (contactType & ContactRepository.PUSH_TYPE) > 0; + + int color = isPush ? ContextCompat.getColor(getContext(), R.color.signal_text_primary) + : ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_60); + + boolean currentContact = currentContacts.contains(id); + + viewHolder.unbind(glideRequests); + viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, color, multiSelect || currentContact); + viewHolder.setEnabled(true); + + if (currentContact) { + viewHolder.setChecked(true); + viewHolder.setEnabled(false); + } else if (numberType == ContactRepository.NEW_USERNAME_TYPE) { + viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number))); + } else { + viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number))); + } + } + + @Override + protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor, @NonNull List payloads) { + if (!arePayloadsValid(payloads)) { + throw new AssertionError(); + } + + String rawId = CursorUtil.requireString(cursor, ContactRepository.ID_COLUMN); + RecipientId id = rawId != null ? RecipientId.from(rawId) : null; + int numberType = CursorUtil.requireInt(cursor, ContactRepository.NUMBER_TYPE_COLUMN); + String number = CursorUtil.requireString(cursor, ContactRepository.NUMBER_COLUMN); + + viewHolder.setEnabled(true); + + if (currentContacts.contains(id)) { + viewHolder.setChecked(true); + viewHolder.setEnabled(false); + } else if (numberType == ContactRepository.NEW_USERNAME_TYPE) { + viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number))); + } else { + viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number))); + } + } + + @Override + public int getItemViewType(@NonNull Cursor cursor) { + if (CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN) == ContactRepository.DIVIDER_TYPE) { + return VIEW_TYPE_DIVIDER; + } else { + return VIEW_TYPE_CONTACT; + } + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) { + return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) { + ((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position)); + } + + @Override + protected boolean arePayloadsValid(@NonNull List payloads) { + return payloads.size() == 1 && payloads.get(0).equals(PAYLOAD_SELECTION_CHANGE); + } + + @Override + public void onItemViewRecycled(ViewHolder holder) { + holder.unbind(glideRequests); + } + + @Override + public CharSequence getBubbleText(int position) { + return getHeaderString(position); + } + + public List getSelectedContacts() { + return selectedContacts.getContacts(); + } + + public int getSelectedContactsCount() { + return selectedContacts.size(); + } + + private CharSequence getSpannedHeaderString(int position) { + final String headerString = getHeaderString(position); + if (isPush(position)) { + SpannableString spannable = new SpannableString(headerString); + spannable.setSpan(new ForegroundColorSpan(getContext().getResources().getColor(R.color.core_ultramarine)), 0, headerString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } else { + return headerString; + } + } + + private @NonNull String getHeaderString(int position) { + int contactType = getContactType(position); + + if ((contactType & ContactRepository.RECENT_TYPE) > 0 || contactType == ContactRepository.DIVIDER_TYPE) { + return " "; + } + + Cursor cursor = getCursorAtPositionOrThrow(position); + String letter = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN); + + if (letter != null) { + letter = letter.trim(); + if (letter.length() > 0) { + char firstChar = letter.charAt(0); + if (Character.isLetterOrDigit(firstChar)) { + return String.valueOf(Character.toUpperCase(firstChar)); + } + } + } + + return "#"; + } + + private int getContactType(int position) { + final Cursor cursor = getCursorAtPositionOrThrow(position); + return cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.CONTACT_TYPE_COLUMN)); + } + + private boolean isPush(int position) { + return getContactType(position) == ContactRepository.PUSH_TYPE; + } + + public interface ItemClickListener { + void onItemClick(ContactSelectionListItem item); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java new file mode 100644 index 00000000..88c5c5d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java @@ -0,0 +1,185 @@ +package org.thoughtcrime.securesms.contacts; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +public class ContactSelectionListItem extends LinearLayout implements RecipientForeverObserver { + + @SuppressWarnings("unused") + private static final String TAG = ContactSelectionListItem.class.getSimpleName(); + + private AvatarImageView contactPhotoImage; + private TextView numberView; + private FromTextView nameView; + private TextView labelView; + private CheckBox checkBox; + + private String number; + private String chipName; + private int contactType; + private LiveRecipient recipient; + private GlideRequests glideRequests; + + public ContactSelectionListItem(Context context) { + super(context); + } + + public ContactSelectionListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.contactPhotoImage = findViewById(R.id.contact_photo_image); + this.numberView = findViewById(R.id.number); + this.labelView = findViewById(R.id.label); + this.nameView = findViewById(R.id.name); + this.checkBox = findViewById(R.id.check_box); + + ViewUtil.setTextViewGravityStart(this.nameView, getContext()); + } + + public void set(@NonNull GlideRequests glideRequests, + @Nullable RecipientId recipientId, + int type, + String name, + String number, + String label, + String about, + int color, + boolean checkboxVisible) + { + this.glideRequests = glideRequests; + this.number = number; + this.contactType = type; + + if (type == ContactRepository.NEW_PHONE_TYPE || type == ContactRepository.NEW_USERNAME_TYPE) { + this.recipient = null; + this.contactPhotoImage.setAvatar(glideRequests, null, false); + } else if (recipientId != null) { + this.recipient = Recipient.live(recipientId); + this.recipient.observeForever(this); + name = this.recipient.get().getDisplayName(getContext()); + } + + Recipient recipientSnapshot = recipient != null ? recipient.get() : null; + + this.nameView.setTextColor(color); + this.numberView.setTextColor(color); + this.contactPhotoImage.setAvatar(glideRequests, recipientSnapshot, false); + + setText(recipientSnapshot, type, name, number, label, about); + + this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE); + } + + public void setChecked(boolean selected) { + this.checkBox.setChecked(selected); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + this.checkBox.setEnabled(enabled); + } + + public void unbind(GlideRequests glideRequests) { + if (recipient != null) { + recipient.removeForeverObserver(this); + recipient = null; + } + } + + @SuppressLint("SetTextI18n") + private void setText(@Nullable Recipient recipient, int type, String name, String number, String label, @Nullable String about) { + if (number == null || number.isEmpty()) { + this.nameView.setEnabled(false); + this.numberView.setText(""); + this.labelView.setVisibility(View.GONE); + } else if (recipient != null && recipient.isGroup()) { + this.nameView.setEnabled(false); + this.numberView.setText(getGroupMemberCount(recipient)); + this.labelView.setVisibility(View.GONE); + } else if (type == ContactRepository.PUSH_TYPE) { + this.numberView.setText(!Util.isEmpty(about) ? about : number); + this.nameView.setEnabled(true); + this.labelView.setVisibility(View.GONE); + } else if (type == ContactRepository.NEW_USERNAME_TYPE) { + this.numberView.setText("@" + number); + this.nameView.setEnabled(true); + this.labelView.setText(label); + this.labelView.setVisibility(View.VISIBLE); + } else { + this.numberView.setText(!Util.isEmpty(about) ? about : number); + this.nameView.setEnabled(true); + this.labelView.setText(label != null && !label.equals("null") ? label : ""); + this.labelView.setVisibility(View.VISIBLE); + } + + if (recipient != null) { + this.nameView.setText(recipient); + chipName = recipient.getShortDisplayName(getContext()); + } else { + this.nameView.setText(name); + chipName = name; + } + } + + public String getNumber() { + return number; + } + + public String getChipName() { + return chipName; + } + + private String getGroupMemberCount(@NonNull Recipient recipient) { + if (!recipient.isGroup()) { + throw new AssertionError(); + } + int memberCount = recipient.getParticipants().size(); + return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_members, memberCount, memberCount); + } + + public @Nullable LiveRecipient getRecipient() { + return recipient; + } + + public boolean isUsernameType() { + return contactType == ContactRepository.NEW_USERNAME_TYPE; + } + + public Optional getRecipientId() { + return recipient != null ? Optional.of(recipient.getId()) : Optional.absent(); + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + contactPhotoImage.setAvatar(glideRequests, recipient, false); + nameView.setText(recipient); + if (recipient.isGroup()) { + numberView.setText(getGroupMemberCount(recipient)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java new file mode 100644 index 00000000..1cac1055 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.Manifest; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.content.CursorLoader; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.UsernameUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * CursorLoader that initializes a ContactsDatabase instance + * + * @author Jake McGinty + */ +public class ContactsCursorLoader extends CursorLoader { + + private static final String TAG = ContactsCursorLoader.class.getSimpleName(); + + public static final class DisplayMode { + public static final int FLAG_PUSH = 1; + public static final int FLAG_SMS = 1 << 1; + public static final int FLAG_ACTIVE_GROUPS = 1 << 2; + public static final int FLAG_INACTIVE_GROUPS = 1 << 3; + public static final int FLAG_SELF = 1 << 4; + public static final int FLAG_BLOCK = 1 << 5; + public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5; + public static final int FLAG_HIDE_NEW = 1 << 6; + public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF; + } + + private static final String[] CONTACT_PROJECTION = new String[]{ContactRepository.ID_COLUMN, + ContactRepository.NAME_COLUMN, + ContactRepository.NUMBER_COLUMN, + ContactRepository.NUMBER_TYPE_COLUMN, + ContactRepository.LABEL_COLUMN, + ContactRepository.CONTACT_TYPE_COLUMN, + ContactRepository.ABOUT_COLUMN}; + + private static final int RECENT_CONVERSATION_MAX = 25; + + private final String filter; + private final int mode; + private final boolean recents; + + private final ContactRepository contactRepository; + + public ContactsCursorLoader(@NonNull Context context, int mode, String filter, boolean recents) + { + super(context); + + if (flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS) && !flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS)) { + throw new AssertionError("Inactive group flag set, but the active group flag isn't!"); + } + + this.filter = sanitizeFilter(filter); + this.mode = mode; + this.recents = recents; + this.contactRepository = new ContactRepository(context); + } + + @Override + public Cursor loadInBackground() { + List cursorList = TextUtils.isEmpty(filter) ? getUnfilteredResults() + : getFilteredResults(); + if (cursorList.size() > 0) { + return new MergeCursor(cursorList.toArray(new Cursor[0])); + } + return null; + } + + private static @NonNull String sanitizeFilter(@Nullable String filter) { + if (filter == null) { + return ""; + } else if (filter.startsWith("@")) { + return filter.substring(1); + } else { + return filter; + } + } + + private List getUnfilteredResults() { + ArrayList cursorList = new ArrayList<>(); + + if (groupsOnly(mode)) { + addRecentGroupsSection(cursorList); + addGroupsSection(cursorList); + } else { + addRecentsSection(cursorList); + addContactsSection(cursorList); + } + + return cursorList; + } + + private List getFilteredResults() { + ArrayList cursorList = new ArrayList<>(); + + addContactsSection(cursorList); + addGroupsSection(cursorList); + + if (!hideNewNumberOrUsername(mode)) { + addNewNumberSection(cursorList); + addUsernameSearchSection(cursorList); + } + + return cursorList; + } + + private void addRecentsSection(@NonNull List cursorList) { + if (!recents) { + return; + } + + Cursor recentConversations = getRecentConversationsCursor(); + + if (recentConversations.getCount() > 0) { + cursorList.add(getRecentsHeaderCursor()); + cursorList.add(recentConversations); + } + } + + private void addContactsSection(@NonNull List cursorList) { + List contacts = getContactsCursors(); + + if (!isCursorListEmpty(contacts)) { + cursorList.add(getContactsHeaderCursor()); + cursorList.addAll(contacts); + } + } + + private void addRecentGroupsSection(@NonNull List cursorList) { + if (!groupsEnabled(mode) || !recents) { + return; + } + + Cursor groups = getRecentConversationsCursor(true); + + if (groups.getCount() > 0) { + cursorList.add(getRecentsHeaderCursor()); + cursorList.add(groups); + } + } + + private void addGroupsSection(@NonNull List cursorList) { + if (!groupsEnabled(mode)) { + return; + } + + Cursor groups = getGroupsCursor(); + + if (groups.getCount() > 0) { + cursorList.add(getGroupsHeaderCursor()); + cursorList.add(groups); + } + } + + private void addNewNumberSection(@NonNull List cursorList) { + if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(filter)) { + cursorList.add(getPhoneNumberSearchHeaderCursor()); + cursorList.add(getNewNumberCursor()); + } else if (!FeatureFlags.usernames() && NumberUtil.isValidSmsOrEmail(filter)){ + cursorList.add(getPhoneNumberSearchHeaderCursor()); + cursorList.add(getNewNumberCursor()); + } + } + + private void addUsernameSearchSection(@NonNull List cursorList) { + if (FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(filter)) { + cursorList.add(getUsernameSearchHeaderCursor()); + cursorList.add(getUsernameSearchCursor()); + } + } + + private Cursor getRecentsHeaderCursor() { + MatrixCursor recentsHeader = new MatrixCursor(CONTACT_PROJECTION); + recentsHeader.addRow(new Object[]{ null, + getContext().getString(R.string.ContactsCursorLoader_recent_chats), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.DIVIDER_TYPE, + "" }); + return recentsHeader; + } + + private Cursor getContactsHeaderCursor() { + MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + contactsHeader.addRow(new Object[] { null, + getContext().getString(R.string.ContactsCursorLoader_contacts), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.DIVIDER_TYPE, + "" }); + return contactsHeader; + } + + private Cursor getGroupsHeaderCursor() { + MatrixCursor groupHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + groupHeader.addRow(new Object[]{ null, + getContext().getString(R.string.ContactsCursorLoader_groups), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.DIVIDER_TYPE, + "" }); + return groupHeader; + } + + private Cursor getPhoneNumberSearchHeaderCursor() { + MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + contactsHeader.addRow(new Object[] { null, + getContext().getString(R.string.ContactsCursorLoader_phone_number_search), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.DIVIDER_TYPE, + "" }); + return contactsHeader; + } + + private Cursor getUsernameSearchHeaderCursor() { + MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + contactsHeader.addRow(new Object[] { null, + getContext().getString(R.string.ContactsCursorLoader_username_search), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.DIVIDER_TYPE, + "" }); + return contactsHeader; + } + + + private Cursor getRecentConversationsCursor() { + return getRecentConversationsCursor(false); + } + + private Cursor getRecentConversationsCursor(boolean groupsOnly) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(getContext()); + + MatrixCursor recentConversations = new MatrixCursor(CONTACT_PROJECTION, RECENT_CONVERSATION_MAX); + try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), groupsOnly, hideGroupsV1(mode), !smsEnabled(mode))) { + ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations); + ThreadRecord threadRecord; + while ((threadRecord = reader.getNext()) != null) { + Recipient recipient = threadRecord.getRecipient(); + String stringId = recipient.isGroup() ? recipient.requireGroupId().toString() : recipient.getE164().transform(PhoneNumberFormatter::prettyPrint).or(recipient.getEmail()).or(""); + + recentConversations.addRow(new Object[] { recipient.getId().serialize(), + recipient.getDisplayName(getContext()), + stringId, + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactRepository.RECENT_TYPE | (recipient.isRegistered() && !recipient.isForceSmsSelection() ? ContactRepository.PUSH_TYPE : 0), + recipient.getCombinedAboutAndEmoji() }); + } + } + return recentConversations; + } + + private List getContactsCursors() { + List cursorList = new ArrayList<>(2); + + if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + return cursorList; + } + + if (pushEnabled(mode)) { + cursorList.add(contactRepository.querySignalContacts(filter, selfEnabled(mode))); + } + + if (pushEnabled(mode) && smsEnabled(mode)) { + cursorList.add(contactRepository.queryNonSignalContacts(filter)); + } else if (smsEnabled(mode)) { + cursorList.add(filterNonPushContacts(contactRepository.queryNonSignalContacts(filter))); + } + return cursorList; + } + + private Cursor getGroupsCursor() { + MatrixCursor groupContacts = new MatrixCursor(CONTACT_PROJECTION); + try (GroupDatabase.Reader reader = DatabaseFactory.getGroupDatabase(getContext()).getGroupsFilteredByTitle(filter, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), hideGroupsV1(mode))) { + GroupDatabase.GroupRecord groupRecord; + while ((groupRecord = reader.getNext()) != null) { + groupContacts.addRow(new Object[] { groupRecord.getRecipientId().serialize(), + groupRecord.getTitle(), + groupRecord.getId(), + ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "", + ContactRepository.NORMAL_TYPE, + "" }); + } + } + return groupContacts; + } + + private Cursor getNewNumberCursor() { + MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1); + newNumberCursor.addRow(new Object[] { null, + getUnknownContactTitle(), + filter, + ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "\u21e2", + ContactRepository.NEW_PHONE_TYPE, + "" }); + return newNumberCursor; + } + + private Cursor getUsernameSearchCursor() { + MatrixCursor cursor = new MatrixCursor(CONTACT_PROJECTION, 1); + cursor.addRow(new Object[] { null, + getUnknownContactTitle(), + filter, + ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "\u21e2", + ContactRepository.NEW_USERNAME_TYPE, + "" }); + return cursor; + } + + private String getUnknownContactTitle() { + if (blockUser(mode)) { + return getContext().getString(R.string.contact_selection_list__unknown_contact_block); + } else if (newConversation(mode)) { + return getContext().getString(R.string.contact_selection_list__unknown_contact); + } else { + return getContext().getString(R.string.contact_selection_list__unknown_contact_add_to_group); + } + } + + private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) { + try { + final long startMillis = System.currentTimeMillis(); + final MatrixCursor matrix = new MatrixCursor(CONTACT_PROJECTION); + while (cursor.moveToNext()) { + final RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN))); + final Recipient recipient = Recipient.resolved(id); + + if (recipient.resolve().getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) { + matrix.addRow(new Object[]{cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.LABEL_COLUMN)), + ContactRepository.NORMAL_TYPE, + "" }); + } + } + Log.i(TAG, "filterNonPushContacts() -> " + (System.currentTimeMillis() - startMillis) + "ms"); + return matrix; + } finally { + cursor.close(); + } + } + + private static boolean isCursorListEmpty(List list) { + int sum = 0; + for (Cursor cursor : list) { + sum += cursor.getCount(); + } + return sum == 0; + } + + private static boolean selfEnabled(int mode) { + return flagSet(mode, DisplayMode.FLAG_SELF); + } + + private static boolean blockUser(int mode) { + return flagSet(mode, DisplayMode.FLAG_BLOCK); + } + + private static boolean newConversation(int mode) { + return groupsEnabled(mode); + } + + private static boolean pushEnabled(int mode) { + return flagSet(mode, DisplayMode.FLAG_PUSH); + } + + private static boolean smsEnabled(int mode) { + return flagSet(mode, DisplayMode.FLAG_SMS); + } + + private static boolean groupsEnabled(int mode) { + return flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS); + } + + private static boolean groupsOnly(int mode) { + return mode == DisplayMode.FLAG_ACTIVE_GROUPS; + } + + private static boolean hideGroupsV1(int mode) { + return flagSet(mode, DisplayMode.FLAG_HIDE_GROUPS_V1); + } + + private static boolean hideNewNumberOrUsername(int mode) { + return flagSet(mode, DisplayMode.FLAG_HIDE_NEW); + } + + private static boolean flagSet(int mode, int flag) { + return (mode & flag) > 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java new file mode 100644 index 00000000..f386ca25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java @@ -0,0 +1,506 @@ +/* + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +import android.accounts.Account; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Database to supply all types of contacts that TextSecure needs to know about + * + * @author Jake McGinty + */ +public class ContactsDatabase { + + private static final String TAG = ContactsDatabase.class.getSimpleName(); + private static final String CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"; + private static final String CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"; + private static final String SYNC = "__TS"; + + private final Context context; + + public ContactsDatabase(Context context) { + this.context = context; + } + + public synchronized void removeDeletedRawContacts(@NonNull Account account) { + Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + + String[] projection = new String[] {BaseColumns._ID, RawContacts.SYNC1}; + + try (Cursor cursor = context.getContentResolver().query(currentContactsUri, projection, RawContacts.DELETED + " = ?", new String[] {"1"}, null)) { + while (cursor != null && cursor.moveToNext()) { + long rawContactId = cursor.getLong(0); + Log.i(TAG, "Deleting raw contact: " + cursor.getString(1) + ", " + rawContactId); + + context.getContentResolver().delete(currentContactsUri, RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}); + } + } + } + + public synchronized void setRegisteredUsers(@NonNull Account account, + @NonNull List registeredAddressList, + boolean remove) + throws RemoteException, OperationApplicationException + { + Set registeredAddressSet = new HashSet<>(registeredAddressList); + ArrayList operations = new ArrayList<>(); + Map currentContacts = getSignalRawContacts(account); + List> registeredChunks = Util.chunk(registeredAddressList, 50); + + for (List registeredChunk : registeredChunks) { + for (String registeredAddress : registeredChunk) { + if (!currentContacts.containsKey(registeredAddress)) { + Optional systemContactInfo = getSystemContactInfo(registeredAddress); + + if (systemContactInfo.isPresent()) { + Log.i(TAG, "Adding number: " + registeredAddress); + addTextSecureRawContact(operations, account, systemContactInfo.get().number, + systemContactInfo.get().name, systemContactInfo.get().id); + } + } + } + if (!operations.isEmpty()) { + context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); + operations.clear(); + } + } + + for (Map.Entry currentContactEntry : currentContacts.entrySet()) { + if (!registeredAddressSet.contains(currentContactEntry.getKey())) { + if (remove) { + Log.i(TAG, "Removing number: " + currentContactEntry.getKey()); + removeTextSecureRawContact(operations, account, currentContactEntry.getValue().getId()); + } + } else if (!currentContactEntry.getValue().isVoiceSupported()) { + Log.i(TAG, "Adding voice support: " + currentContactEntry.getKey()); + addContactVoiceSupport(operations, currentContactEntry.getKey(), currentContactEntry.getValue().getId()); + } else if (!Util.isStringEquals(currentContactEntry.getValue().getRawDisplayName(), + currentContactEntry.getValue().getAggregateDisplayName())) + { + Log.i(TAG, "Updating display name: " + currentContactEntry.getKey()); + updateDisplayName(operations, currentContactEntry.getValue().getAggregateDisplayName(), currentContactEntry.getValue().getId(), currentContactEntry.getValue().getDisplayNameSource()); + } + } + + if (!operations.isEmpty()) { + applyOperationsInBatches(context.getContentResolver(), ContactsContract.AUTHORITY, operations, 50); + } + } + + public @Nullable Cursor getNameDetails(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, + ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, + ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, + ContactsContract.CommonDataKinds.StructuredName.PREFIX, + ContactsContract.CommonDataKinds.StructuredName.SUFFIX, + ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE }; + + return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null); + } + + public @Nullable String getOrganizationName(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.Organization.COMPANY }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE }; + + try (Cursor cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null)) + { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(0); + } + } + + return null; + } + + public @Nullable Cursor getPhoneDetails(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE }; + + return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null); + } + + public @Nullable Cursor getEmailDetails(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.TYPE, + ContactsContract.CommonDataKinds.Email.LABEL }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE }; + + return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null); + } + + public @Nullable Cursor getPostalAddressDetails(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.StructuredPostal.TYPE, + ContactsContract.CommonDataKinds.StructuredPostal.LABEL, + ContactsContract.CommonDataKinds.StructuredPostal.STREET, + ContactsContract.CommonDataKinds.StructuredPostal.POBOX, + ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD, + ContactsContract.CommonDataKinds.StructuredPostal.CITY, + ContactsContract.CommonDataKinds.StructuredPostal.REGION, + ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE, + ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE }; + + return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null); + } + + public @Nullable Uri getAvatarUri(long contactId) { + String[] projection = new String[] { ContactsContract.CommonDataKinds.Photo.PHOTO_URI }; + String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; + String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE }; + + try (Cursor cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, + projection, + selection, + args, + null)) + { + if (cursor != null && cursor.moveToFirst()) { + String uri = cursor.getString(0); + if (uri != null) { + return Uri.parse(uri); + } + } + } + + return null; + } + + + + private void addContactVoiceSupport(List operations, + @NonNull String address, long rawContactId) + { + operations.add(ContentProviderOperation.newUpdate(RawContacts.CONTENT_URI) + .withSelection(RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}) + .withValue(RawContacts.SYNC4, "true") + .build()); + + operations.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) + .withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId) + .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, address) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address)) + .withYieldAllowed(true) + .build()); + } + + private void updateDisplayName(List operations, + @Nullable String displayName, + long rawContactId, int displayNameSource) + { + Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + + if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) { + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .build()); + } else { + operations.add(ContentProviderOperation.newUpdate(dataUri) + .withSelection(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?", + new String[] {String.valueOf(rawContactId), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE}) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .build()); + } + } + + private void addTextSecureRawContact(List operations, + Account account, String e164number, String displayName, + long aggregateId) + { + int index = operations.size(); + Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + + operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) + .withValue(RawContacts.ACCOUNT_NAME, account.name) + .withValue(RawContacts.ACCOUNT_TYPE, account.type) + .withValue(RawContacts.SYNC1, e164number) + .withValue(RawContacts.SYNC4, String.valueOf(true)) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number) + .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER) + .withValue(ContactsContract.Data.SYNC2, SYNC) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number)) + .withYieldAllowed(true) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number)) + .withYieldAllowed(true) + .build()); + + operations.add(ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI) + .withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId) + .withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index) + .withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER) + .build()); + } + + private void removeTextSecureRawContact(List operations, + Account account, long rowId) + { + operations.add(ContentProviderOperation.newDelete(RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) + .withYieldAllowed(true) + .withSelection(BaseColumns._ID + " = ?", new String[] {String.valueOf(rowId)}) + .build()); + } + + private @NonNull Map getSignalRawContacts(@NonNull Account account) { + Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type).build(); + + Map signalContacts = new HashMap<>(); + Cursor cursor = null; + + try { + String[] projection = new String[] {BaseColumns._ID, RawContacts.SYNC1, RawContacts.SYNC4, RawContacts.CONTACT_ID, RawContacts.DISPLAY_NAME_PRIMARY, RawContacts.DISPLAY_NAME_SOURCE}; + + cursor = context.getContentResolver().query(currentContactsUri, projection, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + String currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1)); + long rawContactId = cursor.getLong(0); + long contactId = cursor.getLong(3); + String supportsVoice = cursor.getString(2); + String rawContactDisplayName = cursor.getString(4); + String aggregateDisplayName = getDisplayName(contactId); + int rawContactDisplayNameSource = cursor.getInt(5); + + signalContacts.put(currentAddress, new SignalContact(rawContactId, supportsVoice, rawContactDisplayName, aggregateDisplayName, rawContactDisplayNameSource)); + } + } finally { + if (cursor != null) + cursor.close(); + } + + return signalContacts; + } + + private Optional getSystemContactInfo(@NonNull String address) + { + Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)); + String[] projection = {ContactsContract.PhoneLookup.NUMBER, + ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.DISPLAY_NAME}; + Cursor numberCursor = null; + Cursor idCursor = null; + + try { + numberCursor = context.getContentResolver().query(uri, projection, null, null, null); + + while (numberCursor != null && numberCursor.moveToNext()) { + String systemNumber = numberCursor.getString(0); + String systemAddress = PhoneNumberFormatter.get(context).format(systemNumber); + + if (systemAddress.equals(address)) { + idCursor = context.getContentResolver().query(RawContacts.CONTENT_URI, + new String[] {RawContacts._ID}, + RawContacts.CONTACT_ID + " = ? ", + new String[] {String.valueOf(numberCursor.getLong(1))}, + null); + + if (idCursor != null && idCursor.moveToNext()) { + return Optional.of(new SystemContactInfo(numberCursor.getString(2), + numberCursor.getString(0), + idCursor.getLong(0))); + } + } + } + } finally { + if (numberCursor != null) numberCursor.close(); + if (idCursor != null) idCursor.close(); + } + + return Optional.absent(); + } + + private @Nullable String getDisplayName(long contactId) { + Cursor cursor = context.getContentResolver().query(ContactsContract.Contacts.CONTENT_URI, + new String[]{ContactsContract.Contacts.DISPLAY_NAME}, + ContactsContract.Contacts._ID + " = ?", + new String[] {String.valueOf(contactId)}, + null); + + try { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(0); + } else { + return null; + } + } finally { + if (cursor != null) cursor.close(); + } + } + + private void applyOperationsInBatches(@NonNull ContentResolver contentResolver, + @NonNull String authority, + @NonNull List operations, + int batchSize) + throws OperationApplicationException, RemoteException + { + List> batches = Util.chunk(operations, batchSize); + for (List batch : batches) { + contentResolver.applyBatch(authority, new ArrayList<>(batch)); + } + } + + private static class SystemContactInfo { + private final String name; + private final String number; + private final long id; + + private SystemContactInfo(String name, String number, long id) { + this.name = name; + this.number = number; + this.id = id; + } + } + + private static class SignalContact { + + private final long id; + @Nullable private final String supportsVoice; + @Nullable private final String rawDisplayName; + @Nullable private final String aggregateDisplayName; + private final int displayNameSource; + + SignalContact(long id, + @Nullable String supportsVoice, + @Nullable String rawDisplayName, + @Nullable String aggregateDisplayName, + int displayNameSource) + { + this.id = id; + this.supportsVoice = supportsVoice; + this.rawDisplayName = rawDisplayName; + this.aggregateDisplayName = aggregateDisplayName; + this.displayNameSource = displayNameSource; + } + + public long getId() { + return id; + } + + boolean isVoiceSupported() { + return "true".equals(supportsVoice); + } + + @Nullable + String getRawDisplayName() { + return rawDisplayName; + } + + @Nullable + String getAggregateDisplayName() { + return aggregateDisplayName; + } + + int getDisplayNameSource() { + return displayNameSource; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java new file mode 100644 index 00000000..dc1fb6ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.contacts; + +import android.accounts.Account; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.SyncResult; +import android.os.Bundle; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; + +public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter { + + private static final String TAG = ContactsSyncAdapter.class.getSimpleName(); + + public ContactsSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + + @Override + public void onPerformSync(Account account, Bundle extras, String authority, + ContentProviderClient provider, SyncResult syncResult) + { + Log.i(TAG, "onPerformSync(" + authority +")"); + + if (TextSecurePreferences.isPushRegistered(getContext())) { + try { + DirectoryHelper.refreshDirectory(getContext(), true); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + @Override + public void onSyncCanceled() { + Log.w(TAG, "onSyncCanceled()"); + } + + @Override + public void onSyncCanceled(Thread thread) { + Log.w(TAG, "onSyncCanceled(" + thread + ")"); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/NameAndNumber.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/NameAndNumber.java new file mode 100644 index 00000000..b9e0e1fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/NameAndNumber.java @@ -0,0 +1,35 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.contacts; + +/** + * Name and number tuple. + * + * @author Moxie Marlinspike + * + */ +public class NameAndNumber { + public String name; + public String number; + + public NameAndNumber(String name, String number) { + this.name = name; + this.number = number; + } + + public NameAndNumber() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java new file mode 100644 index 00000000..2470b382 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.contacts; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.text.Annotation; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.view.View; +import android.widget.ResourceCursorAdapter; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.RecipientsFormatter; + +/** + * This adapter is used to filter contacts on both name and number. + */ +public class RecipientsAdapter extends ResourceCursorAdapter { + + public static final int CONTACT_ID_INDEX = 1; + public static final int TYPE_INDEX = 2; + public static final int NUMBER_INDEX = 3; + public static final int LABEL_INDEX = 4; + public static final int NAME_INDEX = 5; + + private final Context mContext; + private final ContentResolver mContentResolver; + private ContactAccessor mContactAccessor; + + public RecipientsAdapter(Context context) { + super(context, R.layout.recipient_filter_item, null); + mContext = context; + mContentResolver = context.getContentResolver(); + mContactAccessor = ContactAccessor.getInstance(); + } + + @Override + public final CharSequence convertToString(Cursor cursor) { + String name = cursor.getString(RecipientsAdapter.NAME_INDEX); + int type = cursor.getInt(RecipientsAdapter.TYPE_INDEX); + String number = cursor.getString(RecipientsAdapter.NUMBER_INDEX).trim(); + + String label = cursor.getString(RecipientsAdapter.LABEL_INDEX); + CharSequence displayLabel = mContactAccessor.phoneTypeToString(mContext, type, label); + + if (number.length() == 0) { + return number; + } + + if (name == null) { + name = ""; + } else { + // Names with commas are the bane of the recipient editor's existence. + // We've worked around them by using spans, but there are edge cases + // where the spans get deleted. Furthermore, having commas in names + // can be confusing to the user since commas are used as separators + // between recipients. The best solution is to simply remove commas + // from names. + name = name.replace(", ", " ") + .replace(",", " "); // Make sure we leave a space between parts of names. + } + + String nameAndNumber = RecipientsFormatter.formatNameAndNumber(name, number); + + SpannableString out = new SpannableString(nameAndNumber); + int len = out.length(); + + if (!TextUtils.isEmpty(name)) { + out.setSpan(new Annotation("name", name), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } else { + out.setSpan(new Annotation("name", number), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + String person_id = cursor.getString(RecipientsAdapter.CONTACT_ID_INDEX); + out.setSpan(new Annotation("person_id", person_id), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + out.setSpan(new Annotation("label", displayLabel.toString()), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + out.setSpan(new Annotation("number", number), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return out; + } + + @Override + public final void bindView(View view, Context context, Cursor cursor) { + TextView name = (TextView) view.findViewById(R.id.name); + name.setText(cursor.getString(NAME_INDEX)); + + TextView label = (TextView) view.findViewById(R.id.label); + int type = cursor.getInt(TYPE_INDEX); + label.setText(mContactAccessor.phoneTypeToString(mContext, type, cursor.getString(LABEL_INDEX))); + + TextView number = (TextView) view.findViewById(R.id.number); + number.setText("(" + cursor.getString(NUMBER_INDEX) + ")"); + } + + @Override + public Cursor runQueryOnBackgroundThread(CharSequence constraint) { + return mContactAccessor.getCursorForRecipientFilter( constraint, mContentResolver ); + } + + /** + * Returns true if all the characters are meaningful as digits + * in a phone number -- letters, digits, and a few punctuation marks. + */ + public static boolean usefulAsDigits(CharSequence cons) { + int len = cons.length(); + + for (int i = 0; i < len; i++) { + char c = cons.charAt(i); + + if ((c >= '0') && (c <= '9')) { + continue; + } + if ((c == ' ') || (c == '-') || (c == '(') || (c == ')') || (c == '.') || (c == '+') + || (c == '#') || (c == '*')) { + continue; + } + if ((c >= 'A') && (c <= 'Z')) { + continue; + } + if ((c >= 'a') && (c <= 'z')) { + continue; + } + + return false; + } + + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java new file mode 100644 index 00000000..39d829df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; +import android.telephony.PhoneNumberUtils; +import android.text.Annotation; +import android.text.Editable; +import android.text.Layout; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.MotionEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.MultiAutoCompleteTextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientsFormatter; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provide UI for editing the recipients of multi-media messages. + */ +public class RecipientsEditor extends AppCompatMultiAutoCompleteTextView { + private int mLongPressedPosition = -1; + private final RecipientsEditorTokenizer mTokenizer; + private char mLastSeparator = ','; + private Context mContext; + + public RecipientsEditor(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + mTokenizer = new RecipientsEditorTokenizer(context, this); + setTokenizer(mTokenizer); + // For the focus to move to the message body when soft Next is pressed + setImeOptions(EditorInfo.IME_ACTION_NEXT); + + /* + * The point of this TextWatcher is that when the user chooses + * an address completion from the AutoCompleteTextView menu, it + * is marked up with Annotation objects to tie it back to the + * address book entry that it came from. If the user then goes + * back and edits that part of the text, it no longer corresponds + * to that address book entry and needs to have the Annotations + * claiming that it does removed. + */ + addTextChangedListener(new TextWatcher() { + private Annotation[] mAffected; + + public void beforeTextChanged(CharSequence s, int start, + int count, int after) { + mAffected = ((Spanned) s).getSpans(start, start + count, + Annotation.class); + } + + public void onTextChanged(CharSequence s, int start, + int before, int after) { + if (before == 0 && after == 1) { // inserting a character + char c = s.charAt(start); + if (c == ',' || c == ';') { + // Remember the delimiter the user typed to end this recipient. We'll + // need it shortly in terminateToken(). + mLastSeparator = c; + } + } + } + + public void afterTextChanged(Editable s) { + if (mAffected != null) { + for (Annotation a : mAffected) { + s.removeSpan(a); + } + } + + mAffected = null; + } + }); + } + + @Override + public boolean enoughToFilter() { + if (!super.enoughToFilter()) { + return false; + } + // If the user is in the middle of editing an existing recipient, don't offer the + // auto-complete menu. Without this, when the user selects an auto-complete menu item, + // it will get added to the list of recipients so we end up with the old before-editing + // recipient and the new post-editing recipient. As a precedent, gmail does not show + // the auto-complete menu when editing an existing recipient. + int end = getSelectionEnd(); + int len = getText().length(); + + return end == len; + } + + public int getRecipientCount() { + return mTokenizer.getNumbers().size(); + } + + public List getNumbers() { + return mTokenizer.getNumbers(); + } + +// public Recipients constructContactsFromInput() { +// return RecipientFactory.getRecipientsFromString(mContext, mTokenizer.getRawString(), false); +// } + + private boolean isValidAddress(String number, boolean isMms) { + /*if (isMms) { + return MessageUtils.isValidMmsAddress(number); + } else {*/ + // TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid + // GSM SMS address. If the address contains a dialable char, it considers it a well + // formed SMS addr. CDMA doesn't work that way and has a different parser for SMS + // address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!! + return PhoneNumberUtils.isWellFormedSmsAddress(number); + } + + public boolean hasValidRecipient(boolean isMms) { + for (String number : mTokenizer.getNumbers()) { + if (isValidAddress(number, isMms)) + return true; + } + return false; + } + + /*public boolean hasInvalidRecipient(boolean isMms) { + for (String number : mTokenizer.getNumbers()) { + if (!isValidAddress(number, isMms)) { + /* TODO if (MmsConfig.getEmailGateway() == null) { + return true; + } else if (!MessageUtils.isAlias(number)) { + return true; + } + } + } + return false; + }*/ + + public String formatInvalidNumbers(boolean isMms) { + StringBuilder sb = new StringBuilder(); + for (String number : mTokenizer.getNumbers()) { + if (!isValidAddress(number, isMms)) { + if (sb.length() != 0) { + sb.append(", "); + } + sb.append(number); + } + } + return sb.toString(); + } + + /*public boolean containsEmail() { + if (TextUtils.indexOf(getText(), '@') == -1) + return false; + + List numbers = mTokenizer.getNumbers(); + for (String number : numbers) { + if (Mms.isEmailAddress(number)) + return true; + } + return false; + }*/ + + public static CharSequence contactToToken(@NonNull Context context, @NonNull Recipient c) { + String name = c.getDisplayName(context); + String number = c.getE164().or(c.getEmail()).or(""); + SpannableString s = new SpannableString(RecipientsFormatter.formatNameAndNumber(name, number)); + int len = s.length(); + + if (len == 0) { + return s; + } + + s.setSpan(new Annotation("number", number), 0, len, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return s; + } + + public void populate(List list) { + SpannableStringBuilder sb = new SpannableStringBuilder(); + + for (Recipient c : list) { + if (sb.length() != 0) { + sb.append(", "); + } + + sb.append(contactToToken(mContext, c)); + } + + setText(sb); + } + + private int pointToPosition(int x, int y) { + x -= getCompoundPaddingLeft(); + y -= getExtendedPaddingTop(); + + x += getScrollX(); + y += getScrollY(); + + Layout layout = getLayout(); + if (layout == null) { + return -1; + } + + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + return off; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = ev.getAction(); + final int x = (int) ev.getX(); + final int y = (int) ev.getY(); + + if (action == MotionEvent.ACTION_DOWN) { + mLongPressedPosition = pointToPosition(x, y); + } + + return super.onTouchEvent(ev); + } + + private static String getNumberAt(Spanned sp, int start, int end, Context context) { + return getFieldAt("number", sp, start, end, context); + } + + private static int getSpanLength(Spanned sp, int start, int end, Context context) { + // TODO: there's a situation where the span can lose its annotations: + // - add an auto-complete contact + // - add another auto-complete contact + // - delete that second contact and keep deleting into the first + // - we lose the annotation and can no longer get the span. + // Need to fix this case because it breaks auto-complete contacts with commas in the name. + Annotation[] a = sp.getSpans(start, end, Annotation.class); + if (a.length > 0) { + return sp.getSpanEnd(a[0]); + } + return 0; + } + + private static String getFieldAt(String field, Spanned sp, int start, int end, + Context context) { + Annotation[] a = sp.getSpans(start, end, Annotation.class); + String fieldValue = getAnnotation(a, field); + if (TextUtils.isEmpty(fieldValue)) { + fieldValue = TextUtils.substring(sp, start, end); + } + return fieldValue; + + } + + private static String getAnnotation(Annotation[] a, String key) { + for (int i = 0; i < a.length; i++) { + if (a[i].getKey().equals(key)) { + return a[i].getValue(); + } + } + + return ""; + } + + private class RecipientsEditorTokenizer + implements MultiAutoCompleteTextView.Tokenizer { + private final MultiAutoCompleteTextView mList; + private final Context mContext; + + RecipientsEditorTokenizer(Context context, MultiAutoCompleteTextView list) { + mList = list; + mContext = context; + } + + /** + * Returns the start of the token that ends at offset + * cursor within text. + * It is a method from the MultiAutoCompleteTextView.Tokenizer interface. + */ + public int findTokenStart(CharSequence text, int cursor) { + int i = cursor; + char c; + + while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') { + i--; + } + while (i < cursor && text.charAt(i) == ' ') { + i++; + } + + return i; + } + + /** + * Returns the end of the token (minus trailing punctuation) + * that begins at offset cursor within text. + * It is a method from the MultiAutoCompleteTextView.Tokenizer interface. + */ + public int findTokenEnd(CharSequence text, int cursor) { + int i = cursor; + int len = text.length(); + char c; + + while (i < len) { + if ((c = text.charAt(i)) == ',' || c == ';') { + return i; + } else { + i++; + } + } + + return len; + } + + /** + * Returns text, modified, if necessary, to ensure that + * it ends with a token terminator (for example a space or comma). + * It is a method from the MultiAutoCompleteTextView.Tokenizer interface. + */ + public CharSequence terminateToken(CharSequence text) { + int i = text.length(); + + while (i > 0 && text.charAt(i - 1) == ' ') { + i--; + } + + char c; + if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) { + return text; + } else { + // Use the same delimiter the user just typed. + // This lets them have a mixture of commas and semicolons in their list. + String separator = mLastSeparator + " "; + if (text instanceof Spanned) { + SpannableString sp = new SpannableString(text + separator); + TextUtils.copySpansFrom((Spanned) text, 0, text.length(), + Object.class, sp, 0); + return sp; + } else { + return text + separator; + } + } + } + public String getRawString() { + return mList.getText().toString(); + } + public List getNumbers() { + Spanned sp = mList.getText(); + int len = sp.length(); + List list = new ArrayList(); + + int start = 0; + int i = 0; + while (i < len + 1) { + char c; + if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) { + if (i > start) { + list.add(getNumberAt(sp, start, i, mContext)); + + // calculate the recipients total length. This is so if the name contains + // commas or semis, we'll skip over the whole name to the next + // recipient, rather than parsing this single name into multiple + // recipients. + int spanLen = getSpanLength(sp, start, i, mContext); + if (spanLen > i) { + i = spanLen; + } + } + + i++; + + while ((i < len) && (sp.charAt(i) == ' ')) { + i++; + } + + start = i; + } else { + i++; + } + } + + return list; + } + } + + static class RecipientContextMenuInfo implements ContextMenuInfo { + final Recipient recipient; + + RecipientContextMenuInfo(Recipient r) { + recipient = r; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java new file mode 100644 index 00000000..54d15c70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.contacts; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +/** + * Model for a contact and the various ways it could be represented. Used in situations where we + * don't want to create Recipients for the wrapped data (like a custom-entered phone number for + * someone you don't yet have a conversation with). + */ +public final class SelectedContact { + private final RecipientId recipientId; + private final String number; + private final String username; + + public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) { + return new SelectedContact(recipientId, number, null); + } + + public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) { + return new SelectedContact(recipientId, null, username); + } + + private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) { + this.recipientId = recipientId; + this.number = number; + this.username = username; + } + + public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) { + if (recipientId != null) { + return recipientId; + } else if (number != null) { + return Recipient.external(context, number).getId(); + } else { + throw new AssertionError(); + } + } + + /** + * Returns true when non-null recipient ids match, and false if not. + *

+ * If one or more recipient id is not set, then it returns true iff any other non-null property + * matches one on the other contact. + */ + public boolean matches(@Nullable SelectedContact other) { + if (other == null) return false; + + if (recipientId != null && other.recipientId != null) { + return recipientId.equals(other.recipientId); + } + + return number != null && number .equals(other.number) || + username != null && username.equals(other.username); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContactSet.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContactSet.java new file mode 100644 index 00000000..50cfa596 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContactSet.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.contacts; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** + * Specialised set for {@link SelectedContact} that will not allow more than one entry that + * {@link SelectedContact#matches(SelectedContact)} any other. + */ +public final class SelectedContactSet { + + private final List contacts = new LinkedList<>(); + + public boolean add(@NonNull SelectedContact contact) { + if (contains(contact)) { + return false; + } + + contacts.add(contact); + return true; + } + + public boolean contains(@NonNull SelectedContact otherContact) { + for (SelectedContact contact : contacts) { + if (otherContact.matches(contact)) { + return true; + } + } + return false; + } + + public List getContacts() { + return new ArrayList<>(contacts); + } + + public int size() { + return contacts.size(); + } + + public void clear() { + contacts.clear(); + } + + public int remove(@NonNull SelectedContact otherContact) { + int removeCount = 0; + Iterator iterator = contacts.iterator(); + + while (iterator.hasNext()) { + SelectedContact next = iterator.next(); + if (next.matches(otherContact)) { + iterator.remove(); + removeCount++; + } + } + + return removeCount; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/TurnOffContactJoinedNotificationsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/TurnOffContactJoinedNotificationsActivity.java new file mode 100644 index 00000000..21650ca3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/TurnOffContactJoinedNotificationsActivity.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.contacts; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.MarkReadReceiver; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.List; + +/** + * Activity which displays a dialog to confirm whether to turn off "Contact Joined Signal" notifications. + */ +public class TurnOffContactJoinedNotificationsActivity extends AppCompatActivity { + + private final static String EXTRA_THREAD_ID = "thread_id"; + + public static Intent newIntent(@NonNull Context context, long threadId) { + Intent intent = new Intent(context, TurnOffContactJoinedNotificationsActivity.class); + + intent.putExtra(EXTRA_THREAD_ID, threadId); + + return intent; + } + + @Override + protected void onResume() { + super.onResume(); + + new AlertDialog.Builder(this) + .setMessage(R.string.TurnOffContactJoinedNotificationsActivity__turn_off_contact_joined_signal) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + handlePositiveAction(dialog); + }) + .setNegativeButton(android.R.string.cancel, ((dialog, which) -> { + dialog.dismiss(); + finish(); + })) + .show(); + } + + private void handlePositiveAction(@NonNull DialogInterface dialog) { + SimpleTask.run(getLifecycle(), () -> { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(this); + + List marked = threadDatabase.setRead(getIntent().getLongExtra(EXTRA_THREAD_ID, -1), false); + MarkReadReceiver.process(this, marked); + + TextSecurePreferences.setNewContactsNotificationEnabled(this, false); + ApplicationDependencies.getMessageNotifier().updateNotification(this); + + return null; + }, unused -> { + dialog.dismiss(); + finish(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColors.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColors.java new file mode 100644 index 00000000..26a206cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColors.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.color.MaterialColor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Colors that can be randomly assigned to a contact. + */ +public class ContactColors { + + public static final MaterialColor UNKNOWN_COLOR = MaterialColor.STEEL; + + private static final List CONVERSATION_PALETTE = new ArrayList<>(Arrays.asList( + MaterialColor.PLUM, + MaterialColor.CRIMSON, + MaterialColor.VERMILLION, + MaterialColor.VIOLET, + MaterialColor.BLUE, + MaterialColor.INDIGO, + MaterialColor.FOREST, + MaterialColor.WINTERGREEN, + MaterialColor.TEAL, + MaterialColor.BURLAP, + MaterialColor.TAUPE, + MaterialColor.ULTRAMARINE + )); + + public static MaterialColor generateFor(@NonNull String name) { + return CONVERSATION_PALETTE.get(Math.abs(name.hashCode()) % CONVERSATION_PALETTE.size()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColorsLegacy.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColorsLegacy.java new file mode 100644 index 00000000..62f5bea3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactColorsLegacy.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.color.MaterialColor; + +/** + * Used for migrating legacy colors to modern colors. For normal color generation, use + * {@link ContactColors}. + */ +public class ContactColorsLegacy { + + private static final String[] LEGACY_PALETTE = new String[] { + "red", + "pink", + "purple", + "deep_purple", + "indigo", + "blue", + "light_blue", + "cyan", + "teal", + "green", + "light_green", + "orange", + "deep_orange", + "amber", + "blue_grey" + }; + + private static final String[] LEGACY_PALETTE_2 = new String[]{ + "pink", + "red", + "orange", + "purple", + "blue", + "indigo", + "green", + "light_green", + "teal", + "brown", + "blue_grey" + }; + + + public static MaterialColor generateFor(@NonNull String name) { + String serialized = LEGACY_PALETTE[Math.abs(name.hashCode()) % LEGACY_PALETTE.length]; + try { + return MaterialColor.fromSerialized(serialized); + } catch (MaterialColor.UnknownColorException e) { + return ContactColors.generateFor(name); + } + } + + public static MaterialColor generateForV2(@NonNull String name) { + String serialized = LEGACY_PALETTE_2[Math.abs(name.hashCode()) % LEGACY_PALETTE_2.length]; + try { + return MaterialColor.fromSerialized(serialized); + } catch (MaterialColor.UnknownColorException e) { + return ContactColors.generateFor(name); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java new file mode 100644 index 00000000..8c9120d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ContactPhoto.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; + +import java.io.IOException; +import java.io.InputStream; + +public interface ContactPhoto extends Key { + + InputStream openInputStream(Context context) throws IOException; + + @Nullable Uri getUri(@NonNull Context context); + + boolean isProfilePhoto(); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java new file mode 100644 index 00000000..9a4e265c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +public interface FallbackContactPhoto { + + public Drawable asDrawable(Context context, int color); + public Drawable asDrawable(Context context, int color, boolean inverted); + public Drawable asSmallDrawable(Context context, int color, boolean inverted); + public Drawable asCallCard(Context context); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java new file mode 100644 index 00000000..96c535e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; + +/** + * Fallback resource based contact photo with a 20dp icon + */ +public final class FallbackPhoto20dp implements FallbackContactPhoto { + + @DrawableRes private final int drawable20dp; + + public FallbackPhoto20dp(@DrawableRes int drawable20dp) { + this.drawable20dp = drawable20dp; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return buildDrawable(context, color); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + return buildDrawable(context, color); + } + + @Override + public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + return buildDrawable(context, color); + } + + @Override + public Drawable asCallCard(Context context) { + throw new UnsupportedOperationException(); + } + + private @NonNull Drawable buildDrawable(@NonNull Context context, int color) { + Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); + Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp); + Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); + LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); + int foregroundInset = ViewUtil.dpToPx(2); + + DrawableCompat.setTint(background, color); + + drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); + + return drawable; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java new file mode 100644 index 00000000..e5b0aa7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; + +public final class FallbackPhoto80dp implements FallbackContactPhoto { + + @DrawableRes private final int drawable80dp; + private final int backgroundColor; + + public FallbackPhoto80dp(@DrawableRes int drawable80dp, int backgroundColor) { + this.drawable80dp = drawable80dp; + this.backgroundColor = backgroundColor; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return buildDrawable(context); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + return buildDrawable(context); + } + + @Override + public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + throw new UnsupportedOperationException(); + } + + @Override + public Drawable asCallCard(Context context) { + throw new UnsupportedOperationException(); + } + + private @NonNull Drawable buildDrawable(@NonNull Context context) { + Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); + Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp); + Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); + LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); + int foregroundInset = ViewUtil.dpToPx(24); + + DrawableCompat.setTint(background, backgroundColor); + + drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); + + return drawable; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java new file mode 100644 index 00000000..eae79143 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.text.TextUtils; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.regex.Pattern; + +public class GeneratedContactPhoto implements FallbackContactPhoto { + + private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+"); + private static final Typeface TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); + + private final String name; + private final int fallbackResId; + private final int targetSize; + private final int fontSize; + + public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId) { + this(name, fallbackResId, -1, ViewUtil.dpToPx(24)); + } + + public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId, int targetSize, int fontSize) { + this.name = name; + this.fallbackResId = fallbackResId; + this.targetSize = targetSize; + this.fontSize = fontSize; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color,false); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + int targetSize = this.targetSize != -1 + ? this.targetSize + : context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); + + String character = getAbbreviation(name); + + if (!TextUtils.isEmpty(character)) { + Drawable base = TextDrawable.builder() + .beginConfig() + .width(targetSize) + .height(targetSize) + .useFont(TYPEFACE) + .fontSize(fontSize) + .textColor(inverted ? color : Color.WHITE) + .endConfig() + .buildRound(character, inverted ? Color.WHITE : color); + + Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); + return new LayerDrawable(new Drawable[] { base, gradient }); + } + + return newFallbackDrawable(context, color, inverted); + } + + @Override + public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + return asDrawable(context, color, inverted); + } + + protected @DrawableRes int getFallbackResId() { + return fallbackResId; + } + + protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); + } + + private @Nullable String getAbbreviation(String name) { + String[] parts = name.split(" "); + StringBuilder builder = new StringBuilder(); + int count = 0; + + for (int i = 0; i < parts.length && count < 2; i++) { + String cleaned = PATTERN.matcher(parts[i]).replaceFirst(""); + if (!TextUtils.isEmpty(cleaned)) { + builder.appendCodePoint(cleaned.codePointAt(0)); + count++; + } + } + + if (builder.length() == 0) { + return null; + } else { + return builder.toString(); + } + } + + @Override + public Drawable asCallCard(Context context) { + return AppCompatResources.getDrawable(context, R.drawable.ic_person_large); + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java new file mode 100644 index 00000000..53369eb9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.Conversions; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; + +public final class GroupRecordContactPhoto implements ContactPhoto { + + private final GroupId groupId; + private final long avatarId; + + public GroupRecordContactPhoto(@NonNull GroupId groupId, long avatarId) { + this.groupId = groupId; + this.avatarId = avatarId; + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional groupRecord = groupDatabase.getGroup(groupId); + + if (!groupRecord.isPresent() || !AvatarHelper.hasAvatar(context, groupRecord.get().getRecipientId())) { + throw new IOException("No avatar for group: " + groupId); + } + + return AvatarHelper.getAvatar(context, groupRecord.get().getRecipientId()); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return null; + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(groupId.toString().getBytes()); + messageDigest.update(Conversions.longToByteArray(avatarId)); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GroupRecordContactPhoto)) return false; + + GroupRecordContactPhoto that = (GroupRecordContactPhoto)other; + return this.groupId.equals(that.groupId) && this.avatarId == that.avatarId; + } + + @Override + public int hashCode() { + return this.groupId.hashCode() ^ (int) avatarId; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java new file mode 100644 index 00000000..c14a762a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.util.Objects; + +public class ProfileContactPhoto implements ContactPhoto { + + private final @NonNull Recipient recipient; + private final @NonNull String avatarObject; + + public ProfileContactPhoto(@NonNull Recipient recipient, @Nullable String avatarObject) { + this.recipient = recipient; + this.avatarObject = avatarObject == null ? "" : avatarObject; + } + + @Override + public @NonNull InputStream openInputStream(Context context) throws IOException { + return AvatarHelper.getAvatar(context, recipient.getId()); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return null; + } + + @Override + public boolean isProfilePhoto() { + return true; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(recipient.getId().serialize().getBytes()); + messageDigest.update(avatarObject.getBytes()); + messageDigest.update(ByteUtil.longToByteArray(getFileLastModified())); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProfileContactPhoto that = (ProfileContactPhoto) o; + return recipient.equals(that.recipient) && + avatarObject.equals(that.avatarObject) && + getFileLastModified() == that.getFileLastModified(); + } + + @Override + public int hashCode() { + return Objects.hash(recipient, avatarObject, getFileLastModified()); + } + + private long getFileLastModified() { + if (!recipient.isSelf()) { + return 0; + } + + return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java new file mode 100644 index 00000000..272310d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.widget.ImageView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.makeramen.roundedimageview.RoundedDrawable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ContextUtil; + +public class ResourceContactPhoto implements FallbackContactPhoto { + + private final int resourceId; + private final int smallResourceId; + private final int callCardResourceId; + + private ImageView.ScaleType scaleType = ImageView.ScaleType.CENTER; + + public ResourceContactPhoto(@DrawableRes int resourceId) { + this(resourceId, resourceId, resourceId); + } + + public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int smallResourceId) { + this(resourceId, smallResourceId, resourceId); + } + + public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int smallResourceId, @DrawableRes int callCardResourceId) { + this.resourceId = resourceId; + this.callCardResourceId = callCardResourceId; + this.smallResourceId = smallResourceId; + } + + public void setScaleType(@NonNull ImageView.ScaleType scaleType) { + this.scaleType = scaleType; + } + + @Override + public @NonNull Drawable asDrawable(@NonNull Context context, int color) { + return asDrawable(context, color, false); + } + + @Override + public @NonNull Drawable asDrawable(@NonNull Context context, int color, boolean inverted) { + return buildDrawable(context, resourceId, color, inverted); + } + + @Override + public @NonNull Drawable asSmallDrawable(@NonNull Context context, int color, boolean inverted) { + return buildDrawable(context, smallResourceId, color, inverted); + } + + private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, int color, boolean inverted) { + Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); + + //noinspection ConstantConditions + foreground.setScaleType(scaleType); + + if (inverted) { + foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); + } + + Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); + + return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient}); + } + + @Override + public @Nullable Drawable asCallCard(@NonNull Context context) { + return AppCompatResources.getDrawable(context, callCardResourceId); + } + + private static class ExpandingLayerDrawable extends LayerDrawable { + public ExpandingLayerDrawable(@NonNull Drawable[] layers) { + super(layers); + } + + @Override + public int getIntrinsicWidth() { + return -1; + } + + @Override + public int getIntrinsicHeight() { + return -1; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java new file mode 100644 index 00000000..c1490cb2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/SystemContactPhoto.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.Conversions; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.security.MessageDigest; + +public class SystemContactPhoto implements ContactPhoto { + + private final RecipientId recipientId; + private final Uri contactPhotoUri; + private final long lastModifiedTime; + + public SystemContactPhoto(@NonNull RecipientId recipientId, @NonNull Uri contactPhotoUri, long lastModifiedTime) { + this.recipientId = recipientId; + this.contactPhotoUri = contactPhotoUri; + this.lastModifiedTime = lastModifiedTime; + } + + @Override + public InputStream openInputStream(Context context) throws FileNotFoundException { + return context.getContentResolver().openInputStream(contactPhotoUri); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return contactPhotoUri; + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(recipientId.serialize().getBytes()); + messageDigest.update(contactPhotoUri.toString().getBytes()); + messageDigest.update(Conversions.longToByteArray(lastModifiedTime)); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof SystemContactPhoto)) return false; + + SystemContactPhoto that = (SystemContactPhoto)other; + + return this.recipientId.equals(that.recipientId) && this.contactPhotoUri.equals(that.contactPhotoUri) && this.lastModifiedTime == that.lastModifiedTime; + } + + @Override + public int hashCode() { + return recipientId.hashCode() ^ contactPhotoUri.hashCode() ^ (int)lastModifiedTime; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java new file mode 100644 index 00000000..f38e6f0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.core.content.ContextCompat; + +import com.makeramen.roundedimageview.RoundedDrawable; + +import org.thoughtcrime.securesms.R; + +public class TransparentContactPhoto implements FallbackContactPhoto { + + public TransparentContactPhoto() {} + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color, false); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent)); + } + + @Override + public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + return asDrawable(context, color, inverted); + } + + @Override + public Drawable asCallCard(Context context) { + return ContextCompat.getDrawable(context, R.drawable.ic_contact_picture_large); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java new file mode 100644 index 00000000..47d2491a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryV2.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.contacts.sync; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper.DirectoryResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.push.IasTrustStore; +import org.thoughtcrime.securesms.util.SetUtil; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.internal.contacts.crypto.Quote; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Uses CDS to map E164's to UUIDs. + */ +class ContactDiscoveryV2 { + + private static final String TAG = Log.tag(ContactDiscoveryV2.class); + + private static final int MAX_NUMBERS = 20_500; + + @WorkerThread + static DirectoryResult getDirectoryResult(@NonNull Context context, + @NonNull Set databaseNumbers, + @NonNull Set systemNumbers) + throws IOException + { + Set allNumbers = SetUtil.union(databaseNumbers, systemNumbers); + FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(allNumbers, databaseNumbers); + Set sanitizedNumbers = sanitizeNumbers(inputResult.getNumbers()); + Set ignoredNumbers = new HashSet<>(); + + if (sanitizedNumbers.size() > MAX_NUMBERS) { + Set randomlySelected = randomlySelect(sanitizedNumbers, MAX_NUMBERS); + + ignoredNumbers = SetUtil.difference(sanitizedNumbers, randomlySelected); + sanitizedNumbers = randomlySelected; + } + + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + KeyStore iasKeyStore = getIasKeyStore(context); + + try { + Map results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE); + FuzzyPhoneNumberHelper.OutputResultV2 outputResult = FuzzyPhoneNumberHelper.generateOutputV2(results, inputResult); + + return new DirectoryResult(outputResult.getNumbers(), outputResult.getRewrites(), ignoredNumbers); + } catch (SignatureException | UnauthenticatedQuoteException | UnauthenticatedResponseException | Quote.InvalidQuoteFormatException |InvalidKeyException e) { + Log.w(TAG, "Attestation error.", e); + throw new IOException(e); + } + } + + static @NonNull DirectoryResult getDirectoryResult(@NonNull Context context, @NonNull String number) throws IOException { + return getDirectoryResult(context, Collections.singleton(number), Collections.singleton(number)); + } + + private static Set sanitizeNumbers(@NonNull Set numbers) { + return Stream.of(numbers).filter(number -> { + try { + return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0; + } catch (NumberFormatException e) { + return false; + } + }).collect(Collectors.toSet()); + } + + private static @NonNull Set randomlySelect(@NonNull Set numbers, int max) { + List list = new ArrayList<>(numbers); + Collections.shuffle(list); + + return new HashSet<>(list.subList(0, max)); + } + + private static KeyStore getIasKeyStore(@NonNull Context context) { + try { + TrustStore contactTrustStore = new IasTrustStore(context); + + KeyStore keyStore = KeyStore.getInstance("BKS"); + keyStore.load(contactTrustStore.getKeyStoreInputStream(), contactTrustStore.getKeyStorePassword().toCharArray()); + + return keyStore; + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java new file mode 100644 index 00000000..dbf70159 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -0,0 +1,542 @@ +package org.thoughtcrime.securesms.contacts.sync; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.thoughtcrime.securesms.contacts.ContactsDatabase; +import org.thoughtcrime.securesms.crypto.SessionUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle; +import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.sms.IncomingJoinedMessage; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; + +import java.io.IOException; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Manages all the stuff around determining if a user is registered or not. + */ +public class DirectoryHelper { + + private static final String TAG = Log.tag(DirectoryHelper.class); + + @WorkerThread + public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException { + if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) { + Log.w(TAG, "Have not yet set our own local number. Skipping."); + return; + } + + if (!Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + Log.w(TAG, "No contact permissions. Skipping."); + return; + } + + if (!SignalStore.registrationValues().isRegistrationComplete()) { + Log.w(TAG, "Registration is not yet complete. Skipping, but running a routine to possibly mark it complete."); + RegistrationUtil.maybeMarkRegistrationComplete(context); + return; + } + + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + Set databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers()); + Set systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context)); + + refreshNumbers(context, databaseNumbers, systemNumbers, notifyOfNewUsers); + + StorageSyncHelper.scheduleSyncForDataChange(); + } + + @WorkerThread + public static void refreshDirectoryFor(@NonNull Context context, @NonNull List recipients, boolean notifyOfNewUsers) throws IOException { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + + for (Recipient recipient : recipients) { + if (recipient.hasUuid() && !recipient.hasE164()) { + if (isUuidRegistered(context, recipient)) { + recipientDatabase.markRegistered(recipient.getId(), recipient.requireUuid()); + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + } + } + + Set numbers = Stream.of(recipients) + .filter(Recipient::hasE164) + .map(Recipient::requireE164) + .collect(Collectors.toSet()); + + refreshNumbers(context, numbers, numbers, notifyOfNewUsers); + } + + @WorkerThread + public static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException { + Stopwatch stopwatch = new Stopwatch("single"); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RegisteredState originalRegisteredState = recipient.resolve().getRegistered(); + RegisteredState newRegisteredState = null; + + if (recipient.hasUuid() && !recipient.hasE164()) { + boolean isRegistered = isUuidRegistered(context, recipient); + stopwatch.split("uuid-network"); + if (isRegistered) { + boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get()); + if (idChanged) { + Log.w(TAG, "ID changed during refresh by UUID."); + } + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + + stopwatch.split("uuid-disk"); + stopwatch.stop(TAG); + + return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED; + } + + if (!recipient.getE164().isPresent()) { + Log.w(TAG, "No UUID or E164?"); + return RegisteredState.NOT_REGISTERED; + } + + DirectoryResult result = ContactDiscoveryV2.getDirectoryResult(context, recipient.getE164().get()); + + stopwatch.split("e164-network"); + + if (result.getNumberRewrites().size() > 0) { + Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers."); + recipientDatabase.updatePhoneNumbers(result.getNumberRewrites()); + } + + if (result.getRegisteredNumbers().size() > 0) { + UUID uuid = result.getRegisteredNumbers().values().iterator().next(); + if (uuid != null) { + boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), uuid); + if (idChanged) { + recipient = Recipient.resolved(recipientDatabase.getByUuid(uuid).get()); + } + } else { + recipientDatabase.markRegistered(recipient.getId()); + } + } else if (recipient.hasUuid() && recipient.isRegistered() && hasCommunicatedWith(context, recipient)) { + if (isUuidRegistered(context, recipient)) { + recipientDatabase.markRegistered(recipient.getId(), recipient.requireUuid()); + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + stopwatch.split("e164-unlisted-network"); + } else { + recipientDatabase.markUnregistered(recipient.getId()); + } + + if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) { + updateContactsDatabase(context, Collections.singletonList(recipient.getId()), false, result.getNumberRewrites()); + } + + newRegisteredState = result.getRegisteredNumbers().size() > 0 ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED; + + if (newRegisteredState != originalRegisteredState) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + + if (notifyOfNewUsers && newRegisteredState == RegisteredState.REGISTERED && recipient.resolve().isSystemContact()) { + notifyNewUsers(context, Collections.singletonList(recipient.getId())); + } + + StorageSyncHelper.scheduleSyncForDataChange(); + } + + stopwatch.split("e164-disk"); + stopwatch.stop(TAG); + + return newRegisteredState; + } + + @WorkerThread + private static void refreshNumbers(@NonNull Context context, @NonNull Set databaseNumbers, @NonNull Set systemNumbers, boolean notifyOfNewUsers) throws IOException { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + Set allNumbers = SetUtil.union(databaseNumbers, systemNumbers); + + if (allNumbers.isEmpty()) { + Log.w(TAG, "No numbers to refresh!"); + return; + } + + Stopwatch stopwatch = new Stopwatch("refresh"); + + DirectoryResult result = ContactDiscoveryV2.getDirectoryResult(context, databaseNumbers, systemNumbers); + + stopwatch.split("network"); + + if (result.getNumberRewrites().size() > 0) { + Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers."); + recipientDatabase.updatePhoneNumbers(result.getNumberRewrites()); + } + + Map uuidMap = recipientDatabase.bulkProcessCdsResult(result.getRegisteredNumbers()); + Set activeNumbers = result.getRegisteredNumbers().keySet(); + Set activeIds = uuidMap.keySet(); + Set inactiveIds = Stream.of(allNumbers) + .filterNot(activeNumbers::contains) + .filterNot(n -> result.getNumberRewrites().containsKey(n)) + .filterNot(n -> result.getIgnoredNumbers().contains(n)) + .map(recipientDatabase::getOrInsertFromE164) + .collect(Collectors.toSet()); + + stopwatch.split("process-cds"); + + UnlistedResult unlistedResult = filterForUnlistedUsers(context, inactiveIds); + + inactiveIds.removeAll(unlistedResult.getPossiblyActive()); + + if (unlistedResult.getRetries().size() > 0) { + Log.i(TAG, "Some profile fetches failed to resolve. Assuming not-inactive for now and scheduling a retry."); + RetrieveProfileJob.enqueue(unlistedResult.getRetries()); + } + + stopwatch.split("handle-unlisted"); + + Set preExistingRegisteredUsers = new HashSet<>(recipientDatabase.getRegistered()); + + recipientDatabase.bulkUpdatedRegisteredStatus(uuidMap, inactiveIds); + + stopwatch.split("update-registered"); + + updateContactsDatabase(context, activeIds, true, result.getNumberRewrites()); + + stopwatch.split("contacts-db"); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); + } + + if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context) && notifyOfNewUsers) { + Set systemContacts = new HashSet<>(recipientDatabase.getSystemContacts()); + Set newlyRegisteredSystemContacts = new HashSet<>(activeIds); + + newlyRegisteredSystemContacts.removeAll(preExistingRegisteredUsers); + newlyRegisteredSystemContacts.retainAll(systemContacts); + + notifyNewUsers(context, newlyRegisteredSystemContacts); + } else { + TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true); + } + + stopwatch.stop(TAG); + } + + + private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException { + try { + ProfileUtil.retrieveProfileSync(context, recipient, SignalServiceProfile.RequestType.PROFILE); + return true; + } catch (NotFoundException e) { + return false; + } + } + + private static void updateContactsDatabase(@NonNull Context context, + @NonNull Collection activeIds, + boolean removeMissing, + @NonNull Map rewrites) + { + if (!Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + Log.w(TAG, "[updateContactsDatabase] No contact permissions. Skipping."); + return; + } + + AccountHolder account = getOrCreateSystemAccount(context); + + if (account == null) { + Log.w(TAG, "Failed to create an account!"); + return; + } + + try { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ContactsDatabase contactsDatabase = DatabaseFactory.getContactsDatabase(context); + List activeAddresses = Stream.of(activeIds) + .map(Recipient::resolved) + .filter(Recipient::hasE164) + .map(Recipient::requireE164) + .toList(); + + contactsDatabase.removeDeletedRawContacts(account.getAccount()); + contactsDatabase.setRegisteredUsers(account.getAccount(), activeAddresses, removeMissing); + + Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context); + BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate(); + + try { + while (cursor != null && cursor.moveToNext()) { + String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)); + + if (isValidContactNumber(number)) { + String formattedNumber = PhoneNumberFormatter.get(context).format(number); + String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber); + RecipientId recipientId = Recipient.externalContact(context, realNumber).getId(); + String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); + String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI)); + String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL)); + int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)); + Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)), + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY))); + + handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString()); + } + } + } finally { + handle.finish(); + } + + if (NotificationChannels.supported()) { + try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = recipients.getNext()) != null) { + NotificationChannels.updateContactChannelName(context, recipient); + } + } + } + } catch (RemoteException | OperationApplicationException e) { + Log.w(TAG, "Failed to update contacts.", e); + } + } + + private static boolean isValidContactNumber(@Nullable String number) { + return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number); + } + + private static @Nullable AccountHolder getOrCreateSystemAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account[] accounts = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID); + + AccountHolder account; + + if (accounts.length == 0) { + account = createAccount(context); + } else { + account = new AccountHolder(accounts[0], false); + } + + if (account != null && !ContentResolver.getSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY)) { + ContentResolver.setSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY, true); + } + + return account; + } + + private static @Nullable AccountHolder createAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account account = new Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID); + + if (accountManager.addAccountExplicitly(account, null, null)) { + Log.i(TAG, "Created new account..."); + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); + return new AccountHolder(account, true); + } else { + Log.w(TAG, "Failed to create account!"); + return null; + } + } + + private static void notifyNewUsers(@NonNull Context context, + @NonNull Collection newUsers) + { + if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return; + + for (RecipientId newUser: newUsers) { + Recipient recipient = Recipient.resolved(newUser); + if (!SessionUtil.hasSession(context, recipient.getId()) && + !recipient.isSelf() && + recipient.hasAUserSetDisplayName(context)) + { + IncomingJoinedMessage message = new IncomingJoinedMessage(recipient.getId()); + Optional insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message); + + if (insertResult.isPresent()) { + int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); + if (hour >= 9 && hour < 23) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true); + } else { + Log.i(TAG, "Not notifying of a new user due to the time of day. (Hour: " + hour + ")"); + } + } + } + } + } + + private static Set sanitizeNumbers(@NonNull Set numbers) { + return Stream.of(numbers).filter(number -> { + try { + return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0; + } catch (NumberFormatException e) { + return false; + } + }).collect(Collectors.toSet()); + } + + /** + * Users can mark themselves as 'unlisted' in CDS, meaning that even if CDS says they're + * unregistered, they might actually be registered. We need to double-check users who we already + * have UUIDs for. Also, we only want to bother doing this for users we have conversations for, + * so we will also only check for users that have a thread. + */ + private static UnlistedResult filterForUnlistedUsers(@NonNull Context context, @NonNull Set inactiveIds) { + List possiblyUnlisted = Stream.of(inactiveIds) + .map(Recipient::resolved) + .filter(Recipient::isRegistered) + .filter(Recipient::hasUuid) + .filter(r -> hasCommunicatedWith(context, r)) + .toList(); + + List>> futures = Stream.of(possiblyUnlisted) + .map(r -> new Pair<>(r, ProfileUtil.retrieveProfile(context, r, SignalServiceProfile.RequestType.PROFILE))) + .toList(); + Set potentiallyActiveIds = new HashSet<>(); + Set retries = new HashSet<>(); + + Stream.of(futures) + .forEach(pair -> { + try { + pair.second().get(5, TimeUnit.SECONDS); + potentiallyActiveIds.add(pair.first().getId()); + } catch (InterruptedException | TimeoutException e) { + retries.add(pair.first().getId()); + potentiallyActiveIds.add(pair.first().getId()); + } catch (ExecutionException e) { + if (!(e.getCause() instanceof NotFoundException)) { + retries.add(pair.first().getId()); + potentiallyActiveIds.add(pair.first().getId()); + } + } + }); + + return new UnlistedResult(potentiallyActiveIds, retries); + } + + private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) { + return DatabaseFactory.getThreadDatabase(context).hasThread(recipient.getId()) || + DatabaseFactory.getSessionDatabase(context).hasSessionFor(recipient.getId()); + } + + static class DirectoryResult { + private final Map registeredNumbers; + private final Map numberRewrites; + private final Set ignoredNumbers; + + DirectoryResult(@NonNull Map registeredNumbers, + @NonNull Map numberRewrites, + @NonNull Set ignoredNumbers) + { + this.registeredNumbers = registeredNumbers; + this.numberRewrites = numberRewrites; + this.ignoredNumbers = ignoredNumbers; + } + + + @NonNull Map getRegisteredNumbers() { + return registeredNumbers; + } + + @NonNull Map getNumberRewrites() { + return numberRewrites; + } + + @NonNull Set getIgnoredNumbers() { + return ignoredNumbers; + } + } + + private static class UnlistedResult { + private final Set possiblyActive; + private final Set retries; + + private UnlistedResult(@NonNull Set possiblyActive, @NonNull Set retries) { + this.possiblyActive = possiblyActive; + this.retries = retries; + } + + @NonNull Set getPossiblyActive() { + return possiblyActive; + } + + @NonNull Set getRetries() { + return retries; + } + } + + private static class AccountHolder { + private final boolean fresh; + private final Account account; + + private AccountHolder(Account account, boolean fresh) { + this.fresh = fresh; + this.account = account; + } + + @SuppressWarnings("unused") + public boolean isFresh() { + return fresh; + } + + public Account getAccount() { + return account; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java new file mode 100644 index 00000000..cad867f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/FuzzyPhoneNumberHelper.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.contacts.sync; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * A helper class to match a single number with multiple possible registered numbers. An example is + * Mexican phone numbers, which recently removed a '1' after their country code. The idea is that + * when doing contact intersection, we can try both with and without the '1' and make a decision + * based on the results. + */ +class FuzzyPhoneNumberHelper { + + /** + * This should be run on the list of eligible numbers for contact intersection so that we can + * create an updated list that has potentially more "fuzzy" number matches in it. + */ + static @NonNull InputResult generateInput(@NonNull Collection testNumbers, @NonNull Collection storedNumbers) { + Set allNumbers = new HashSet<>(testNumbers); + Map fuzzies = new HashMap<>(); + + for (String number : testNumbers) { + if (mx(number)) { + String add1 = mxAdd1(number); + String strip1 = mxStrip1(number); + + if (mxMissing1(number) && !storedNumbers.contains(add1) && allNumbers.add(add1)) { + fuzzies.put(number, add1); + } else if (mxHas1(number) && !storedNumbers.contains(strip1) && allNumbers.add(strip1)) { + fuzzies.put(number, strip1); + } + } + } + + return new InputResult(allNumbers, fuzzies); + } + + /** + * This should be run on the list of numbers we find out are registered with the server. Based on + * these results and our initial input set, we can decide if we need to rewrite which number we + * have stored locally. + */ + static @NonNull OutputResult generateOutput(@NonNull Collection registeredNumbers, @NonNull InputResult inputResult) { + Set allNumbers = new HashSet<>(registeredNumbers); + Map rewrites = new HashMap<>(); + + for (Map.Entry entry : inputResult.getFuzzies().entrySet()) { + if (registeredNumbers.contains(entry.getKey()) && registeredNumbers.contains(entry.getValue())) { + if (mxHas1(entry.getKey())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } else { + allNumbers.remove(entry.getValue()); + } + } else if (registeredNumbers.contains(entry.getValue())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } + } + + return new OutputResult(allNumbers, rewrites); + } + + /** + * This should be run on the list of numbers we find out are registered with the server. Based on + * these results and our initial input set, we can decide if we need to rewrite which number we + * have stored locally. + */ + static @NonNull OutputResultV2 generateOutputV2(@NonNull Map registeredNumbers, @NonNull InputResult inputResult) { + Map allNumbers = new HashMap<>(registeredNumbers); + Map rewrites = new HashMap<>(); + + for (Map.Entry entry : inputResult.getFuzzies().entrySet()) { + if (registeredNumbers.containsKey(entry.getKey()) && registeredNumbers.containsKey(entry.getValue())) { + if (mxHas1(entry.getKey())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } else { + allNumbers.remove(entry.getValue()); + } + } else if (registeredNumbers.containsKey(entry.getValue())) { + rewrites.put(entry.getKey(), entry.getValue()); + allNumbers.remove(entry.getKey()); + } + } + + return new OutputResultV2(allNumbers, rewrites); + } + + + private static boolean mx(@NonNull String number) { + return number.startsWith("+52") && (number.length() == 13 || number.length() == 14); + } + + private static boolean mxHas1(@NonNull String number) { + return number.startsWith("+521") && number.length() == 14; + } + + private static boolean mxMissing1(@NonNull String number) { + return number.startsWith("+52") && !number.startsWith("+521") && number.length() == 13; + } + + private static @NonNull String mxStrip1(@NonNull String number) { + return mxHas1(number) ? "+52" + number.substring("+521".length()) + : number; + } + + private static @NonNull String mxAdd1(@NonNull String number) { + return mxMissing1(number) ? "+521" + number.substring("+52".length()) + : number; + } + + + public static class InputResult { + private final Set numbers; + private final Map fuzzies; + + @VisibleForTesting + InputResult(@NonNull Set numbers, @NonNull Map fuzzies) { + this.numbers = numbers; + this.fuzzies = fuzzies; + } + + public @NonNull Set getNumbers() { + return numbers; + } + + public @NonNull Map getFuzzies() { + return fuzzies; + } + } + + public static class OutputResult { + private final Set numbers; + private final Map rewrites; + + private OutputResult(@NonNull Set numbers, @NonNull Map rewrites) { + this.numbers = numbers; + this.rewrites = rewrites; + } + + public @NonNull Set getNumbers() { + return numbers; + } + + public @NonNull Map getRewrites() { + return rewrites; + } + } + + public static class OutputResultV2 { + private final Map numbers; + private final Map rewrites; + + private OutputResultV2(@NonNull Map numbers, @NonNull Map rewrites) { + this.numbers = numbers; + this.rewrites = rewrites; + } + + public @NonNull Map getNumbers() { + return numbers; + } + + public @NonNull Map getRewrites() { + return rewrites; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java new file mode 100644 index 00000000..7d98289f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java @@ -0,0 +1,667 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public class Contact implements Parcelable { + + @JsonProperty + private final Name name; + + @JsonProperty + private final String organization; + + @JsonProperty + private final List phoneNumbers; + + @JsonProperty + private final List emails; + + @JsonProperty + private final List postalAddresses; + + @JsonProperty + private final Avatar avatar; + + public Contact(@JsonProperty("name") @NonNull Name name, + @JsonProperty("organization") @Nullable String organization, + @JsonProperty("phoneNumbers") @NonNull List phoneNumbers, + @JsonProperty("emails") @NonNull List emails, + @JsonProperty("postalAddresses") @NonNull List postalAddresses, + @JsonProperty("avatar") @Nullable Avatar avatar) + { + this.name = name; + this.organization = organization; + this.phoneNumbers = Collections.unmodifiableList(phoneNumbers); + this.emails = Collections.unmodifiableList(emails); + this.postalAddresses = Collections.unmodifiableList(postalAddresses); + this.avatar = avatar; + } + + public Contact(@NonNull Contact contact, @Nullable Avatar avatar) { + this(contact.getName(), + contact.getOrganization(), + contact.getPhoneNumbers(), + contact.getEmails(), + contact.getPostalAddresses(), + avatar); + } + + private Contact(Parcel in) { + this(in.readParcelable(Name.class.getClassLoader()), + in.readString(), + in.createTypedArrayList(Phone.CREATOR), + in.createTypedArrayList(Email.CREATOR), + in.createTypedArrayList(PostalAddress.CREATOR), + in.readParcelable(Avatar.class.getClassLoader())); + } + + public @NonNull Name getName() { + return name; + } + + public @Nullable String getOrganization() { + return organization; + } + + public @NonNull List getPhoneNumbers() { + return phoneNumbers; + } + + public @NonNull List getEmails() { + return emails; + } + + public @NonNull List getPostalAddresses() { + return postalAddresses; + } + + public @Nullable Avatar getAvatar() { + return avatar; + } + + @JsonIgnore + public @Nullable Attachment getAvatarAttachment() { + return avatar != null ? avatar.getAttachment() : null; + } + + public String serialize() throws IOException { + return JsonUtils.toJson(this); + } + + public static Contact deserialize(@NonNull String serialized) throws IOException { + return JsonUtils.fromJson(serialized, Contact.class); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(name, flags); + dest.writeString(organization); + dest.writeTypedList(phoneNumbers); + dest.writeTypedList(emails); + dest.writeTypedList(postalAddresses); + dest.writeParcelable(avatar, flags); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Contact createFromParcel(Parcel in) { + return new Contact(in); + } + + @Override + public Contact[] newArray(int size) { + return new Contact[size]; + } + }; + + public static class Name implements Parcelable { + + @JsonProperty + private final String displayName; + + @JsonProperty + private final String givenName; + + @JsonProperty + private final String familyName; + + @JsonProperty + private final String prefix; + + @JsonProperty + private final String suffix; + + @JsonProperty + private final String middleName; + + Name(@JsonProperty("displayName") @Nullable String displayName, + @JsonProperty("givenName") @Nullable String givenName, + @JsonProperty("familyName") @Nullable String familyName, + @JsonProperty("prefix") @Nullable String prefix, + @JsonProperty("suffix") @Nullable String suffix, + @JsonProperty("middleName") @Nullable String middleName) + { + this.displayName = displayName; + this.givenName = givenName; + this.familyName = familyName; + this.prefix = prefix; + this.suffix = suffix; + this.middleName = middleName; + } + + private Name(Parcel in) { + this(in.readString(), in.readString(), in.readString(), in.readString(), in.readString(), in.readString()); + } + + public @Nullable String getDisplayName() { + return displayName; + } + + public @Nullable String getGivenName() { + return givenName; + } + + public @Nullable String getFamilyName() { + return familyName; + } + + public @Nullable String getPrefix() { + return prefix; + } + + public @Nullable String getSuffix() { + return suffix; + } + + public @Nullable String getMiddleName() { + return middleName; + } + + public boolean isEmpty() { + return TextUtils.isEmpty(displayName) && + TextUtils.isEmpty(givenName) && + TextUtils.isEmpty(familyName) && + TextUtils.isEmpty(prefix) && + TextUtils.isEmpty(suffix) && + TextUtils.isEmpty(middleName); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(displayName); + dest.writeString(givenName); + dest.writeString(familyName); + dest.writeString(prefix); + dest.writeString(suffix); + dest.writeString(middleName); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Name createFromParcel(Parcel in) { + return new Name(in); + } + + @Override + public Name[] newArray(int size) { + return new Name[size]; + } + }; + } + + public static class Phone implements Selectable, Parcelable { + + @JsonProperty + private final String number; + + @JsonProperty + private final Type type; + + @JsonProperty + private final String label; + + @JsonIgnore + private boolean selected; + + Phone(@JsonProperty("number") @NonNull String number, + @JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label) + { + this.number = number; + this.type = type; + this.label = label; + this.selected = true; + } + + private Phone(Parcel in) { + this(in.readString(), Type.valueOf(in.readString()), in.readString()); + } + + public @NonNull String getNumber() { + return number; + } + + public @NonNull Type getType() { + return type; + } + + public @Nullable String getLabel() { + return label; + } + + @Override + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(number); + dest.writeString(type.name()); + dest.writeString(label); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Phone createFromParcel(Parcel in) { + return new Phone(in); + } + + @Override + public Phone[] newArray(int size) { + return new Phone[size]; + } + }; + + public enum Type { + HOME, MOBILE, WORK, CUSTOM + } + } + + public static class Email implements Selectable, Parcelable { + + @JsonProperty + private final String email; + + @JsonProperty + private final Type type; + + @JsonProperty + private final String label; + + @JsonIgnore + private boolean selected; + + Email(@JsonProperty("email") @NonNull String email, + @JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label) + { + this.email = email; + this.type = type; + this.label = label; + this.selected = true; + } + + private Email(Parcel in) { + this(in.readString(), Type.valueOf(in.readString()), in.readString()); + } + + public @NonNull String getEmail() { + return email; + } + + public @NonNull Type getType() { + return type; + } + + public @NonNull String getLabel() { + return label; + } + + @Override + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(email); + dest.writeString(type.name()); + dest.writeString(label); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Email createFromParcel(Parcel in) { + return new Email(in); + } + + @Override + public Email[] newArray(int size) { + return new Email[size]; + } + }; + + public enum Type { + HOME, MOBILE, WORK, CUSTOM + } + } + + public static class PostalAddress implements Selectable, Parcelable { + + @JsonProperty + private final Type type; + + @JsonProperty + private final String label; + + @JsonProperty + private final String street; + + @JsonProperty + private final String poBox; + + @JsonProperty + private final String neighborhood; + + @JsonProperty + private final String city; + + @JsonProperty + private final String region; + + @JsonProperty + private final String postalCode; + + @JsonProperty + private final String country; + + @JsonIgnore + private boolean selected; + + PostalAddress(@JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label, + @JsonProperty("street") @Nullable String street, + @JsonProperty("poBox") @Nullable String poBox, + @JsonProperty("neighborhood") @Nullable String neighborhood, + @JsonProperty("city") @Nullable String city, + @JsonProperty("region") @Nullable String region, + @JsonProperty("postalCode") @Nullable String postalCode, + @JsonProperty("country") @Nullable String country) + { + this.type = type; + this.label = label; + this.street = street; + this.poBox = poBox; + this.neighborhood = neighborhood; + this.city = city; + this.region = region; + this.postalCode = postalCode; + this.country = country; + this.selected = true; + } + + private PostalAddress(Parcel in) { + this(Type.valueOf(in.readString()), + in.readString(), + in.readString(), + in.readString(), + in.readString(), + in.readString(), + in.readString(), + in.readString(), + in.readString()); + } + + public @NonNull Type getType() { + return type; + } + + public @Nullable String getLabel() { + return label; + } + + public @Nullable String getStreet() { + return street; + } + + public @Nullable String getPoBox() { + return poBox; + } + + public @Nullable String getNeighborhood() { + return neighborhood; + } + + public @Nullable String getCity() { + return city; + } + + public @Nullable String getRegion() { + return region; + } + + public @Nullable String getPostalCode() { + return postalCode; + } + + public @Nullable String getCountry() { + return country; + } + + @Override + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(type.name()); + dest.writeString(label); + dest.writeString(street); + dest.writeString(poBox); + dest.writeString(neighborhood); + dest.writeString(city); + dest.writeString(region); + dest.writeString(postalCode); + dest.writeString(country); + } + + public static final Creator CREATOR = new Creator() { + @Override + public PostalAddress createFromParcel(Parcel in) { + return new PostalAddress(in); + } + + @Override + public PostalAddress[] newArray(int size) { + return new PostalAddress[size]; + } + }; + + @Override + public @NonNull String toString() { + StringBuilder builder = new StringBuilder(); + + if (!TextUtils.isEmpty(street)) { + builder.append(street).append('\n'); + } + + if (!TextUtils.isEmpty(poBox)) { + builder.append(poBox).append('\n'); + } + + if (!TextUtils.isEmpty(neighborhood)) { + builder.append(neighborhood).append('\n'); + } + + if (!TextUtils.isEmpty(city) && !TextUtils.isEmpty(region)) { + builder.append(city).append(", ").append(region); + } else if (!TextUtils.isEmpty(city)) { + builder.append(city).append(' '); + } else if (!TextUtils.isEmpty(region)) { + builder.append(region).append(' '); + } + + if (!TextUtils.isEmpty(postalCode)) { + builder.append(postalCode); + } + + if (!TextUtils.isEmpty(country)) { + builder.append('\n').append(country); + } + + return builder.toString().trim(); + } + + public enum Type { + HOME, WORK, CUSTOM + } + } + + public static class Avatar implements Selectable, Parcelable { + + @JsonProperty + private final AttachmentId attachmentId; + + @JsonProperty + private final boolean isProfile; + + @JsonIgnore + private final Attachment attachment; + + @JsonIgnore + private boolean selected; + + public Avatar(@Nullable AttachmentId attachmentId, @Nullable Attachment attachment, boolean isProfile) { + this.attachmentId = attachmentId; + this.attachment = attachment; + this.isProfile = isProfile; + this.selected = true; + } + + Avatar(@Nullable Uri attachmentUri, boolean isProfile) { + this(null, attachmentFromUri(attachmentUri), isProfile); + } + + @JsonCreator + private Avatar(@JsonProperty("attachmentId") @Nullable AttachmentId attachmentId, @JsonProperty("isProfile") boolean isProfile) { + this(attachmentId, null, isProfile); + } + + private Avatar(Parcel in) { + this((Uri) in.readParcelable(Uri.class.getClassLoader()), in.readByte() != 0); + } + + public @Nullable AttachmentId getAttachmentId() { + return attachmentId; + } + + public @Nullable Attachment getAttachment() { + return attachment; + } + + public boolean isProfile() { + return isProfile; + } + + @Override + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + private static Attachment attachmentFromUri(@Nullable Uri uri) { + if (uri == null) return null; + return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, null, null, null, null, null); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(attachment != null ? attachment.getUri() : null, flags); + dest.writeByte((byte) (isProfile ? 1 : 0)); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Avatar createFromParcel(Parcel in) { + return new Avatar(in); + } + + @Override + public Avatar[] newArray(int size) { + return new Avatar[size]; + } + }; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactFieldAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactFieldAdapter.java new file mode 100644 index 00000000..fc5a36ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactFieldAdapter.java @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.content.Context; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.Contact.Phone; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; +import static org.thoughtcrime.securesms.contactshare.Contact.Email; +import static org.thoughtcrime.securesms.contactshare.Contact.PostalAddress; + +class ContactFieldAdapter extends RecyclerView.Adapter { + + private final Locale locale; + private final boolean selectable; + private final List fields; + private final GlideRequests glideRequests; + + public ContactFieldAdapter(@NonNull Locale locale, @NonNull GlideRequests glideRequests, boolean selectable) { + this.locale = locale; + this.glideRequests = glideRequests; + this.selectable = selectable; + this.fields = new ArrayList<>(); + } + + @Override + public @NonNull ContactFieldViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ContactFieldViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_selectable_contact_field, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ContactFieldViewHolder holder, int position) { + holder.bind(fields.get(position), glideRequests, selectable); + } + + @Override + public void onViewRecycled(@NonNull ContactFieldViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return fields.size(); + } + + void setFields(@NonNull Context context, + @Nullable Avatar avatar, + @NonNull List phoneNumbers, + @NonNull List emails, + @NonNull List postalAddresses) + { + fields.clear(); + + if (avatar != null) { + fields.add(new Field(avatar)); + } + + fields.addAll(Stream.of(phoneNumbers).map(phone -> new Field(context, phone, locale)).toList()); + fields.addAll(Stream.of(emails).map(email -> new Field(context, email)).toList()); + fields.addAll(Stream.of(postalAddresses).map(address -> new Field(context, address)).toList()); + + notifyDataSetChanged(); + } + + static class ContactFieldViewHolder extends RecyclerView.ViewHolder { + + private final TextView value; + private final TextView label; + private final ImageView icon; + private final ImageView avatar; + private final CheckBox checkBox; + + ContactFieldViewHolder(View itemView) { + super(itemView); + + value = itemView.findViewById(R.id.contact_field_value); + label = itemView.findViewById(R.id.contact_field_label); + icon = itemView.findViewById(R.id.contact_field_icon); + avatar = itemView.findViewById(R.id.contact_field_avatar); + checkBox = itemView.findViewById(R.id.contact_field_checkbox); + } + + void bind(@NonNull Field field, @NonNull GlideRequests glideRequests, boolean selectable) { + value.setMaxLines(field.maxLines); + value.setText(field.value); + label.setText(field.label); + icon.setImageResource(field.iconResId); + + if (field.iconUri != null) { + avatar.setVisibility(View.VISIBLE); + glideRequests.load(field.iconUri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .circleCrop() + .into(avatar); + } else { + avatar.setVisibility(View.GONE); + } + + if (selectable) { + checkBox.setVisibility(View.VISIBLE); + checkBox.setOnCheckedChangeListener(null); + checkBox.setChecked(field.isSelected()); + checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> field.setSelected(isChecked)); + } else { + checkBox.setVisibility(View.GONE); + checkBox.setOnCheckedChangeListener(null); + } + } + + void recycle() { + checkBox.setOnCheckedChangeListener(null); + } + } + + static class Field { + + final String value; + final String label; + final int iconResId; + final int maxLines; + final Selectable selectable; + + @Nullable + final Uri iconUri; + + Field(@NonNull Context context, @NonNull Phone phoneNumber, @NonNull Locale locale) { + this.value = ContactUtil.getPrettyPhoneNumber(phoneNumber, locale); + this.iconResId = R.drawable.ic_phone_right_unlock_solid_24; + this.iconUri = null; + this.maxLines = 1; + this.selectable = phoneNumber; + + switch (phoneNumber.getType()) { + case HOME: + label = context.getString(R.string.ContactShareEditActivity_type_home); + break; + case MOBILE: + label = context.getString(R.string.ContactShareEditActivity_type_mobile); + break; + case WORK: + label = context.getString(R.string.ContactShareEditActivity_type_work); + break; + case CUSTOM: + label = phoneNumber.getLabel() != null ? phoneNumber.getLabel() : ""; + break; + default: + label = ""; + } + } + + Field(@NonNull Context context, @NonNull Email email) { + this.value = email.getEmail(); + this.iconResId = R.drawable.baseline_email_white_24; + this.iconUri = null; + this.maxLines = 1; + this.selectable = email; + + switch (email.getType()) { + case HOME: + label = context.getString(R.string.ContactShareEditActivity_type_home); + break; + case MOBILE: + label = context.getString(R.string.ContactShareEditActivity_type_mobile); + break; + case WORK: + label = context.getString(R.string.ContactShareEditActivity_type_work); + break; + case CUSTOM: + label = email.getLabel() != null ? email.getLabel() : ""; + break; + default: + label = ""; + } + } + + Field(@NonNull Context context, @NonNull PostalAddress postalAddress) { + this.value = postalAddress.toString(); + this.iconResId = R.drawable.ic_location_on_white_24dp; + this.iconUri = null; + this.maxLines = 3; + this.selectable = postalAddress; + + switch (postalAddress.getType()) { + case HOME: + label = context.getString(R.string.ContactShareEditActivity_type_home); + break; + case WORK: + label = context.getString(R.string.ContactShareEditActivity_type_work); + break; + case CUSTOM: + label = postalAddress.getLabel() != null ? postalAddress.getLabel() : context.getString(R.string.ContactShareEditActivity_type_missing); + break; + default: + label = context.getString(R.string.ContactShareEditActivity_type_missing); + } + } + + Field(@NonNull Avatar avatar) { + this.value = ""; + this.iconResId = R.drawable.baseline_account_circle_white_24; + this.iconUri = avatar.getAttachment() != null ? avatar.getAttachment().getUri() : null; + this.maxLines = 1; + this.selectable = avatar; + this.label = ""; + } + + void setSelected(boolean selected) { + selectable.setSelected(selected); + } + + boolean isSelected() { + return selectable.isSelected(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java new file mode 100644 index 00000000..99194e07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java @@ -0,0 +1,172 @@ +package org.thoughtcrime.securesms.contactshare; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.PointerAttachment; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; +import static org.thoughtcrime.securesms.contactshare.Contact.Email; +import static org.thoughtcrime.securesms.contactshare.Contact.Name; +import static org.thoughtcrime.securesms.contactshare.Contact.Phone; +import static org.thoughtcrime.securesms.contactshare.Contact.PostalAddress; + +public class ContactModelMapper { + + public static SharedContact.Builder localToRemoteBuilder(@NonNull Contact contact) { + List phoneNumbers = new ArrayList<>(contact.getPhoneNumbers().size()); + List emails = new ArrayList<>(contact.getEmails().size()); + List postalAddresses = new ArrayList<>(contact.getPostalAddresses().size()); + + for (Phone phone : contact.getPhoneNumbers()) { + phoneNumbers.add(new SharedContact.Phone.Builder().setValue(phone.getNumber()) + .setType(localToRemoteType(phone.getType())) + .setLabel(phone.getLabel()) + .build()); + } + + for (Email email : contact.getEmails()) { + emails.add(new SharedContact.Email.Builder().setValue(email.getEmail()) + .setType(localToRemoteType(email.getType())) + .setLabel(email.getLabel()) + .build()); + } + + for (PostalAddress postalAddress : contact.getPostalAddresses()) { + postalAddresses.add(new SharedContact.PostalAddress.Builder().setType(localToRemoteType(postalAddress.getType())) + .setLabel(postalAddress.getLabel()) + .setStreet(postalAddress.getStreet()) + .setPobox(postalAddress.getPoBox()) + .setNeighborhood(postalAddress.getNeighborhood()) + .setCity(postalAddress.getCity()) + .setRegion(postalAddress.getRegion()) + .setPostcode(postalAddress.getPostalCode()) + .setCountry(postalAddress.getCountry()) + .build()); + } + + SharedContact.Name name = new SharedContact.Name.Builder().setDisplay(contact.getName().getDisplayName()) + .setGiven(contact.getName().getGivenName()) + .setFamily(contact.getName().getFamilyName()) + .setPrefix(contact.getName().getPrefix()) + .setSuffix(contact.getName().getSuffix()) + .setMiddle(contact.getName().getMiddleName()) + .build(); + + return new SharedContact.Builder().setName(name) + .withOrganization(contact.getOrganization()) + .withPhones(phoneNumbers) + .withEmails(emails) + .withAddresses(postalAddresses); + } + + public static Contact remoteToLocal(@NonNull SharedContact sharedContact) { + Name name = new Name(sharedContact.getName().getDisplay().orNull(), + sharedContact.getName().getGiven().orNull(), + sharedContact.getName().getFamily().orNull(), + sharedContact.getName().getPrefix().orNull(), + sharedContact.getName().getSuffix().orNull(), + sharedContact.getName().getMiddle().orNull()); + + List phoneNumbers = new LinkedList<>(); + if (sharedContact.getPhone().isPresent()) { + for (SharedContact.Phone phone : sharedContact.getPhone().get()) { + phoneNumbers.add(new Phone(phone.getValue(), + remoteToLocalType(phone.getType()), + phone.getLabel().orNull())); + } + } + + List emails = new LinkedList<>(); + if (sharedContact.getEmail().isPresent()) { + for (SharedContact.Email email : sharedContact.getEmail().get()) { + emails.add(new Email(email.getValue(), + remoteToLocalType(email.getType()), + email.getLabel().orNull())); + } + } + + List postalAddresses = new LinkedList<>(); + if (sharedContact.getAddress().isPresent()) { + for (SharedContact.PostalAddress postalAddress : sharedContact.getAddress().get()) { + postalAddresses.add(new PostalAddress(remoteToLocalType(postalAddress.getType()), + postalAddress.getLabel().orNull(), + postalAddress.getStreet().orNull(), + postalAddress.getPobox().orNull(), + postalAddress.getNeighborhood().orNull(), + postalAddress.getCity().orNull(), + postalAddress.getRegion().orNull(), + postalAddress.getPostcode().orNull(), + postalAddress.getCountry().orNull())); + } + } + + Avatar avatar = null; + if (sharedContact.getAvatar().isPresent()) { + Attachment attachment = PointerAttachment.forPointer(Optional.of(sharedContact.getAvatar().get().getAttachment().asPointer())).get(); + boolean isProfile = sharedContact.getAvatar().get().isProfile(); + + avatar = new Avatar(null, attachment, isProfile); + } + + return new Contact(name, sharedContact.getOrganization().orNull(), phoneNumbers, emails, postalAddresses, avatar); + } + + private static Phone.Type remoteToLocalType(SharedContact.Phone.Type type) { + switch (type) { + case HOME: return Phone.Type.HOME; + case MOBILE: return Phone.Type.MOBILE; + case WORK: return Phone.Type.WORK; + default: return Phone.Type.CUSTOM; + } + } + + private static Email.Type remoteToLocalType(SharedContact.Email.Type type) { + switch (type) { + case HOME: return Email.Type.HOME; + case MOBILE: return Email.Type.MOBILE; + case WORK: return Email.Type.WORK; + default: return Email.Type.CUSTOM; + } + } + + private static PostalAddress.Type remoteToLocalType(SharedContact.PostalAddress.Type type) { + switch (type) { + case HOME: return PostalAddress.Type.HOME; + case WORK: return PostalAddress.Type.WORK; + default: return PostalAddress.Type.CUSTOM; + } + } + + private static SharedContact.Phone.Type localToRemoteType(Phone.Type type) { + switch (type) { + case HOME: return SharedContact.Phone.Type.HOME; + case MOBILE: return SharedContact.Phone.Type.MOBILE; + case WORK: return SharedContact.Phone.Type.WORK; + default: return SharedContact.Phone.Type.CUSTOM; + } + } + + private static SharedContact.Email.Type localToRemoteType(Email.Type type) { + switch (type) { + case HOME: return SharedContact.Email.Type.HOME; + case MOBILE: return SharedContact.Email.Type.MOBILE; + case WORK: return SharedContact.Email.Type.WORK; + default: return SharedContact.Email.Type.CUSTOM; + } + } + + private static SharedContact.PostalAddress.Type localToRemoteType(PostalAddress.Type type) { + switch (type) { + case HOME: return SharedContact.PostalAddress.Type.HOME; + case WORK: return SharedContact.PostalAddress.Type.WORK; + default: return SharedContact.PostalAddress.Type.CUSTOM; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditActivity.java new file mode 100644 index 00000000..67f5c565 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditActivity.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +import static org.thoughtcrime.securesms.contactshare.Contact.Name; + +public class ContactNameEditActivity extends PassphraseRequiredActivity { + + public static final String KEY_NAME = "name"; + public static final String KEY_CONTACT_INDEX = "contact_index"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private TextView displayNameView; + private ContactNameEditViewModel viewModel; + + static Intent getIntent(@NonNull Context context, @NonNull Name name, int contactPosition) { + Intent intent = new Intent(context, ContactNameEditActivity.class); + intent.putExtra(KEY_NAME, name); + intent.putExtra(KEY_CONTACT_INDEX, contactPosition); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + + if (getIntent() == null) { + throw new IllegalStateException("You must supply extras to this activity. Please use the #getIntent() method."); + } + + Name name = getIntent().getParcelableExtra(KEY_NAME); + if (name == null) { + throw new IllegalStateException("You must supply a name to this activity. Please use the #getIntent() method."); + } + + setContentView(R.layout.activity_contact_name_edit); + + initializeToolbar(); + initializeViews(name); + + viewModel = ViewModelProviders.of(this).get(ContactNameEditViewModel.class); + viewModel.setName(name); + viewModel.getDisplayName().observe(this, displayNameView::setText); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + private void initializeToolbar() { + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + toolbar.setTitle(""); + toolbar.setNavigationOnClickListener(v -> { + Intent resultIntent = new Intent(); + resultIntent.putExtra(KEY_NAME, viewModel.getName()); + resultIntent.putExtra(KEY_CONTACT_INDEX, getIntent().getIntExtra(KEY_CONTACT_INDEX, -1)); + setResult(RESULT_OK, resultIntent); + finish(); + }); + } + + private void initializeViews(@NonNull Name name) { + displayNameView = findViewById(R.id.name_edit_display_name); + + TextView givenName = findViewById(R.id.name_edit_given_name); + TextView familyName = findViewById(R.id.name_edit_family_name); + TextView middleName = findViewById(R.id.name_edit_middle_name); + TextView prefix = findViewById(R.id.name_edit_prefix); + TextView suffix = findViewById(R.id.name_edit_suffix); + + givenName.setText(name.getGivenName()); + familyName.setText(name.getFamilyName()); + middleName.setText(name.getMiddleName()); + prefix.setText(name.getPrefix()); + suffix.setText(name.getSuffix()); + + givenName.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.updateGivenName(text); + } + }); + + familyName.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.updateFamilyName(text); + } + }); + + middleName.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.updateMiddleName(text); + } + }); + + prefix.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.updatePrefix(text); + } + }); + + suffix.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.updateSuffix(text); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditViewModel.java new file mode 100644 index 00000000..191e43e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactNameEditViewModel.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.util.cjkv.CJKVUtil; + +import static org.thoughtcrime.securesms.contactshare.Contact.Name; + +public class ContactNameEditViewModel extends ViewModel { + + private final MutableLiveData displayName; + + private String givenName; + private String familyName; + private String middleName; + private String prefix; + private String suffix; + + public ContactNameEditViewModel() { + this.displayName = new MutableLiveData<>(); + } + + void setName(@NonNull Name name) { + givenName = name.getGivenName(); + familyName = name.getFamilyName(); + middleName = name.getMiddleName(); + prefix = name.getPrefix(); + suffix = name.getSuffix(); + + displayName.postValue(buildDisplayName()); + } + + Name getName() { + return new Name(displayName.getValue(), givenName, familyName, prefix, suffix, middleName); + } + + LiveData getDisplayName() { + return displayName; + } + + void updateGivenName(@NonNull String givenName) { + this.givenName = givenName; + displayName.postValue(buildDisplayName()); + } + + void updateFamilyName(@NonNull String familyName) { + this.familyName = familyName; + displayName.postValue(buildDisplayName()); + } + + void updatePrefix(@NonNull String prefix) { + this.prefix = prefix; + displayName.postValue(buildDisplayName()); + } + + void updateSuffix(@NonNull String suffix) { + this.suffix = suffix; + displayName.postValue(buildDisplayName()); + } + + void updateMiddleName(@NonNull String middleName) { + this.middleName = middleName; + displayName.postValue(buildDisplayName()); + } + + private String buildDisplayName() { + boolean isCJKV = CJKVUtil.isCJKV(givenName) && + CJKVUtil.isCJKV(middleName) && + CJKVUtil.isCJKV(familyName) && + CJKVUtil.isCJKV(prefix) && + CJKVUtil.isCJKV(suffix); + + if (isCJKV) { + return joinString(familyName, givenName, prefix, suffix, middleName); + } + return joinString(prefix, givenName, middleName, familyName, suffix); + } + + private String joinString(String... values) { + StringBuilder builder = new StringBuilder(); + + for (String value : values) { + if (!TextUtils.isEmpty(value)) { + builder.append(value).append(' '); + } + } + + return builder.toString().trim(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java new file mode 100644 index 00000000..2b677e42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; + +import java.util.ArrayList; +import java.util.List; + +import static org.thoughtcrime.securesms.contactshare.Contact.Name; +import static org.thoughtcrime.securesms.contactshare.ContactShareEditViewModel.Event; +import static org.thoughtcrime.securesms.contactshare.ContactShareEditViewModel.Factory; + +public class ContactShareEditActivity extends PassphraseRequiredActivity implements ContactShareEditAdapter.EventListener { + + public static final String KEY_CONTACTS = "contacts"; + private static final String KEY_CONTACT_URIS = "contact_uris"; + private static final int CODE_NAME_EDIT = 55; + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private ContactShareEditViewModel viewModel; + + public static Intent getIntent(@NonNull Context context, @NonNull List contactUris) { + ArrayList contactUriList = new ArrayList<>(contactUris); + + Intent intent = new Intent(context, ContactShareEditActivity.class); + intent.putParcelableArrayListExtra(KEY_CONTACT_URIS, contactUriList); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.activity_contact_share_edit); + + if (getIntent() == null) { + throw new IllegalStateException("You must supply extras to this activity. Please use the #getIntent() method."); + } + + List contactUris = getIntent().getParcelableArrayListExtra(KEY_CONTACT_URIS); + if (contactUris == null) { + throw new IllegalStateException("You must supply contact Uri's to this activity. Please use the #getIntent() method."); + } + + View sendButton = findViewById(R.id.contact_share_edit_send); + sendButton.setOnClickListener(v -> onSendClicked(viewModel.getFinalizedContacts())); + + RecyclerView contactList = findViewById(R.id.contact_share_edit_list); + contactList.setLayoutManager(new LinearLayoutManager(this)); + contactList.getLayoutManager().setAutoMeasureEnabled(true); + + ContactShareEditAdapter contactAdapter = new ContactShareEditAdapter(GlideApp.with(this), dynamicLanguage.getCurrentLocale(), this); + contactList.setAdapter(contactAdapter); + + SharedContactRepository contactRepository = new SharedContactRepository(this, + AsyncTask.THREAD_POOL_EXECUTOR, + DatabaseFactory.getContactsDatabase(this)); + + viewModel = ViewModelProviders.of(this, new Factory(contactUris, contactRepository)).get(ContactShareEditViewModel.class); + viewModel.getContacts().observe(this, contacts -> { + contactAdapter.setContacts(contacts); + contactList.post(() -> contactList.scrollToPosition(0)); + }); + viewModel.getEvents().observe(this, this::presentEvent); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicTheme.onResume(this); + } + + private void presentEvent(@Nullable Event event) { + if (event == null) { + return; + } + + if (event == Event.BAD_CONTACT) { + Toast.makeText(this, R.string.ContactShareEditActivity_invalid_contact, Toast.LENGTH_SHORT).show(); + finish(); + } + } + + private void onSendClicked(List contacts) { + Intent intent = new Intent(); + + ArrayList contactArrayList = new ArrayList<>(contacts.size()); + contactArrayList.addAll(contacts); + intent.putExtra(KEY_CONTACTS, contactArrayList); + + setResult(Activity.RESULT_OK, intent); + + finish(); + } + + @Override + public void onNameEditClicked(int position, @NonNull Name name) { + startActivityForResult(ContactNameEditActivity.getIntent(this, name, position), CODE_NAME_EDIT); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != CODE_NAME_EDIT || resultCode != RESULT_OK || data == null) { + return; + } + + int position = data.getIntExtra(ContactNameEditActivity.KEY_CONTACT_INDEX, -1); + Name name = data.getParcelableExtra(ContactNameEditActivity.KEY_NAME); + + if (name != null) { + viewModel.updateContactName(position, name); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditAdapter.java new file mode 100644 index 00000000..effc0117 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditAdapter.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static org.thoughtcrime.securesms.contactshare.Contact.Name; + +public class ContactShareEditAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final Locale locale; + private final EventListener eventListener; + private final List contacts; + + ContactShareEditAdapter(@NonNull GlideRequests glideRequests, @NonNull Locale locale, @NonNull EventListener eventListener) { + this.glideRequests = glideRequests; + this.locale = locale; + this.eventListener = eventListener; + this.contacts = new ArrayList<>(); + } + + @Override + public @NonNull ContactEditViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ContactEditViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_editable_contact, parent, false), + locale, + glideRequests); + } + + @Override + public void onBindViewHolder(@NonNull ContactEditViewHolder holder, int position) { + holder.bind(position, contacts.get(position), eventListener); + } + + @Override + public int getItemCount() { + return contacts.size(); + } + + void setContacts(@Nullable List contacts) { + this.contacts.clear(); + + if (contacts != null) { + this.contacts.addAll(contacts); + } + + notifyDataSetChanged(); + } + + static class ContactEditViewHolder extends RecyclerView.ViewHolder { + + private final TextView name; + private final View nameEditButton; + private final ContactFieldAdapter fieldAdapter; + + ContactEditViewHolder(View itemView, @NonNull Locale locale, @NonNull GlideRequests glideRequests) { + super(itemView); + + this.name = itemView.findViewById(R.id.editable_contact_name); + this.nameEditButton = itemView.findViewById(R.id.editable_contact_name_edit_button); + this.fieldAdapter = new ContactFieldAdapter(locale, glideRequests, true); + + RecyclerView fields = itemView.findViewById(R.id.editable_contact_fields); + fields.setLayoutManager(new LinearLayoutManager(itemView.getContext())); + fields.getLayoutManager().setAutoMeasureEnabled(true); + fields.setAdapter(fieldAdapter); + } + + void bind(int position, @NonNull Contact contact, @NonNull EventListener eventListener) { + Context context = itemView.getContext(); + + name.setText(ContactUtil.getDisplayName(contact)); + nameEditButton.setOnClickListener(v -> eventListener.onNameEditClicked(position, contact.getName())); + fieldAdapter.setFields(context, contact.getAvatar(), contact.getPhoneNumbers(), contact.getEmails(), contact.getPostalAddresses()); + } + } + + interface EventListener { + void onNameEditClicked(int position, @NonNull Name name); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java new file mode 100644 index 00000000..facd1ce4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.contactshare.Contact.Name; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +import java.util.ArrayList; +import java.util.List; + +class ContactShareEditViewModel extends ViewModel { + + private final MutableLiveData> contacts; + private final SingleLiveEvent events; + private final SharedContactRepository repo; + + ContactShareEditViewModel(@NonNull List contactUris, + @NonNull SharedContactRepository contactRepository) + { + contacts = new MutableLiveData<>(); + events = new SingleLiveEvent<>(); + repo = contactRepository; + + repo.getContacts(contactUris, retrieved -> { + if (retrieved.isEmpty()) { + events.postValue(Event.BAD_CONTACT); + } else { + contacts.postValue(retrieved); + } + }); + } + + @NonNull LiveData> getContacts() { + return contacts; + } + + @NonNull List getFinalizedContacts() { + List currentContacts = getCurrentContacts(); + List trimmedContacts = new ArrayList<>(currentContacts.size()); + + for (Contact contact : currentContacts) { + Contact trimmed = new Contact(contact.getName(), + contact.getOrganization(), + trimSelectables(contact.getPhoneNumbers()), + trimSelectables(contact.getEmails()), + trimSelectables(contact.getPostalAddresses()), + contact.getAvatar() != null && contact.getAvatar().isSelected() ? contact.getAvatar() : null); + trimmedContacts.add(trimmed); + } + + return trimmedContacts; + } + + @NonNull LiveData getEvents() { + return events; + } + + void updateContactName(int contactPosition, @NonNull Name name) { + if (name.isEmpty()) { + events.postValue(Event.BAD_CONTACT); + return; + } + + List currentContacts = getCurrentContacts(); + Contact original = currentContacts.remove(contactPosition); + + currentContacts.add(new Contact(name, + original.getOrganization(), + original.getPhoneNumbers(), + original.getEmails(), + original.getPostalAddresses(), + original.getAvatar())); + + contacts.postValue(currentContacts); + } + + private List trimSelectables(List selectables) { + return Stream.of(selectables).filter(Selectable::isSelected).toList(); + } + + @NonNull + private List getCurrentContacts() { + List currentContacts = contacts.getValue(); + return currentContacts != null ? currentContacts : new ArrayList<>(); + } + + enum Event { + BAD_CONTACT + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final List contactUris; + private final SharedContactRepository contactRepository; + + Factory(@NonNull List contactUris, @NonNull SharedContactRepository contactRepository) { + this.contactUris = contactUris; + this.contactRepository = contactRepository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new ContactShareEditViewModel(contactUris, contactRepository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java new file mode 100644 index 00000000..9e6288f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java @@ -0,0 +1,236 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AlertDialog; + +import com.annimon.stream.Stream; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiStrings; +import org.thoughtcrime.securesms.contactshare.Contact.Email; +import org.thoughtcrime.securesms.contactshare.Contact.Phone; +import org.thoughtcrime.securesms.contactshare.Contact.PostalAddress; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SpanUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class ContactUtil { + + private static final String TAG = ContactUtil.class.getSimpleName(); + + public static long getContactIdFromUri(@NonNull Uri uri) { + try { + return Long.parseLong(uri.getLastPathSegment()); + } catch (NumberFormatException e) { + return -1; + } + } + + public static @NonNull CharSequence getStringSummary(@NonNull Context context, @NonNull Contact contact) { + String contactName = ContactUtil.getDisplayName(contact); + + if (!TextUtils.isEmpty(contactName)) { + return context.getString(R.string.MessageNotifier_contact_message, EmojiStrings.BUST_IN_SILHOUETTE, contactName); + } + + return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message)); + } + + public static @NonNull String getDisplayName(@Nullable Contact contact) { + if (contact == null) { + return ""; + } + + if (!TextUtils.isEmpty(contact.getName().getDisplayName())) { + return contact.getName().getDisplayName(); + } + + if (!TextUtils.isEmpty(contact.getOrganization())) { + return contact.getOrganization(); + } + + return ""; + } + + public static @NonNull String getDisplayNumber(@NonNull Contact contact, @NonNull Locale locale) { + Phone displayNumber = getPrimaryNumber(contact); + + if (displayNumber != null) { + return ContactUtil.getPrettyPhoneNumber(displayNumber, locale); + } else if (contact.getEmails().size() > 0) { + return contact.getEmails().get(0).getEmail(); + } else { + return ""; + } + } + + private static @Nullable Phone getPrimaryNumber(@NonNull Contact contact) { + if (contact.getPhoneNumbers().size() == 0) { + return null; + } + + List mobileNumbers = Stream.of(contact.getPhoneNumbers()).filter(number -> number.getType() == Phone.Type.MOBILE).toList(); + if (mobileNumbers.size() > 0) { + return mobileNumbers.get(0); + } + + return contact.getPhoneNumbers().get(0); + } + + public static @NonNull String getPrettyPhoneNumber(@NonNull Phone phoneNumber, @NonNull Locale fallbackLocale) { + return getPrettyPhoneNumber(phoneNumber.getNumber(), fallbackLocale); + } + + private static @NonNull String getPrettyPhoneNumber(@NonNull String phoneNumber, @NonNull Locale fallbackLocale) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + try { + PhoneNumber parsed = util.parse(phoneNumber, fallbackLocale.getCountry()); + return util.format(parsed, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); + } catch (NumberParseException e) { + return phoneNumber; + } + } + + public static @NonNull String getNormalizedPhoneNumber(@NonNull Context context, @NonNull String number) { + return PhoneNumberFormatter.get(context).format(number); + } + + @MainThread + public static void selectRecipientThroughDialog(@NonNull Context context, @NonNull List choices, @NonNull Locale locale, @NonNull RecipientSelectedCallback callback) { + if (choices.size() > 1) { + CharSequence[] values = new CharSequence[choices.size()]; + + for (int i = 0; i < values.length; i++) { + values[i] = getPrettyPhoneNumber(choices.get(i).requireE164(), locale); + } + + new AlertDialog.Builder(context) + .setItems(values, ((dialog, which) -> callback.onSelected(choices.get(which)))) + .create() + .show(); + } else { + callback.onSelected(choices.get(0)); + } + } + + public static List getRecipients(@NonNull Context context, @NonNull Contact contact) { + return Stream.of(contact.getPhoneNumbers()).map(phone -> Recipient.external(context, phone.getNumber())).map(Recipient::getId).toList(); + } + + @WorkerThread + public static @NonNull Intent buildAddToContactsIntent(@NonNull Context context, @NonNull Contact contact) { + Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + intent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE); + + if (!TextUtils.isEmpty(contact.getName().getDisplayName())) { + intent.putExtra(ContactsContract.Intents.Insert.NAME, contact.getName().getDisplayName()); + } + + if (!TextUtils.isEmpty(contact.getOrganization())) { + intent.putExtra(ContactsContract.Intents.Insert.COMPANY, contact.getOrganization()); + } + + if (contact.getPhoneNumbers().size() > 0) { + intent.putExtra(ContactsContract.Intents.Insert.PHONE, contact.getPhoneNumbers().get(0).getNumber()); + intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE, getSystemType(contact.getPhoneNumbers().get(0).getType())); + } + + if (contact.getPhoneNumbers().size() > 1) { + intent.putExtra(ContactsContract.Intents.Insert.SECONDARY_PHONE, contact.getPhoneNumbers().get(1).getNumber()); + intent.putExtra(ContactsContract.Intents.Insert.SECONDARY_PHONE_TYPE, getSystemType(contact.getPhoneNumbers().get(1).getType())); + } + + if (contact.getPhoneNumbers().size() > 2) { + intent.putExtra(ContactsContract.Intents.Insert.TERTIARY_PHONE, contact.getPhoneNumbers().get(2).getNumber()); + intent.putExtra(ContactsContract.Intents.Insert.TERTIARY_PHONE_TYPE, getSystemType(contact.getPhoneNumbers().get(2).getType())); + } + + if (contact.getEmails().size() > 0) { + intent.putExtra(ContactsContract.Intents.Insert.EMAIL, contact.getEmails().get(0).getEmail()); + intent.putExtra(ContactsContract.Intents.Insert.EMAIL_TYPE, getSystemType(contact.getEmails().get(0).getType())); + } + + if (contact.getEmails().size() > 1) { + intent.putExtra(ContactsContract.Intents.Insert.SECONDARY_EMAIL, contact.getEmails().get(1).getEmail()); + intent.putExtra(ContactsContract.Intents.Insert.SECONDARY_EMAIL_TYPE, getSystemType(contact.getEmails().get(1).getType())); + } + + if (contact.getEmails().size() > 2) { + intent.putExtra(ContactsContract.Intents.Insert.TERTIARY_EMAIL, contact.getEmails().get(2).getEmail()); + intent.putExtra(ContactsContract.Intents.Insert.TERTIARY_EMAIL_TYPE, getSystemType(contact.getEmails().get(2).getType())); + } + + if (contact.getPostalAddresses().size() > 0) { + intent.putExtra(ContactsContract.Intents.Insert.POSTAL, contact.getPostalAddresses().get(0).toString()); + intent.putExtra(ContactsContract.Intents.Insert.POSTAL_TYPE, getSystemType(contact.getPostalAddresses().get(0).getType())); + } + + if (contact.getAvatarAttachment() != null && contact.getAvatarAttachment().getUri() != null) { + try { + ContentValues values = new ContentValues(); + values.put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE); + values.put(ContactsContract.CommonDataKinds.Photo.PHOTO, StreamUtil.readFully(PartAuthority.getAttachmentStream(context, contact.getAvatarAttachment().getUri()))); + + ArrayList valuesArray = new ArrayList<>(1); + valuesArray.add(values); + + intent.putParcelableArrayListExtra(ContactsContract.Intents.Insert.DATA, valuesArray); + } catch (IOException e) { + Log.w(TAG, "Failed to read avatar into a byte array.", e); + } + } + return intent; + } + + private static int getSystemType(Phone.Type type) { + switch (type) { + case HOME: return ContactsContract.CommonDataKinds.Phone.TYPE_HOME; + case MOBILE: return ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE; + case WORK: return ContactsContract.CommonDataKinds.Phone.TYPE_WORK; + default: return ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM; + } + } + + private static int getSystemType(Email.Type type) { + switch (type) { + case HOME: return ContactsContract.CommonDataKinds.Email.TYPE_HOME; + case MOBILE: return ContactsContract.CommonDataKinds.Email.TYPE_MOBILE; + case WORK: return ContactsContract.CommonDataKinds.Email.TYPE_WORK; + default: return ContactsContract.CommonDataKinds.Email.TYPE_CUSTOM; + } + } + + private static int getSystemType(PostalAddress.Type type) { + switch (type) { + case HOME: return ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME; + case WORK: return ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK; + default: return ContactsContract.CommonDataKinds.StructuredPostal.TYPE_CUSTOM; + } + } + + public interface RecipientSelectedCallback { + void onSelected(@NonNull Recipient recipient); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Selectable.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Selectable.java new file mode 100644 index 00000000..d85bd3c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Selectable.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.contactshare; + +public interface Selectable { + void setSelected(boolean selected); + boolean isSelected(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsActivity.java new file mode 100644 index 00000000..4560f513 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsActivity.java @@ -0,0 +1,247 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.WindowUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; + +public class SharedContactDetailsActivity extends PassphraseRequiredActivity { + + private static final int CODE_ADD_EDIT_CONTACT = 2323; + private static final String KEY_CONTACT = "contact"; + + private ContactFieldAdapter contactFieldAdapter; + private TextView nameView; + private TextView numberView; + private ImageView avatarView; + private View addButtonView; + private View inviteButtonView; + private ViewGroup engageContainerView; + private View messageButtonView; + private View callButtonView; + + private GlideRequests glideRequests; + private Contact contact; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private final Map activeRecipients = new HashMap<>(); + + public static Intent getIntent(@NonNull Context context, @NonNull Contact contact) { + Intent intent = new Intent(context, SharedContactDetailsActivity.class); + intent.putExtra(KEY_CONTACT, contact); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.activity_shared_contact_details); + + if (getIntent() == null) { + throw new IllegalStateException("You must supply arguments to this activity. Please use the #getIntent() method."); + } + + contact = getIntent().getParcelableExtra(KEY_CONTACT); + if (contact == null) { + throw new IllegalStateException("You must supply a contact to this activity. Please use the #getIntent() method."); + } + + initToolbar(); + initViews(); + + presentContact(contact); + presentActionButtons(ContactUtil.getRecipients(this, contact)); + presentAvatar(contact.getAvatarAttachment() != null ? contact.getAvatarAttachment().getUri() : null); + + for (LiveRecipient recipient : activeRecipients.values()) { + recipient.observe(this, r -> presentActionButtons(Collections.singletonList(r.getId()))); + } + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onCreate(this); + dynamicTheme.onResume(this); + } + + private void initToolbar() { + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setLogo(null); + getSupportActionBar().setTitle(""); + toolbar.setNavigationOnClickListener(v -> onBackPressed()); + + WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.shared_contact_details_titlebar)); + } + + private void initViews() { + nameView = findViewById(R.id.contact_details_name); + numberView = findViewById(R.id.contact_details_number); + avatarView = findViewById(R.id.contact_details_avatar); + addButtonView = findViewById(R.id.contact_details_add_button); + inviteButtonView = findViewById(R.id.contact_details_invite_button); + engageContainerView = findViewById(R.id.contact_details_engage_container); + messageButtonView = findViewById(R.id.contact_details_message_button); + callButtonView = findViewById(R.id.contact_details_call_button); + + contactFieldAdapter = new ContactFieldAdapter(dynamicLanguage.getCurrentLocale(), glideRequests, false); + + RecyclerView list = findViewById(R.id.contact_details_fields); + list.setLayoutManager(new LinearLayoutManager(this)); + list.setAdapter(contactFieldAdapter); + + glideRequests = GlideApp.with(this); + } + + @SuppressLint("StaticFieldLeak") + private void presentContact(@Nullable Contact contact) { + this.contact = contact; + + if (contact != null) { + nameView.setText(ContactUtil.getDisplayName(contact)); + numberView.setText(ContactUtil.getDisplayNumber(contact, dynamicLanguage.getCurrentLocale())); + + addButtonView.setOnClickListener(v -> { + new AsyncTask() { + @Override + protected Intent doInBackground(Void... voids) { + return ContactUtil.buildAddToContactsIntent(SharedContactDetailsActivity.this, contact); + } + + @Override + protected void onPostExecute(Intent intent) { + startActivityForResult(intent, CODE_ADD_EDIT_CONTACT); + } + }.execute(); + }); + + contactFieldAdapter.setFields(this, null, contact.getPhoneNumbers(), contact.getEmails(), contact.getPostalAddresses()); + } else { + nameView.setText(""); + numberView.setText(""); + } + } + + public void presentAvatar(@Nullable Uri uri) { + if (uri != null) { + glideRequests.load(new DecryptableUri(uri)) + .fallback(R.drawable.ic_contact_picture) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(avatarView); + } else { + glideRequests.load(R.drawable.ic_contact_picture) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(avatarView); + } + } + + private void presentActionButtons(@NonNull List recipients) { + for (RecipientId recipientId : recipients) { + activeRecipients.put(recipientId, Recipient.live(recipientId)); + } + + List pushUsers = new ArrayList<>(recipients.size()); + List systemUsers = new ArrayList<>(recipients.size()); + + for (LiveRecipient recipient : activeRecipients.values()) { + if (recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { + pushUsers.add(recipient.get()); + } else if (recipient.get().isSystemContact()) { + systemUsers.add(recipient.get()); + } + } + + if (!pushUsers.isEmpty()) { + engageContainerView.setVisibility(View.VISIBLE); + inviteButtonView.setVisibility(View.GONE); + + messageButtonView.setOnClickListener(v -> { + ContactUtil.selectRecipientThroughDialog(this, pushUsers, dynamicLanguage.getCurrentLocale(), recipient -> { + CommunicationActions.startConversation(this, recipient, null); + }); + }); + + callButtonView.setOnClickListener(v -> { + ContactUtil.selectRecipientThroughDialog(this, pushUsers, dynamicLanguage.getCurrentLocale(), recipient -> CommunicationActions.startVoiceCall(this, recipient)); + }); + } else if (!systemUsers.isEmpty()) { + inviteButtonView.setVisibility(View.VISIBLE); + engageContainerView.setVisibility(View.GONE); + + inviteButtonView.setOnClickListener(v -> { + ContactUtil.selectRecipientThroughDialog(this, systemUsers, dynamicLanguage.getCurrentLocale(), recipient -> { + CommunicationActions.composeSmsThroughDefaultApp(this, recipient, getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))); + }); + }); + } else { + inviteButtonView.setVisibility(View.GONE); + engageContainerView.setVisibility(View.GONE); + } + } + + private void clearView() { + nameView.setText(""); + numberView.setText(""); + inviteButtonView.setVisibility(View.GONE); + engageContainerView.setVisibility(View.GONE); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CODE_ADD_EDIT_CONTACT && contact != null) { + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java new file mode 100644 index 00000000..f93f41d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java @@ -0,0 +1,271 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.ContactsDatabase; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contactshare.Contact.Email; +import org.thoughtcrime.securesms.contactshare.Contact.Name; +import org.thoughtcrime.securesms.contactshare.Contact.Phone; +import org.thoughtcrime.securesms.contactshare.Contact.PostalAddress; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +import ezvcard.Ezvcard; +import ezvcard.VCard; + +import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; + +public class SharedContactRepository { + + private static final String TAG = SharedContactRepository.class.getSimpleName(); + + private final Context context; + private final Executor executor; + private final ContactsDatabase contactsDatabase; + + SharedContactRepository(@NonNull Context context, + @NonNull Executor executor, + @NonNull ContactsDatabase contactsDatabase) + { + this.context = context.getApplicationContext(); + this.executor = executor; + this.contactsDatabase = contactsDatabase; + } + + void getContacts(@NonNull List contactUris, @NonNull ValueCallback> callback) { + executor.execute(() -> { + List contacts = new ArrayList<>(contactUris.size()); + for (Uri contactUri : contactUris) { + Contact contact; + + if (ContactsContract.AUTHORITY.equals(contactUri.getAuthority())) { + contact = getContactFromSystemContacts(ContactUtil.getContactIdFromUri(contactUri)); + } else { + contact = getContactFromVcard(contactUri); + } + + if (contact != null) { + contacts.add(contact); + } + } + callback.onComplete(contacts); + }); + } + + @WorkerThread + private @Nullable Contact getContactFromSystemContacts(long contactId) { + Name name = getName(contactId); + if (name == null) { + Log.w(TAG, "Couldn't find a name associated with the provided contact ID."); + return null; + } + + List phoneNumbers = getPhoneNumbers(contactId); + AvatarInfo avatarInfo = getAvatarInfo(contactId, phoneNumbers); + Avatar avatar = avatarInfo != null ? new Avatar(avatarInfo.uri, avatarInfo.isProfile) : null; + + return new Contact(name, null, phoneNumbers, getEmails(contactId), getPostalAddresses(contactId), avatar); + } + + @WorkerThread + private @Nullable Contact getContactFromVcard(@NonNull Uri uri) { + Contact contact = null; + + try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) { + VCard vcard = Ezvcard.parse(stream).first(); + contact = VCardUtil.getContactFromVcard(vcard); + } catch (IOException e) { + Log.w(TAG, "Failed to parse the vcard.", e); + } + + if (BlobProvider.AUTHORITY.equals(uri.getAuthority())) { + BlobProvider.getInstance().delete(context, uri); + } + + return contact; + } + + @WorkerThread + private @Nullable Name getName(long contactId) { + try (Cursor cursor = contactsDatabase.getNameDetails(contactId)) { + if (cursor != null && cursor.moveToFirst()) { + String cursorDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)); + String cursorGivenName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)); + String cursorFamilyName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)); + String cursorPrefix = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.PREFIX)); + String cursorSuffix = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.SUFFIX)); + String cursorMiddleName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)); + + Name name = new Name(cursorDisplayName, cursorGivenName, cursorFamilyName, cursorPrefix, cursorSuffix, cursorMiddleName); + if (!name.isEmpty()) { + return name; + } + } + } + + String org = contactsDatabase.getOrganizationName(contactId); + if (!TextUtils.isEmpty(org)) { + return new Name(org, org, null, null, null, null); + } + + return null; + } + + @WorkerThread + private @NonNull List getPhoneNumbers(long contactId) { + Map numberMap = new HashMap<>(); + try (Cursor cursor = contactsDatabase.getPhoneDetails(contactId)) { + while (cursor != null && cursor.moveToNext()) { + String cursorNumber = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)); + int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)); + String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL)); + + String number = ContactUtil.getNormalizedPhoneNumber(context, cursorNumber); + Phone existing = numberMap.get(number); + Phone candidate = new Phone(number, VCardUtil.phoneTypeFromContactType(cursorType), cursorLabel); + + if (existing == null || (existing.getType() == Phone.Type.CUSTOM && existing.getLabel() == null)) { + numberMap.put(number, candidate); + } + } + } + + List numbers = new ArrayList<>(numberMap.size()); + numbers.addAll(numberMap.values()); + return numbers; + } + + @WorkerThread + private @NonNull List getEmails(long contactId) { + List emails = new LinkedList<>(); + + try (Cursor cursor = contactsDatabase.getEmailDetails(contactId)) { + while (cursor != null && cursor.moveToNext()) { + String cursorEmail = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS)); + int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.TYPE)); + String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.LABEL)); + + emails.add(new Email(cursorEmail, VCardUtil.emailTypeFromContactType(cursorType), cursorLabel)); + } + } + + return emails; + } + + @WorkerThread + private @NonNull List getPostalAddresses(long contactId) { + List postalAddresses = new LinkedList<>(); + + try (Cursor cursor = contactsDatabase.getPostalAddressDetails(contactId)) { + while (cursor != null && cursor.moveToNext()) { + int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.TYPE)); + String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.LABEL)); + String cursorStreet = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.STREET)); + String cursorPoBox = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.POBOX)); + String cursorNeighborhood = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD)); + String cursorCity = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.CITY)); + String cursorRegion = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.REGION)); + String cursorPostal = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE)); + String cursorCountry = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)); + + postalAddresses.add(new PostalAddress(VCardUtil.postalAddressTypeFromContactType(cursorType), + cursorLabel, + cursorStreet, + cursorPoBox, + cursorNeighborhood, + cursorCity, + cursorRegion, + cursorPostal, + cursorCountry)); + } + } + + return postalAddresses; + } + + @WorkerThread + private @Nullable AvatarInfo getAvatarInfo(long contactId, List phoneNumbers) { + AvatarInfo systemAvatar = getSystemAvatarInfo(contactId); + + if (systemAvatar != null) { + return systemAvatar; + } + + for (Phone phoneNumber : phoneNumbers) { + AvatarInfo recipientAvatar = getRecipientAvatarInfo(PhoneNumberFormatter.get(context).format(phoneNumber.getNumber())); + if (recipientAvatar != null) { + return recipientAvatar; + } + } + return null; + } + + @WorkerThread + private @Nullable AvatarInfo getSystemAvatarInfo(long contactId) { + Uri uri = contactsDatabase.getAvatarUri(contactId); + if (uri != null) { + return new AvatarInfo(uri, false); + } + + return null; + } + + @WorkerThread + private @Nullable AvatarInfo getRecipientAvatarInfo(String address) { + Recipient recipient = Recipient.external(context, address); + ContactPhoto contactPhoto = recipient.getContactPhoto(); + + if (contactPhoto != null) { + Uri avatarUri = contactPhoto.getUri(context); + if (avatarUri != null) { + return new AvatarInfo(avatarUri, contactPhoto.isProfilePhoto()); + } + } + + return null; + } + + interface ValueCallback { + void onComplete(@NonNull T value); + } + + private static class AvatarInfo { + + private final Uri uri; + private final boolean isProfile; + + private AvatarInfo(Uri uri, boolean isProfile) { + this.uri = uri; + this.isProfile = isProfile; + } + + public Uri getUri() { + return uri; + } + + public boolean isProfile() { + return isProfile; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java new file mode 100644 index 00000000..b2448b8f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.text.Editable; +import android.text.TextWatcher; + +public abstract class SimpleTextWatcher implements TextWatcher { + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + onTextChanged(s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { } + + public abstract void onTextChanged(String text); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java new file mode 100644 index 00000000..6b83f8b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.contactshare; + +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import ezvcard.Ezvcard; +import ezvcard.VCard; + +public final class VCardUtil { + + private VCardUtil(){} + + private static final String TAG = VCardUtil.class.getSimpleName(); + + public static List parseContacts(@NonNull String vCardData) { + List vContacts = Ezvcard.parse(vCardData).all(); + List contacts = new LinkedList<>(); + for (VCard vCard: vContacts){ + contacts.add(getContactFromVcard(vCard)); + } + return contacts; + } + + static @Nullable Contact getContactFromVcard(@NonNull VCard vcard) { + ezvcard.property.StructuredName vName = vcard.getStructuredName(); + List vPhones = vcard.getTelephoneNumbers(); + List vEmails = vcard.getEmails(); + List vPostalAddresses = vcard.getAddresses(); + + String organization = vcard.getOrganization() != null && !vcard.getOrganization().getValues().isEmpty() ? vcard.getOrganization().getValues().get(0) : null; + String displayName = vcard.getFormattedName() != null ? vcard.getFormattedName().getValue() : null; + + if (displayName == null && vName != null) { + displayName = vName.getGiven(); + } + + if (displayName == null && vcard.getOrganization() != null) { + displayName = organization; + } + + if (displayName == null) { + Log.w(TAG, "Failed to parse the vcard: No valid name."); + return null; + } + + Contact.Name name = new Contact.Name(displayName, + vName != null ? vName.getGiven() : null, + vName != null ? vName.getFamily() : null, + vName != null && !vName.getPrefixes().isEmpty() ? vName.getPrefixes().get(0) : null, + vName != null && !vName.getSuffixes().isEmpty() ? vName.getSuffixes().get(0) : null, + null); + + + List phoneNumbers = new ArrayList<>(vPhones.size()); + for (ezvcard.property.Telephone vEmail : vPhones) { + String label = !vEmail.getTypes().isEmpty() ? getCleanedVcardType(vEmail.getTypes().get(0).getValue()) : null; + + // Phone number is stored in the uri field in v4.0 only. In other versions, it is in the text field. + String phoneNumberFromText = vEmail.getText(); + String extractedPhoneNumber = phoneNumberFromText == null ? vEmail.getUri().getNumber() : phoneNumberFromText; + phoneNumbers.add(new Contact.Phone(extractedPhoneNumber, phoneTypeFromVcardType(label), label)); + } + + List emails = new ArrayList<>(vEmails.size()); + for (ezvcard.property.Email vEmail : vEmails) { + String label = !vEmail.getTypes().isEmpty() ? getCleanedVcardType(vEmail.getTypes().get(0).getValue()) : null; + emails.add(new Contact.Email(vEmail.getValue(), emailTypeFromVcardType(label), label)); + } + + List postalAddresses = new ArrayList<>(vPostalAddresses.size()); + for (ezvcard.property.Address vPostalAddress : vPostalAddresses) { + String label = !vPostalAddress.getTypes().isEmpty() ? getCleanedVcardType(vPostalAddress.getTypes().get(0).getValue()) : null; + postalAddresses.add(new Contact.PostalAddress(postalAddressTypeFromVcardType(label), + label, + vPostalAddress.getStreetAddress(), + vPostalAddress.getPoBox(), + null, + vPostalAddress.getLocality(), + vPostalAddress.getRegion(), + vPostalAddress.getPostalCode(), + vPostalAddress.getCountry())); + } + + return new Contact(name, organization, phoneNumbers, emails, postalAddresses, null); + } + + static Contact.Phone.Type phoneTypeFromContactType(int type) { + switch (type) { + case ContactsContract.CommonDataKinds.Phone.TYPE_HOME: + return Contact.Phone.Type.HOME; + case ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE: + return Contact.Phone.Type.MOBILE; + case ContactsContract.CommonDataKinds.Phone.TYPE_WORK: + return Contact.Phone.Type.WORK; + } + return Contact.Phone.Type.CUSTOM; + } + + private static Contact.Phone.Type phoneTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return Contact.Phone.Type.HOME; + else if ("cell".equalsIgnoreCase(type)) return Contact.Phone.Type.MOBILE; + else if ("work".equalsIgnoreCase(type)) return Contact.Phone.Type.WORK; + else return Contact.Phone.Type.CUSTOM; + } + + static Contact.Email.Type emailTypeFromContactType(int type) { + switch (type) { + case ContactsContract.CommonDataKinds.Email.TYPE_HOME: + return Contact.Email.Type.HOME; + case ContactsContract.CommonDataKinds.Email.TYPE_MOBILE: + return Contact.Email.Type.MOBILE; + case ContactsContract.CommonDataKinds.Email.TYPE_WORK: + return Contact.Email.Type.WORK; + } + return Contact.Email.Type.CUSTOM; + } + + private static Contact.Email.Type emailTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return Contact.Email.Type.HOME; + else if ("cell".equalsIgnoreCase(type)) return Contact.Email.Type.MOBILE; + else if ("work".equalsIgnoreCase(type)) return Contact.Email.Type.WORK; + else return Contact.Email.Type.CUSTOM; + } + + static Contact.PostalAddress.Type postalAddressTypeFromContactType(int type) { + switch (type) { + case ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME: + return Contact.PostalAddress.Type.HOME; + case ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK: + return Contact.PostalAddress.Type.WORK; + } + return Contact.PostalAddress.Type.CUSTOM; + } + + private static Contact.PostalAddress.Type postalAddressTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return Contact.PostalAddress.Type.HOME; + else if ("work".equalsIgnoreCase(type)) return Contact.PostalAddress.Type.WORK; + else return Contact.PostalAddress.Type.CUSTOM; + } + + private static String getCleanedVcardType(@Nullable String type) { + if (TextUtils.isEmpty(type)) return ""; + + if (type.startsWith("x-") && type.length() > 2) { + return type.substring(2); + } + + return type; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java new file mode 100644 index 00000000..59909d84 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.StorageUtil; + +import java.util.Arrays; +import java.util.List; + +public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.InputView { + + private View container; + private AttachmentKeyboardMediaAdapter mediaAdapter; + private AttachmentKeyboardButtonAdapter buttonAdapter; + private Callback callback; + + private RecyclerView mediaList; + private View permissionText; + private View permissionButton; + + public AttachmentKeyboard(@NonNull Context context) { + super(context); + init(context); + } + + public AttachmentKeyboard(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(@NonNull Context context) { + inflate(context, R.layout.attachment_keyboard, this); + + this.container = findViewById(R.id.attachment_keyboard_container); + this.mediaList = findViewById(R.id.attachment_keyboard_media_list); + this.permissionText = findViewById(R.id.attachment_keyboard_permission_text); + this.permissionButton = findViewById(R.id.attachment_keyboard_permission_button); + + RecyclerView buttonList = findViewById(R.id.attachment_keyboard_button_list); + + mediaAdapter = new AttachmentKeyboardMediaAdapter(GlideApp.with(this), media -> { + if (callback != null) { + callback.onAttachmentMediaClicked(media); + } + }); + + buttonAdapter = new AttachmentKeyboardButtonAdapter(button -> { + if (callback != null) { + callback.onAttachmentSelectorClicked(button); + } + }); + + mediaList.setAdapter(mediaAdapter); + buttonList.setAdapter(buttonAdapter); + + mediaList.setLayoutManager(new GridLayoutManager(context, 1, GridLayoutManager.HORIZONTAL, false)); + buttonList.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); + + buttonAdapter.setButtons(Arrays.asList( + AttachmentKeyboardButton.GALLERY, + AttachmentKeyboardButton.GIF, + AttachmentKeyboardButton.FILE, + AttachmentKeyboardButton.CONTACT, + AttachmentKeyboardButton.LOCATION + )); + } + + public void setCallback(@NonNull Callback callback) { + this.callback = callback; + } + + public void onMediaChanged(@NonNull List media) { + if (StorageUtil.canReadFromMediaStore()) { + mediaAdapter.setMedia(media); + permissionButton.setVisibility(GONE); + permissionText.setVisibility(GONE); + } else { + permissionButton.setVisibility(VISIBLE); + permissionText.setVisibility(VISIBLE); + + permissionButton.setOnClickListener(v -> { + if (callback != null) { + callback.onAttachmentPermissionsRequested(); + } + }); + } + } + + public void setWallpaperEnabled(boolean wallpaperEnabled) { + if (wallpaperEnabled) { + container.setBackgroundColor(getContext().getResources().getColor(R.color.wallpaper_compose_background)); + } else { + container.setBackgroundColor(getContext().getResources().getColor(R.color.signal_background_primary)); + } + buttonAdapter.setWallpaperEnabled(wallpaperEnabled); + } + + + @Override + public void show(int height, boolean immediate) { + ViewGroup.LayoutParams params = getLayoutParams(); + params.height = height; + setLayoutParams(params); + + setVisibility(VISIBLE); + } + + @Override + public void hide(boolean immediate) { + setVisibility(GONE); + } + + @Override + public boolean isShowing() { + return getVisibility() == VISIBLE; + } + + public interface Callback { + void onAttachmentMediaClicked(@NonNull Media media); + void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button); + void onAttachmentPermissionsRequested(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java new file mode 100644 index 00000000..cc3a3660 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.conversation; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +public enum AttachmentKeyboardButton { + + GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.ic_photo_album_outline_32), + GIF(R.string.AttachmentKeyboard_gif, R.drawable.ic_gif_outline_32), + FILE(R.string.AttachmentKeyboard_file, R.drawable.ic_file_outline_32), + CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.ic_contact_circle_outline_32), + LOCATION(R.string.AttachmentKeyboard_location, R.drawable.ic_location_outline_32); + + private final int titleRes; + private final int iconRes; + + AttachmentKeyboardButton(@StringRes int titleRes, @DrawableRes int iconRes) { + this.titleRes = titleRes; + this.iconRes = iconRes; + } + + public @StringRes int getTitleRes() { + return titleRes; + } + + public @DrawableRes int getIconRes() { + return iconRes; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButtonAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButtonAdapter.java new file mode 100644 index 00000000..ba4eec9d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButtonAdapter.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.List; + +class AttachmentKeyboardButtonAdapter extends RecyclerView.Adapter { + + private final List buttons; + private final Listener listener; + + private boolean wallpaperEnabled; + + AttachmentKeyboardButtonAdapter(@NonNull Listener listener) { + this.buttons = new ArrayList<>(); + this.listener = listener; + + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return buttons.get(position).getTitleRes(); + } + + @Override + public @NonNull + ButtonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ButtonViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboard_button_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ButtonViewHolder holder, int position) { + holder.bind(buttons.get(position), wallpaperEnabled, listener); + } + + @Override + public void onViewRecycled(@NonNull ButtonViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return buttons.size(); + } + + public void setButtons(@NonNull List buttons) { + this.buttons.clear(); + this.buttons.addAll(buttons); + notifyDataSetChanged(); + } + + public void setWallpaperEnabled(boolean enabled) { + if (wallpaperEnabled != enabled) { + wallpaperEnabled = enabled; + notifyDataSetChanged(); + } + } + + interface Listener { + void onClick(@NonNull AttachmentKeyboardButton button); + } + + static class ButtonViewHolder extends RecyclerView.ViewHolder { + + private final ImageView image; + private final TextView title; + + public ButtonViewHolder(@NonNull View itemView) { + super(itemView); + + this.image = itemView.findViewById(R.id.attachment_button_image); + this.title = itemView.findViewById(R.id.attachment_button_title); + } + + void bind(@NonNull AttachmentKeyboardButton button,boolean wallpaperEnabled, @NonNull Listener listener) { + image.setImageResource(button.getIconRes()); + title.setText(button.getTitleRes()); + + itemView.setOnClickListener(v -> listener.onClick(button)); + + if (wallpaperEnabled) { + itemView.setBackgroundResource(R.drawable.attachment_keyboard_button_wallpaper_background); + } else { + itemView.setBackgroundResource(R.drawable.attachment_keyboard_button_background); + } + } + + void recycle() { + itemView.setOnClickListener(null); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java new file mode 100644 index 00000000..2a33fb9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter { + + private final List media; + private final GlideRequests glideRequests; + private final Listener listener; + private final StableIdGenerator idGenerator; + + AttachmentKeyboardMediaAdapter(@NonNull GlideRequests glideRequests, @NonNull Listener listener) { + this.glideRequests = glideRequests; + this.listener = listener; + this.media = new ArrayList<>(); + this.idGenerator = new StableIdGenerator<>(); + + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return idGenerator.getId(media.get(position)); + } + + @Override + public @NonNull MediaViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new MediaViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboad_media_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull MediaViewHolder holder, int position) { + holder.bind(media.get(position), glideRequests, listener); + } + + @Override + public void onViewRecycled(@NonNull MediaViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return media.size(); + } + + public void setMedia(@NonNull List media) { + this.media.clear(); + this.media.addAll(media); + notifyDataSetChanged(); + } + + interface Listener { + void onMediaClicked(@NonNull Media media); + } + + static class MediaViewHolder extends RecyclerView.ViewHolder { + + private final ThumbnailView image; + private final TextView duration; + private final View videoIcon; + + public MediaViewHolder(@NonNull View itemView) { + super(itemView); + image = itemView.findViewById(R.id.attachment_keyboard_item_image); + duration = itemView.findViewById(R.id.attachment_keyboard_item_video_time); + videoIcon = itemView.findViewById(R.id.attachment_keyboard_item_video_icon); + } + + void bind(@NonNull Media media, @NonNull GlideRequests glideRequests, @NonNull Listener listener) { + image.setImageResource(glideRequests, media.getUri(), 400, 400); + image.setOnClickListener(v -> listener.onMediaClicked(media)); + + duration.setVisibility(View.GONE); + videoIcon.setVisibility(View.GONE); + + if (media.getDuration() > 0) { + duration.setVisibility(View.VISIBLE); + duration.setText(formatTime(media.getDuration())); + } else if (MediaUtil.isVideoType(media.getMimeType())) { + videoIcon.setVisibility(View.VISIBLE); + } + } + + void recycle() { + image.setOnClickListener(null); + } + + @NonNull static String formatTime(long time) { + long hours = TimeUnit.MILLISECONDS.toHours(time); + time -= TimeUnit.HOURS.toMillis(hours); + + long minutes = TimeUnit.MILLISECONDS.toMinutes(time); + time -= TimeUnit.MINUTES.toMillis(minutes); + + long seconds = TimeUnit.MILLISECONDS.toSeconds(time); + + if (hours > 0) { + return zeroPad(hours) + ":" + zeroPad(minutes) + ":" + zeroPad(seconds); + } else { + return zeroPad(minutes) + ":" + zeroPad(seconds); + } + } + + @NonNull static String zeroPad(long value) { + if (value < 10) { + return "0" + value; + } else { + return String.valueOf(value); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/BubbleConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/BubbleConversationActivity.java new file mode 100644 index 00000000..e97933a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/BubbleConversationActivity.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.conversation; + +/** + * Activity which encapsulates a conversation for a Bubble window. + * + * This activity is empty, and exists so that we can override some of its manifest parameters + * without clashing with ConversationActivity. + */ +public class BubbleConversationActivity extends ConversationActivity { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java new file mode 100644 index 00000000..03ec9267 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -0,0 +1,3821 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversation; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Camera; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Vibrator; +import android.provider.Browser; +import android.provider.ContactsContract; +import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextWatcher; +import android.view.Display; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnFocusChangeListener; +import android.view.View.OnKeyListener; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.android.material.button.MaterialButton; +import com.tm.androidcopysdk.DataGrabber; + +import org.archiver.ArchiveConstants; +import org.archiver.ArchiveSender; +import org.archiver.FileUtilTestMoti; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.thoughtcrime.securesms.ExpirationDialog; +import org.thoughtcrime.securesms.GroupMembersDialog; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.MuteDialog; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.PromptMmsActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.ShortcutLauncherActivity; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.TombstoneAttachment; +import org.thoughtcrime.securesms.audio.AudioRecorder; +import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.components.ConversationSearchBottomBar; +import org.thoughtcrime.securesms.components.HidingLinearLayout; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.components.InputPanel; +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; +import org.thoughtcrime.securesms.components.SendButton; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.components.TypingStatusSender; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; +import org.thoughtcrime.securesms.components.emoji.EmojiStrings; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; +import org.thoughtcrime.securesms.components.location.SignalPlace; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; +import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationInitiationReminder; +import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder; +import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsReminder; +import org.thoughtcrime.securesms.components.reminder.Reminder; +import org.thoughtcrime.securesms.components.reminder.ReminderView; +import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; +import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; +import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity; +import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; +import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.SecurityEvent; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.DraftDatabase; +import org.thoughtcrime.securesms.database.DraftDatabase.Draft; +import org.thoughtcrime.securesms.database.DraftDatabase.Drafts; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions; +import org.thoughtcrime.securesms.database.MmsSmsColumns.Types; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.identity.IdentityRecordList; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.GroupCallPeekEvent; +import org.thoughtcrime.securesms.events.ReminderUpdateEvent; +import org.thoughtcrime.securesms.giph.ui.GiphyActivity; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; +import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog; +import org.thoughtcrime.securesms.insights.InsightsLauncher; +import org.thoughtcrime.securesms.invites.InviteReminderModel; +import org.thoughtcrime.securesms.invites.InviteReminderRepository; +import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob; +import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel; +import org.thoughtcrime.securesms.maps.PlacePickerActivity; +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult; +import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity; +import org.thoughtcrime.securesms.messagerequests.MessageRequestState; +import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; +import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView; +import org.thoughtcrime.securesms.mms.AttachmentManager; +import org.thoughtcrime.securesms.mms.SlideFactory.MediaType; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.LocationSlide; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.mms.QuoteId; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.SlideFactory; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientExporter; +import org.thoughtcrime.securesms.recipients.RecipientFormattingException; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; +import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.stickers.StickerManagementActivity; +import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent; +import org.thoughtcrime.securesms.stickers.StickerSearchRepository; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.DrawableUtil; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.FullscreenHelper; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MessageUtil; +import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SmsUtil; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.thoughtcrime.securesms.TransportOption.Type; +import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; + +/** + * Activity for displaying a message thread, as well as + * composing/sending a new message into that thread. + * + * @author Moxie Marlinspike + * + */ +@SuppressLint("StaticFieldLeak") +public class ConversationActivity extends PassphraseRequiredActivity + implements ConversationFragment.ConversationFragmentListener, + AttachmentManager.AttachmentListener, + OnKeyboardShownListener, + InputPanel.Listener, + InputPanel.MediaListener, + ComposeText.CursorPositionChangedListener, + ConversationSearchBottomBar.EventListener, + StickerKeyboardProvider.StickerEventListener, + AttachmentKeyboard.Callback, + ConversationReactionOverlay.OnReactionSelectedListener, + ReactWithAnyEmojiBottomSheetDialogFragment.Callback, + SafetyNumberChangeDialog.Callback, + ReactionsBottomSheetDialogFragment.Callback +{ + + private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2); + + private static final String TAG = ConversationActivity.class.getSimpleName(); + + private static final String STATE_REACT_WITH_ANY_PAGE = "STATE_REACT_WITH_ANY_PAGE"; + + private static final int PICK_GALLERY = 1; + private static final int PICK_DOCUMENT = 2; + private static final int PICK_AUDIO = 3; + private static final int PICK_CONTACT = 4; + private static final int GET_CONTACT_DETAILS = 5; + private static final int GROUP_EDIT = 6; + private static final int TAKE_PHOTO = 7; + private static final int ADD_CONTACT = 8; + private static final int PICK_LOCATION = 9; + private static final int PICK_GIF = 10; + private static final int SMS_DEFAULT = 11; + private static final int MEDIA_SENDER = 12; + + private GlideRequests glideRequests; + protected ComposeText composeText; + private AnimatingToggle buttonToggle; + private SendButton sendButton; + private ImageButton attachButton; + protected ConversationTitleView titleView; + private TextView charactersLeft; + private ConversationFragment fragment; + private Button unblockButton; + private Button makeDefaultSmsButton; + private Button registerButton; + private InputAwareLayout container; + protected Stub reminderView; + private Stub unverifiedBannerView; + private Stub reviewBanner; + private TypingStatusTextWatcher typingTextWatcher; + private ConversationSearchBottomBar searchNav; + private MenuItem searchViewItem; + private MessageRequestsBottomView messageRequestBottomView; + private ConversationReactionDelegate reactionDelegate; + + private AttachmentManager attachmentManager; + private AudioRecorder audioRecorder; + private BroadcastReceiver securityUpdateReceiver; + private Stub emojiDrawerStub; + private Stub attachmentKeyboardStub; + protected HidingLinearLayout quickAttachmentToggle; + protected HidingLinearLayout inlineAttachmentToggle; + private InputPanel inputPanel; + private View panelParent; + private View noLongerMemberBanner; + private View requestingMemberBanner; + private View cancelJoinRequest; + private Stub mentionsSuggestions; + private MaterialButton joinGroupCallButton; + private boolean callingTooltipShown; + private ImageView wallpaper; + private View wallpaperDim; + private Toolbar toolbar; + + private LinkPreviewViewModel linkPreviewViewModel; + private ConversationSearchViewModel searchViewModel; + private ConversationStickerViewModel stickerViewModel; + private ConversationViewModel viewModel; + private InviteReminderModel inviteReminderModel; + private ConversationGroupViewModel groupViewModel; + private MentionsPickerViewModel mentionsViewModel; + private GroupCallViewModel groupCallViewModel; + + private LiveRecipient recipient; + private long threadId; + private int distributionType; + private boolean isSecureText; + private int reactWithAnyEmojiStartPage = -1; + private boolean isDefaultSms = true; + private boolean isMmsEnabled = true; + private boolean isSecurityInitialized = false; + + private IdentityRecordList identityRecords = new IdentityRecordList(Collections.emptyList()); + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle state, boolean ready) { + if (ConversationIntents.isInvalid(getIntent())) { + Log.w(TAG, "[onCreate] Missing recipientId!"); + // TODO [greyson] Navigation + startActivity(MainActivity.clearTop(this)); + finish(); + return; + } + + new FullscreenHelper(this).showSystemUI(); + + ConversationIntents.Args args = ConversationIntents.Args.from(getIntent()); + + reportShortcutLaunch(args.getRecipientId()); + setContentView(R.layout.conversation_activity); + + getWindow().getDecorView().setBackgroundResource(R.color.signal_background_primary); + + fragment = initFragment(R.id.fragment_content, new ConversationFragment(), dynamicLanguage.getCurrentLocale()); + + initializeReceivers(); + initializeActionBar(); + initializeViews(); + updateWallpaper(args.getWallpaper()); + initializeResources(args); + initializeLinkPreviewObserver(); + initializeSearchObserver(); + initializeStickerObserver(); + initializeViewModel(args); + initializeGroupViewModel(); + initializeMentionsViewModel(); + initializeGroupCallViewModel(); + initializeEnabledCheck(); + initializePendingRequestsBanner(); + initializeGroupV1MigrationsBanners(); + initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + initializeProfiles(); + initializeGv1Migration(); + initializeDraft(args).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean loadedDraft) { + if (loadedDraft != null && loadedDraft) { + Log.i(TAG, "Finished loading draft"); + Util.runOnMain(() -> { + if (fragment != null && fragment.isResumed()) { + fragment.moveToLastSeen(); + } else { + Log.w(TAG, "Wanted to move to the last seen position, but the fragment was in an invalid state"); + } + }); + } + + if (TextSecurePreferences.isTypingIndicatorsEnabled(ConversationActivity.this)) { + composeText.addTextChangedListener(typingTextWatcher); + } + composeText.setSelection(composeText.length(), composeText.length()); + } + }); + } + }); + initializeInsightObserver(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + Log.i(TAG, "onNewIntent()"); + + if (isFinishing()) { + Log.w(TAG, "Activity is finishing..."); + return; + } + + reactWithAnyEmojiStartPage = -1; + if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.getQuote().isPresent()) { + saveDraft(); + attachmentManager.clear(glideRequests, false); + inputPanel.clearQuote(); + silentlySetComposeText(""); + } + + if (ConversationIntents.isInvalid(intent)) { + Log.w(TAG, "[onNewIntent] Missing recipientId!"); + // TODO [greyson] Navigation + startActivity(MainActivity.clearTop(this)); + finish(); + return; + } + + setIntent(intent); + + viewModel.setArgs(ConversationIntents.Args.from(intent)); + + reportShortcutLaunch(viewModel.getArgs().getRecipientId()); + initializeResources(viewModel.getArgs()); + initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + initializeDraft(viewModel.getArgs()); + } + }); + + if (fragment != null) { + fragment.onNewIntent(); + } + + searchNav.setVisibility(View.GONE); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + + WindowUtil.setLightNavigationBarFromTheme(this); + WindowUtil.setLightStatusBarFromTheme(this); + + EventBus.getDefault().register(this); + initializeMmsEnabledCheck(); + initializeIdentityRecords(); + composeText.setTransport(sendButton.getSelectedTransport()); + + Recipient recipientSnapshot = recipient.get(); + + titleView.setTitle(glideRequests, recipientSnapshot); + setBlockedUserState(recipientSnapshot, isSecureText, isDefaultSms); + calculateCharactersRemaining(); + + if (recipientSnapshot.getGroupId().isPresent() && recipientSnapshot.getGroupId().get().isV2()) { + GroupId.V2 groupId = recipientSnapshot.getGroupId().get().requireV2(); + + ApplicationDependencies.getJobManager() + .startChain(new RequestGroupV2InfoJob(groupId)) + .then(new GroupV2UpdateSelfProfileKeyJob(groupId)) + .enqueue(); + + if (viewModel.getArgs().isFirstTimeInSelfCreatedGroup()) { + groupViewModel.inviteFriendsOneTimeIfJustSelfInGroup(getSupportFragmentManager(), groupId); + } + } + + if (groupCallViewModel != null) { + groupCallViewModel.peekGroupCall(this); + } + + setVisibleThread(threadId); + ConversationUtil.refreshRecipientShortcuts(); + } + + @Override + protected void onPause() { + super.onPause(); + if (!isInBubble()) { + ApplicationDependencies.getMessageNotifier().clearVisibleThread(); + } + + if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_end); + inputPanel.onPause(); + + fragment.setLastSeen(System.currentTimeMillis()); + markLastSeen(); + EventBus.getDefault().unregister(this); + } + + @Override + protected void onStop() { + super.onStop(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.i(TAG, "onConfigurationChanged(" + newConfig.orientation + ")"); + super.onConfigurationChanged(newConfig); + composeText.setTransport(sendButton.getSelectedTransport()); + + if (emojiDrawerStub.resolved() && container.getCurrentInput() == emojiDrawerStub.get()) { + container.hideAttachedInput(true); + } + + if (reactionDelegate.isShowing()) { + reactionDelegate.hide(); + } + } + + @Override + protected void onDestroy() { + saveDraft(); + if (securityUpdateReceiver != null) unregisterReceiver(securityUpdateReceiver); + super.onDestroy(); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + return reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev); + } + + @Override + public void onActivityResult(final int reqCode, int resultCode, Intent data) { + Log.i(TAG, "onActivityResult called: " + reqCode + ", " + resultCode + " , " + data); + super.onActivityResult(reqCode, resultCode, data); + //Moti Amar - after user select media from intent + if ((data == null && reqCode != TAKE_PHOTO && reqCode != SMS_DEFAULT) || + (resultCode != RESULT_OK && reqCode != SMS_DEFAULT)) + { + updateLinkPreviewState(); + return; + } + + switch (reqCode) { + case PICK_DOCUMENT: + setMedia(data.getData(), SlideFactory.MediaType.DOCUMENT); + break; + case PICK_AUDIO: + setMedia(data.getData(), SlideFactory.MediaType.AUDIO); + break; + case PICK_CONTACT: + if (isSecureText && !isSmsForced()) { + openContactShareEditor(data.getData()); + } else { + addAttachmentContactInfo(data.getData()); + } + break; + case GET_CONTACT_DETAILS: + sendSharedContact(data.getParcelableArrayListExtra(ContactShareEditActivity.KEY_CONTACTS)); + break; + case GROUP_EDIT: + Recipient recipientSnapshot = recipient.get(); + + onRecipientChanged(recipientSnapshot); + titleView.setTitle(glideRequests, recipientSnapshot); + NotificationChannels.updateContactChannelName(this, recipientSnapshot); + setBlockedUserState(recipientSnapshot, isSecureText, isDefaultSms); + supportInvalidateOptionsMenu(); + break; + case TAKE_PHOTO: + handleImageFromDeviceCameraApp(); + break; + case ADD_CONTACT: + SimpleTask.run(() -> { + try { + DirectoryHelper.refreshDirectoryFor(this, recipient.get(), false); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh user after adding to contacts."); + } + return null; + }, nothing -> onRecipientChanged(recipient.get())); + break; + case PICK_LOCATION: + SignalPlace place = new SignalPlace(PlacePickerActivity.addressFromData(data)); + attachmentManager.setLocation(place, getCurrentMediaConstraints()); + break; + case PICK_GIF: + setMedia(data.getData(), + SlideFactory.MediaType.GIF, + data.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0), + data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0), + data.getBooleanExtra(GiphyActivity.EXTRA_BORDERLESS, false)); + break; + case SMS_DEFAULT: + initializeSecurity(isSecureText, isDefaultSms); + break; + case MEDIA_SENDER: + MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivity.EXTRA_RESULT); + + if (!Objects.equals(result.getRecipientId(), recipient.getId())) { + Log.w(TAG, "Result's recipientId did not match ours! Result: " + result.getRecipientId() + ", Activity: " + recipient.getId()); + Toast.makeText(this, R.string.ConversationActivity_error_sending_media, Toast.LENGTH_SHORT).show(); + return; + } + + sendButton.setTransport(result.getTransport()); + + if (result.isPushPreUpload()) { + sendMediaMessage(result); + return; + } + long expiresIn = recipient.get().getExpireMessages() * 1000L; + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + boolean initiating = threadId == -1; + QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); + SlideDeck slideDeck = new SlideDeck(); + List mentions = new ArrayList<>(result.getMentions()); + + for (Media mediaItem : result.getNonUploadedMedia()) { + if (MediaUtil.isVideoType(mediaItem.getMimeType())) { + slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), mediaItem.getSize(), mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull(), mediaItem.getTransformProperties().orNull())); + } else if (MediaUtil.isGif(mediaItem.getMimeType())) { + slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), mediaItem.getSize(), mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.isBorderless(), mediaItem.getCaption().orNull())); + } else if (MediaUtil.isImageType(mediaItem.getMimeType())) { + slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), mediaItem.getMimeType(), mediaItem.getSize(), mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.isBorderless(), mediaItem.getCaption().orNull(), null)); + } else { + Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping."); + } + } + + final Context context = ConversationActivity.this.getApplicationContext(); + + sendMediaMessage(result.getRecipientId(), + result.getTransport().isSms(), + result.getBody(), + slideDeck, + quote, + Collections.emptyList(), + Collections.emptyList(), + mentions, + expiresIn, + result.isViewOnce(), + subscriptionId, + initiating, + true).addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Void result) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + Stream.of(slideDeck.getSlides()) + .map(Slide::getUri) + .withoutNulls() + .filter(BlobProvider::isAuthority) + .forEach(uri -> BlobProvider.getInstance().delete(context, uri)); + }); + } + }); + + break; + } + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putInt(STATE_REACT_WITH_ANY_PAGE, reactWithAnyEmojiStartPage); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + + reactWithAnyEmojiStartPage = savedInstanceState.getInt(STATE_REACT_WITH_ANY_PAGE, -1); + } + + private void setVisibleThread(long threadId) { + if (!isInBubble()) { + ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId); + } + } + + private void reportShortcutLaunch(@NonNull RecipientId recipientId) { + if (Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + return; + } + + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(this); + if (shortcutManager != null) { + shortcutManager.reportShortcutUsed(ConversationUtil.getShortcutId(recipientId)); + } + } + + private void handleImageFromDeviceCameraApp() { + if (attachmentManager.getCaptureUri() == null) { + Log.w(TAG, "No image available."); + return; + } + + try { + Uri mediaUri = BlobProvider.getInstance() + .forData(getContentResolver().openInputStream(attachmentManager.getCaptureUri()), 0L) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleSessionOnDisk(this); + + getContentResolver().delete(attachmentManager.getCaptureUri(), null, null); + + setMedia(mediaUri, SlideFactory.MediaType.IMAGE); + } catch (IOException ioe) { + Log.w(TAG, "Could not handle public image", ioe); + } + } + + @Override + public void startActivity(Intent intent) { + if (intent.getStringExtra(Browser.EXTRA_APPLICATION_ID) != null) { + intent.removeExtra(Browser.EXTRA_APPLICATION_ID); + } + + try { + super.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.w(TAG, e); + Toast.makeText(this, R.string.ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device, Toast.LENGTH_LONG).show(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + GroupActiveState groupActiveState = groupViewModel.getGroupActiveState().getValue(); + boolean isActiveGroup = groupActiveState != null && groupActiveState.isActiveGroup(); + boolean isActiveV2Group = groupActiveState != null && groupActiveState.isActiveV2Group(); + boolean isInActiveGroup = groupActiveState != null && !groupActiveState.isActiveGroup(); + + if (isInMessageRequest()) { + if (isActiveGroup) { + inflater.inflate(R.menu.conversation_message_requests_group, menu); + } + + inflater.inflate(R.menu.conversation_message_requests, menu); + + if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu); + else inflater.inflate(R.menu.conversation_unmuted, menu); + + super.onCreateOptionsMenu(menu); + return true; + } + + if (isSecureText) { + if (recipient.get().getExpireMessages() > 0) { + if (!isInActiveGroup) { + inflater.inflate(R.menu.conversation_expiring_on, menu); + } + titleView.showExpiring(recipient); + } else { + if (!isInActiveGroup) { + inflater.inflate(R.menu.conversation_expiring_off, menu); + } + titleView.clearExpiring(); + } + } + + if (isSingleConversation()) { + if (isSecureText) inflater.inflate(R.menu.conversation_callable_secure, menu); + else inflater.inflate(R.menu.conversation_callable_insecure, menu); + } else if (isGroupConversation()) { + if (isActiveV2Group && FeatureFlags.groupCalling()) { + inflater.inflate(R.menu.conversation_callable_groupv2, menu); + if (groupCallViewModel != null && Boolean.TRUE.equals(groupCallViewModel.hasActiveGroupCall().getValue())) { + hideMenuItem(menu, R.id.menu_video_secure); + } + showGroupCallingTooltip(); + } + + inflater.inflate(R.menu.conversation_group_options, menu); + + if (!isPushGroupConversation()) { + inflater.inflate(R.menu.conversation_mms_group_options, menu); + if (distributionType == ThreadDatabase.DistributionTypes.BROADCAST) { + menu.findItem(R.id.menu_distribution_broadcast).setChecked(true); + } else { + menu.findItem(R.id.menu_distribution_conversation).setChecked(true); + } + } + + inflater.inflate(R.menu.conversation_active_group_options, menu); + } + + inflater.inflate(R.menu.conversation, menu); + + if (isSingleConversation() && isSecureText) { + inflater.inflate(R.menu.conversation_secure, menu); + } else if (isSingleConversation()) { + inflater.inflate(R.menu.conversation_insecure, menu); + } + + if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu); + else inflater.inflate(R.menu.conversation_unmuted, menu); + + if (isSingleConversation() && getRecipient().getContactUri() == null) { + inflater.inflate(R.menu.conversation_add_to_contacts, menu); + } + + if (recipient != null && recipient.get().isSelf()) { + if (isSecureText) { + hideMenuItem(menu, R.id.menu_call_secure); + hideMenuItem(menu, R.id.menu_video_secure); + } else { + hideMenuItem(menu, R.id.menu_call_insecure); + } + + hideMenuItem(menu, R.id.menu_mute_notifications); + } + + if (recipient != null && recipient.get().isBlocked()) { + if (isSecureText) { + hideMenuItem(menu, R.id.menu_call_secure); + hideMenuItem(menu, R.id.menu_video_secure); + hideMenuItem(menu, R.id.menu_expiring_messages); + hideMenuItem(menu, R.id.menu_expiring_messages_off); + } else { + hideMenuItem(menu, R.id.menu_call_insecure); + } + + hideMenuItem(menu, R.id.menu_mute_notifications); + } + + hideMenuItem(menu, R.id.menu_group_recipients); + + if (isActiveV2Group) { + hideMenuItem(menu, R.id.menu_mute_notifications); + hideMenuItem(menu, R.id.menu_conversation_settings); + } else if (isGroupConversation()) { + hideMenuItem(menu, R.id.menu_conversation_settings); + } + + hideMenuItem(menu, R.id.menu_create_bubble); + viewModel.canShowAsBubble().observe(this, canShowAsBubble -> { + MenuItem item = menu.findItem(R.id.menu_create_bubble); + + if (item != null) { + item.setVisible(canShowAsBubble && !isInBubble()); + } + }); + + searchViewItem = menu.findItem(R.id.menu_search); + + SearchView searchView = (SearchView) searchViewItem.getActionView(); + SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + searchViewModel.onQueryUpdated(query, threadId, true); + searchNav.showLoading(); + fragment.onSearchQueryUpdated(query); + return true; + } + + @Override + public boolean onQueryTextChange(String query) { + searchViewModel.onQueryUpdated(query, threadId, false); + searchNav.showLoading(); + fragment.onSearchQueryUpdated(query); + return true; + } + }; + + searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + searchView.setOnQueryTextListener(queryListener); + searchViewModel.onSearchOpened(); + searchNav.setVisibility(View.VISIBLE); + searchNav.setData(0, 0); + inputPanel.setVisibility(View.GONE); + + for (int i = 0; i < menu.size(); i++) { + if (!menu.getItem(i).equals(searchViewItem)) { + menu.getItem(i).setVisible(false); + } + } + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + searchView.setOnQueryTextListener(null); + searchViewModel.onSearchClosed(); + searchNav.setVisibility(View.GONE); + inputPanel.setVisibility(View.VISIBLE); + fragment.onSearchQueryUpdated(null); + setBlockedUserState(recipient.get(), isSecureText, isDefaultSms); + invalidateOptionsMenu(); + return true; + } + }); + + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + switch (item.getItemId()) { + case R.id.menu_call_secure: handleDial(getRecipient(), true); return true; + case R.id.menu_video_secure: handleVideo(getRecipient()); return true; + case R.id.menu_call_insecure: handleDial(getRecipient(), false); return true; + case R.id.menu_view_media: handleViewMedia(); return true; + case R.id.menu_add_shortcut: handleAddShortcut(); return true; + case R.id.menu_search: handleSearch(); return true; + case R.id.menu_add_to_contacts: handleAddToContacts(); return true; + case R.id.menu_reset_secure_session: handleResetSecureSession(); return true; + case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true; + case R.id.menu_distribution_broadcast: handleDistributionBroadcastEnabled(item); return true; + case R.id.menu_distribution_conversation: handleDistributionConversationEnabled(item); return true; + case R.id.menu_group_settings: handleManageGroup(); return true; + case R.id.menu_leave: handleLeavePushGroup(); return true; + case R.id.menu_invite: handleInviteLink(); return true; + case R.id.menu_mute_notifications: handleMuteNotifications(); return true; + case R.id.menu_unmute_notifications: handleUnmuteNotifications(); return true; + case R.id.menu_conversation_settings: handleConversationSettings(); return true; + case R.id.menu_expiring_messages_off: + case R.id.menu_expiring_messages: handleSelectMessageExpiration(); return true; + case R.id.menu_create_bubble: handleCreateBubble(); return true; + case android.R.id.home: super.onBackPressed(); return true; + } + + return false; + } + + @Override + public boolean onMenuOpened(int featureId, Menu menu) { + if (menu == null) { + return super.onMenuOpened(featureId, null); + } + + if (!SignalStore.uiHints().hasSeenGroupSettingsMenuToast()) { + MenuItem settingsMenuItem = menu.findItem(R.id.menu_group_settings); + + if (settingsMenuItem != null && settingsMenuItem.isVisible()) { + Toast toast = Toast.makeText(this, R.string.ConversationActivity__more_options_now_in_group_settings, Toast.LENGTH_SHORT); + + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + + SignalStore.uiHints().markHasSeenGroupSettingsMenuToast(); + } + } + + return super.onMenuOpened(featureId, menu); + } + + @Override + public void onBackPressed() { + Log.d(TAG, "onBackPressed()"); + if (reactionDelegate.isShowing()) { + reactionDelegate.hide(); + } else if (container.isInputOpen()) { + container.hideCurrentInput(composeText); + } else { + super.onBackPressed(); + } + } + + @Override + public void onKeyboardShown() { + inputPanel.onKeyboardShown(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(ReminderUpdateEvent event) { + updateReminders(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onAttachmentMediaClicked(@NonNull Media media) { + linkPreviewViewModel.onUserCancel(); + startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); + container.hideCurrentInput(composeText); + } + + @Override + public void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button) { + switch (button) { + case GALLERY: + AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()); + break; + case GIF: + AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this)); + break; + case FILE: + AttachmentManager.selectDocument(this, PICK_DOCUMENT); + break; + case CONTACT: + AttachmentManager.selectContactInfo(this, PICK_CONTACT); + break; + case LOCATION: + AttachmentManager.selectLocation(this, PICK_LOCATION); + break; + } + + container.hideCurrentInput(composeText); + } + + @Override + public void onAttachmentPermissionsRequested() { + Permissions.with(this) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .onAllGranted(() -> viewModel.onAttachmentKeyboardOpen()) + .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .execute(); + } + +//////// Event Handlers + + private void handleSelectMessageExpiration() { + boolean activeGroup = isActiveGroup(); + + if (isPushGroupConversation() && !activeGroup) { + return; + } + + final long thread = this.threadId; + + ExpirationDialog.show(this, recipient.get().getExpireMessages(), + expirationTime -> + SimpleTask.run( + getLifecycle(), + () -> { + if (activeGroup) { + try { + GroupManager.updateGroupTimer(ConversationActivity.this, getRecipient().requireGroupId().requirePush(), expirationTime); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + return GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)); + } + } else { + DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime); + OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L); + MessageSender.send(ConversationActivity.this, outgoingMessage, thread, false, null); + } + return GroupChangeResult.SUCCESS; + }, + (changeResult) -> { + if (!changeResult.isSuccess()) { + Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(changeResult.getFailureReason()), Toast.LENGTH_SHORT).show(); + } else { + invalidateOptionsMenu(); + if (fragment != null) fragment.setLastSeen(0); + } + }) + ); + } + + private void handleMuteNotifications() { + MuteDialog.show(this, until -> { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + DatabaseFactory.getRecipientDatabase(ConversationActivity.this) + .setMuted(recipient.getId(), until); + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + }); + } + + private void handleConversationSettings() { + if (isGroupConversation()) { + handleManageGroup(); + return; + } + + if (isInMessageRequest()) return; + + Intent intent = ManageRecipientActivity.newIntentFromConversation(this, recipient.getId()); + startActivitySceneTransition(intent, titleView.findViewById(R.id.contact_photo_image), "avatar"); + } + + private void handleUnmuteNotifications() { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + DatabaseFactory.getRecipientDatabase(ConversationActivity.this) + .setMuted(recipient.getId(), 0); + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void handleUnblock() { + BlockUnblockDialog.showUnblockFor(this, getLifecycle(), recipient.get(), () -> { + SignalExecutors.BOUNDED.execute(() -> { + RecipientUtil.unblock(ConversationActivity.this, recipient.get()); + }); + }); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private void handleMakeDefaultSms() { + startActivityForResult(SmsUtil.getSmsRoleIntent(this), SMS_DEFAULT); + } + + private void handleRegisterForSignal() { + startActivity(RegistrationNavigationActivity.newIntentForReRegistration(this)); + } + + private void handleInviteLink() { + String inviteText = getString(R.string.ConversationActivity_lets_switch_to_signal, getString(R.string.install_url)); + + if (isDefaultSms) { + composeText.appendInvite(inviteText); + } else { + Intent intent = new Intent(Intent.ACTION_SENDTO); + intent.setData(Uri.parse("smsto:" + recipient.get().requireSmsAddress())); + intent.putExtra("sms_body", inviteText); + intent.putExtra(Intent.EXTRA_TEXT, inviteText); + startActivity(intent); + } + } + + private void handleResetSecureSession() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.ConversationActivity_reset_secure_session_question); + builder.setIcon(R.drawable.ic_warning); + builder.setCancelable(true); + builder.setMessage(R.string.ConversationActivity_this_may_help_if_youre_having_encryption_problems); + builder.setPositiveButton(R.string.ConversationActivity_reset, (dialog, which) -> { + if (isSingleConversation()) { + final Context context = getApplicationContext(); + + OutgoingEndSessionMessage endSessionMessage = + new OutgoingEndSessionMessage(new OutgoingTextMessage(getRecipient(), "TERMINATE", 0, -1)); + + new AsyncTask() { + @Override + protected Long doInBackground(OutgoingEndSessionMessage... messages) { + return MessageSender.send(context, messages[0], threadId, false, null); + } + + @Override + protected void onPostExecute(Long result) { + sendComplete(result); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, endSessionMessage); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + private void handleViewMedia() { + startActivity(MediaOverviewActivity.forThread(this, threadId)); + } + + private void handleAddShortcut() { + Log.i(TAG, "Creating home screen shortcut for recipient " + recipient.get().getId()); + + final Context context = getApplicationContext(); + final Recipient recipient = this.recipient.get(); + + GlideApp.with(this) + .asBitmap() + .load(recipient.getContactPhoto()) + .error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getColor().toAvatarColor(this), false)) + .into(new CustomTarget() { + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + if (errorDrawable == null) { + throw new AssertionError(); + } + + Log.w(TAG, "Utilizing fallback photo for shortcut for recipient " + recipient.getId()); + + SimpleTask.run(() -> DrawableUtil.toBitmap(errorDrawable, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE), + bitmap -> addIconToHomeScreen(context, bitmap, recipient)); + } + + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + SimpleTask.run(() -> BitmapUtil.createScaledBitmap(resource, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE), + bitmap -> addIconToHomeScreen(context, bitmap, recipient)); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + + } + + private void handleCreateBubble() { + ConversationIntents.Args args = viewModel.getArgs(); + + BubbleUtil.displayAsBubble(this, args.getRecipientId(), args.getThreadId()); + finish(); + } + + private static void addIconToHomeScreen(@NonNull Context context, + @NonNull Bitmap bitmap, + @NonNull Recipient recipient) + { + IconCompat icon = IconCompat.createWithAdaptiveBitmap(bitmap); + String name = recipient.isSelf() ? context.getString(R.string.note_to_self) + : recipient.getDisplayName(context); + + ShortcutInfoCompat shortcutInfoCompat = new ShortcutInfoCompat.Builder(context, recipient.getId().serialize() + '-' + System.currentTimeMillis()) + .setShortLabel(name) + .setIcon(icon) + .setIntent(ShortcutLauncherActivity.createIntent(context, recipient.getId())) + .build(); + + if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) { + Toast.makeText(context, context.getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show(); + } + + bitmap.recycle(); + } + + private void handleSearch() { + searchViewModel.onSearchOpened(); + } + + private void handleLeavePushGroup() { + if (getRecipient() == null) { + Toast.makeText(this, getString(R.string.ConversationActivity_invalid_recipient), + Toast.LENGTH_LONG).show(); + return; + } + + LeaveGroupDialog.handleLeavePushGroup(this, getRecipient().requireGroupId().requirePush(), this::finish); + } + + private void handleManageGroup() { + startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId()), + GROUP_EDIT, + ManageGroupActivity.createTransitionBundle(this, titleView.findViewById(R.id.contact_photo_image))); + } + + private void handleDistributionBroadcastEnabled(MenuItem item) { + distributionType = ThreadDatabase.DistributionTypes.BROADCAST; + item.setChecked(true); + + if (threadId != -1) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + DatabaseFactory.getThreadDatabase(ConversationActivity.this) + .setDistributionType(threadId, ThreadDatabase.DistributionTypes.BROADCAST); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private void handleDistributionConversationEnabled(MenuItem item) { + distributionType = ThreadDatabase.DistributionTypes.CONVERSATION; + item.setChecked(true); + + if (threadId != -1) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + DatabaseFactory.getThreadDatabase(ConversationActivity.this) + .setDistributionType(threadId, ThreadDatabase.DistributionTypes.CONVERSATION); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private void handleDial(final Recipient recipient, boolean isSecure) { + if (recipient == null) return; + + if (isSecure) { + CommunicationActions.startVoiceCall(this, recipient); + } else { + CommunicationActions.startInsecureCall(this, recipient); + } + } + + private void handleVideo(final Recipient recipient) { + if (recipient == null) return; + + CommunicationActions.startVideoCall(this, recipient); + } + + private void handleDisplayGroupRecipients() { + new GroupMembersDialog(this, getRecipient()).display(); + } + + private void handleAddToContacts() { + if (recipient.get().isGroup()) return; + + try { + startActivityForResult(RecipientExporter.export(recipient.get()).asAddContactIntent(), ADD_CONTACT); + } catch (ActivityNotFoundException e) { + Log.w(TAG, e); + } + } + + private boolean handleDisplayQuickContact() { + if (isInMessageRequest() || recipient.get().isGroup()) return false; + + if (recipient.get().getContactUri() != null) { + ContactsContract.QuickContact.showQuickContact(ConversationActivity.this, titleView, recipient.get().getContactUri(), ContactsContract.QuickContact.MODE_LARGE, null); + } else { + handleAddToContacts(); + } + + return true; + } + + private void handleAddAttachment() { + if (this.isMmsEnabled || isSecureText) { + viewModel.getRecentMedia().removeObservers(this); + + if (attachmentKeyboardStub.resolved() && container.isInputOpen() && container.getCurrentInput() == attachmentKeyboardStub.get()) { + container.showSoftkey(composeText); + } else { + viewModel.getRecentMedia().observe(this, media -> attachmentKeyboardStub.get().onMediaChanged(media)); + attachmentKeyboardStub.get().setCallback(this); + attachmentKeyboardStub.get().setWallpaperEnabled(recipient.get().hasWallpaper()); + container.show(composeText, attachmentKeyboardStub.get()); + + viewModel.onAttachmentKeyboardOpen(); + } + } else { + handleManualMmsRequired(); + } + } + + private void handleManualMmsRequired() { + Toast.makeText(this, R.string.MmsDownloader_error_reading_mms_settings, Toast.LENGTH_LONG).show(); + + Bundle extras = getIntent().getExtras(); + Intent intent = new Intent(this, PromptMmsActivity.class); + if (extras != null) intent.putExtras(extras); + startActivity(intent); + } + + private void handleRecentSafetyNumberChange() { + List records = identityRecords.getUnverifiedRecords(); + records.addAll(identityRecords.getUntrustedRecords()); + SafetyNumberChangeDialog.show(getSupportFragmentManager(), records); + } + + @Override + public void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients) { + initializeIdentityRecords().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + sendMessage(); + } + }); + } + + @Override + public void onMessageResentAfterSafetyNumberChange() { + initializeIdentityRecords().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { } + }); + } + + @Override + public void onCanceled() { } + + private void handleSecurityChange(boolean isSecureText, boolean isDefaultSms) { + Log.i(TAG, "handleSecurityChange(" + isSecureText + ", " + isDefaultSms + ")"); + + this.isSecureText = isSecureText; + this.isDefaultSms = isDefaultSms; + this.isSecurityInitialized = true; + + boolean isMediaMessage = recipient.get().isMmsGroup() || attachmentManager.isAttachmentPresent(); + + sendButton.resetAvailableTransports(isMediaMessage); + + if (!isSecureText && !isPushGroupConversation()) sendButton.disableTransport(Type.TEXTSECURE); + if (recipient.get().isPushGroup()) sendButton.disableTransport(Type.SMS); + + if (!recipient.get().isPushGroup() && recipient.get().isForceSmsSelection()) { + sendButton.setDefaultTransport(Type.SMS); + } else { + if (isSecureText || isPushGroupConversation()) sendButton.setDefaultTransport(Type.TEXTSECURE); + else sendButton.setDefaultTransport(Type.SMS); + } + + calculateCharactersRemaining(); + supportInvalidateOptionsMenu(); + setBlockedUserState(recipient.get(), isSecureText, isDefaultSms); + } + + ///// Initializers + + private ListenableFuture initializeDraft(@NonNull ConversationIntents.Args args) { + final SettableFuture result = new SettableFuture<>(); + + final CharSequence draftText = args.getDraftText(); + final Uri draftMedia = getIntent().getData(); + final String draftContentType = getIntent().getType(); + final MediaType draftMediaType = SlideFactory.MediaType.from(draftContentType); + final List mediaList = args.getMedia(); + final StickerLocator stickerLocator = args.getStickerLocator(); + final boolean borderless = args.isBorderless(); + + if (stickerLocator != null && draftMedia != null) { + Log.d(TAG, "Handling shared sticker."); + sendSticker(stickerLocator, Objects.requireNonNull(draftContentType), draftMedia, 0, true); + return new SettableFuture<>(false); + } + + if (draftMedia != null && draftContentType != null && borderless) { + SimpleTask.run(getLifecycle(), + () -> getKeyboardImageDetails(draftMedia), + details -> sendKeyboardImage(draftMedia, draftContentType, details)); + return new SettableFuture<>(false); + } + + if (!Util.isEmpty(mediaList)) { + Log.d(TAG, "Handling shared Media."); + Intent sendIntent = MediaSendActivity.buildEditorIntent(this, mediaList, recipient.get(), draftText, sendButton.getSelectedTransport()); + startActivityForResult(sendIntent, MEDIA_SENDER); + return new SettableFuture<>(false); + } + + if (draftText != null) { + composeText.setText(""); + composeText.append(draftText); + result.set(true); + } + + if (draftMedia != null && draftMediaType != null) { + Log.d(TAG, "Handling shared Data."); + return setMedia(draftMedia, draftMediaType); + } + + if (draftText == null && draftMedia == null && draftMediaType == null) { + return initializeDraftFromDatabase(); + } else { + updateToggleButtonState(); + result.set(false); + } + + return result; + } + + private void initializeEnabledCheck() { + groupViewModel.getSelfMemberLevel().observe(this, selfMemberShip -> { + boolean canSendMessages; + boolean leftGroup; + boolean canCancelRequest; + + if (selfMemberShip == null) { + leftGroup = false; + canSendMessages = true; + canCancelRequest = false; + } else { + switch (selfMemberShip) { + case NOT_A_MEMBER: + leftGroup = true; + canSendMessages = false; + canCancelRequest = false; + break; + case PENDING_MEMBER: + leftGroup = false; + canSendMessages = false; + canCancelRequest = false; + break; + case REQUESTING_MEMBER: + leftGroup = false; + canSendMessages = false; + canCancelRequest = true; + break; + case FULL_MEMBER: + case ADMINISTRATOR: + leftGroup = false; + canSendMessages = true; + canCancelRequest = false; + break; + default: + throw new AssertionError(); + } + } + + noLongerMemberBanner.setVisibility(leftGroup ? View.VISIBLE : View.GONE); + requestingMemberBanner.setVisibility(canCancelRequest ? View.VISIBLE : View.GONE); + if (canCancelRequest) { + cancelJoinRequest.setOnClickListener(v -> ConversationGroupViewModel.onCancelJoinRequest(getRecipient(), new AsynchronousCallback.MainThread() { + @Override + public void onComplete(@Nullable Void result) { + Log.d(TAG, "Cancel request complete"); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + Log.d(TAG, "Cancel join request failed " + error); + Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_SHORT).show(); + } + }.toWorkerCallback())); + } + + inputPanel.setVisibility(canSendMessages ? View.VISIBLE : View.GONE); + inputPanel.setEnabled(canSendMessages); + sendButton.setEnabled(canSendMessages); + attachButton.setEnabled(canSendMessages); + }); + } + + private void initializePendingRequestsBanner() { + groupViewModel.getActionableRequestingMembers() + .observe(this, actionablePendingGroupRequests -> updateReminders()); + } + + private void initializeGroupV1MigrationsBanners() { + groupViewModel.getGroupV1MigrationSuggestions() + .observe(this, s -> updateReminders()); + groupViewModel.getShowGroupsV1MigrationBanner() + .observe(this, b -> updateReminders()); + } + + + private ListenableFuture initializeDraftFromDatabase() { + SettableFuture future = new SettableFuture<>(); + + new AsyncTask>() { + @Override + protected Pair doInBackground(Void... params) { + Context context = ConversationActivity.this; + DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(context); + Drafts results = draftDatabase.getDrafts(threadId); + Draft mentionsDraft = results.getDraftOfType(Draft.MENTION); + Spannable updatedText = null; + + if (mentionsDraft != null) { + String text = results.getDraftOfType(Draft.TEXT).getValue(); + List mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.getValue())); + UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions); + + updatedText = new SpannableString(updated.getBody()); + MentionAnnotation.setMentionAnnotations(updatedText, updated.getMentions()); + } + + draftDatabase.clearDrafts(threadId); + + return new Pair<>(results, updatedText); + } + + @Override + protected void onPostExecute(Pair draftsWithUpdatedMentions) { + Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first()); + CharSequence updatedText = draftsWithUpdatedMentions.second(); + + if (drafts.isEmpty()) { + future.set(false); + updateToggleButtonState(); + return; + } + + AtomicInteger draftsRemaining = new AtomicInteger(drafts.size()); + AtomicBoolean success = new AtomicBoolean(false); + ListenableFuture.Listener listener = new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + success.compareAndSet(false, result); + + if (draftsRemaining.decrementAndGet() <= 0) { + future.set(success.get()); + } + } + }; + + for (Draft draft : drafts) { + try { + switch (draft.getType()) { + case Draft.TEXT: + composeText.setText(updatedText == null ? draft.getValue() : updatedText); + listener.onSuccess(true); + break; + case Draft.LOCATION: + attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener); + break; + case Draft.IMAGE: + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.IMAGE).addListener(listener); + break; + case Draft.AUDIO: + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.AUDIO).addListener(listener); + break; + case Draft.VIDEO: + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.VIDEO).addListener(listener); + break; + case Draft.QUOTE: + SettableFuture quoteResult = new SettableFuture<>(); + new QuoteRestorationTask(draft.getValue(), quoteResult).execute(); + quoteResult.addListener(listener); + break; + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + + updateToggleButtonState(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return future; + } + + private ListenableFuture initializeSecurity(final boolean currentSecureText, + final boolean currentIsDefaultSms) + { + final SettableFuture future = new SettableFuture<>(); + + handleSecurityChange(currentSecureText || isPushGroupConversation(), currentIsDefaultSms); + + new AsyncTask() { + @Override + protected boolean[] doInBackground(Recipient... params) { + Context context = ConversationActivity.this; + Recipient recipient = params[0].resolve(); + Log.i(TAG, "Resolving registered state..."); + RegisteredState registeredState; + + if (recipient.isPushGroup()) { + Log.i(TAG, "Push group recipient..."); + registeredState = RegisteredState.REGISTERED; + } else { + Log.i(TAG, "Checking through resolved recipient"); + registeredState = recipient.resolve().getRegistered(); + } + + Log.i(TAG, "Resolved registered state: " + registeredState); + boolean signalEnabled = TextSecurePreferences.isPushRegistered(context); + + if (registeredState == RegisteredState.UNKNOWN) { + try { + Log.i(TAG, "Refreshing directory for user: " + recipient.getId().serialize()); + registeredState = DirectoryHelper.refreshDirectoryFor(context, recipient, false); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + Log.i(TAG, "Returning registered state..."); + return new boolean[] {registeredState == RegisteredState.REGISTERED && signalEnabled, + Util.isDefaultSmsProvider(context)}; + } + + @Override + protected void onPostExecute(boolean[] result) { + if (result[0] != currentSecureText || result[1] != currentIsDefaultSms) { + Log.i(TAG, "onPostExecute() handleSecurityChange: " + result[0] + " , " + result[1]); + handleSecurityChange(result[0], result[1]); + } + future.set(true); + onSecurityUpdated(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, recipient.get()); + + return future; + } + + private void onSecurityUpdated() { + Log.i(TAG, "onSecurityUpdated()"); + updateReminders(); + updateDefaultSubscriptionId(recipient.get().getDefaultSubscriptionId()); + } + + private void initializeInsightObserver() { + inviteReminderModel = new InviteReminderModel(this, new InviteReminderRepository(this)); + inviteReminderModel.loadReminder(recipient, this::updateReminders); + } + + protected void updateReminders() { + Optional inviteReminder = inviteReminderModel.getReminder(); + Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue(); + List gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue(); + Boolean gv1MigrationBanner = groupViewModel.getShowGroupsV1MigrationBanner().getValue(); + + if (UnauthorizedReminder.isEligible(this)) { + reminderView.get().showReminder(new UnauthorizedReminder(this)); + } else if (ExpiredBuildReminder.isEligible()) { + reminderView.get().showReminder(new ExpiredBuildReminder(this)); + reminderView.get().setOnActionClickListener(this::handleReminderAction); + } else if (ServiceOutageReminder.isEligible(this)) { + ApplicationDependencies.getJobManager().add(new ServiceOutageDetectionJob()); + reminderView.get().showReminder(new ServiceOutageReminder(this)); + } else if (TextSecurePreferences.isPushRegistered(this) && + TextSecurePreferences.isShowInviteReminders(this) && + !isSecureText && + inviteReminder.isPresent() && + !recipient.get().isGroup()) { + reminderView.get().setOnActionClickListener(this::handleReminderAction); + reminderView.get().setOnDismissListener(() -> inviteReminderModel.dismissReminder()); + reminderView.get().showReminder(inviteReminder.get()); + } else if (actionableRequestingMembers != null && actionableRequestingMembers > 0) { + reminderView.get().showReminder(PendingGroupJoinRequestsReminder.create(this, actionableRequestingMembers)); + reminderView.get().setOnActionClickListener(id -> { + if (id == R.id.reminder_action_review_join_requests) { + startActivity(ManagePendingAndRequestingMembersActivity.newIntent(this, getRecipient().getGroupId().get().requireV2())); + } + }); + } else if (gv1MigrationBanner == Boolean.TRUE && recipient.get().isPushV1Group()) { + reminderView.get().showReminder(new GroupsV1MigrationInitiationReminder(this)); + reminderView.get().setOnActionClickListener(actionId -> { + if (actionId == R.id.reminder_action_gv1_initiation_update_group) { + GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(getSupportFragmentManager(), recipient.getId()); + } else if (actionId == R.id.reminder_action_gv1_initiation_not_now) { + groupViewModel.onMigrationInitiationReminderBannerDismissed(recipient.getId()); + } + }); + } else if (gv1MigrationSuggestions != null && gv1MigrationSuggestions.size() > 0 && recipient.get().isPushV2Group()) { + reminderView.get().showReminder(new GroupsV1MigrationSuggestionsReminder(this, gv1MigrationSuggestions)); + reminderView.get().setOnActionClickListener(actionId -> { + if (actionId == R.id.reminder_action_gv1_suggestion_add_members) { + GroupsV1MigrationSuggestionsDialog.show(this, recipient.get().requireGroupId().requireV2(), gv1MigrationSuggestions); + } else if (actionId == R.id.reminder_action_gv1_suggestion_no_thanks) { + groupViewModel.onSuggestedMembersBannerDismissed(recipient.get().requireGroupId(), gv1MigrationSuggestions); + } + }); + reminderView.get().setOnDismissListener(() -> { + }); + } else if (reminderView.resolved()) { + reminderView.get().hide(); + } + } + + private void handleReminderAction(@IdRes int reminderActionId) { + if (reminderActionId == R.id.reminder_action_invite) { + handleInviteLink(); + reminderView.get().requestDismiss(); + } else if (reminderActionId == R.id.reminder_action_view_insights) { + InsightsLauncher.showInsightsDashboard(getSupportFragmentManager()); + } else if (reminderActionId == R.id.reminder_action_update_now) { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this); + } else { + throw new IllegalArgumentException("Unknown ID: " + reminderActionId); + } + } + + private void updateDefaultSubscriptionId(Optional defaultSubscriptionId) { + Log.i(TAG, "updateDefaultSubscriptionId(" + defaultSubscriptionId.orNull() + ")"); + sendButton.setDefaultSubscriptionId(defaultSubscriptionId); + } + + private void initializeMmsEnabledCheck() { + new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + return Util.isMmsCapable(ConversationActivity.this); + } + + @Override + protected void onPostExecute(Boolean isMmsEnabled) { + ConversationActivity.this.isMmsEnabled = isMmsEnabled; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private ListenableFuture initializeIdentityRecords() { + final SettableFuture future = new SettableFuture<>(); + + new AsyncTask>() { + @Override + protected @NonNull Pair doInBackground(Recipient... params) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this); + List recipients; + + if (params[0].isGroup()) { + recipients = DatabaseFactory.getGroupDatabase(ConversationActivity.this) + .getGroupMembers(params[0].requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + } else { + recipients = Collections.singletonList(params[0]); + } + + long startTime = System.currentTimeMillis(); + IdentityRecordList identityRecordList = identityDatabase.getIdentities(recipients); + + Log.i(TAG, String.format(Locale.US, "Loaded %d identities in %d ms", recipients.size(), System.currentTimeMillis() - startTime)); + + String message = null; + + if (identityRecordList.isUnverified()) { + message = IdentityUtil.getUnverifiedBannerDescription(ConversationActivity.this, identityRecordList.getUnverifiedRecipients()); + } + + return new Pair<>(identityRecordList, message); + } + + @Override + protected void onPostExecute(@NonNull Pair result) { + Log.i(TAG, "Got identity records: " + result.first().isUnverified()); + identityRecords = result.first(); + + if (result.second() != null) { + Log.d(TAG, "Replacing banner..."); + unverifiedBannerView.get().display(result.second(), result.first().getUnverifiedRecords(), + new UnverifiedClickedListener(), + new UnverifiedDismissedListener()); + } else if (unverifiedBannerView.resolved()) { + Log.d(TAG, "Clearing banner..."); + unverifiedBannerView.get().hide(); + } + + titleView.setVerified(isSecureText && identityRecords.isVerified()); + + future.set(true); + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, recipient.get()); + + return future; + } + + private void initializeViews() { + titleView = findViewById(R.id.conversation_title_view); + buttonToggle = findViewById(R.id.button_toggle); + sendButton = findViewById(R.id.send_button); + attachButton = findViewById(R.id.attach_button); + composeText = findViewById(R.id.embedded_text_editor); + charactersLeft = findViewById(R.id.space_left); + emojiDrawerStub = ViewUtil.findStubById(this, R.id.emoji_drawer_stub); + attachmentKeyboardStub = ViewUtil.findStubById(this, R.id.attachment_keyboard_stub); + unblockButton = findViewById(R.id.unblock_button); + makeDefaultSmsButton = findViewById(R.id.make_default_sms_button); + registerButton = findViewById(R.id.register_button); + container = findViewById(R.id.layout_container); + reminderView = ViewUtil.findStubById(this, R.id.reminder_stub); + unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub); + reviewBanner = ViewUtil.findStubById(this, R.id.review_banner_stub); + quickAttachmentToggle = findViewById(R.id.quick_attachment_toggle); + inlineAttachmentToggle = findViewById(R.id.inline_attachment_container); + inputPanel = findViewById(R.id.bottom_panel); + panelParent = findViewById(R.id.conversation_activity_panel_parent); + searchNav = findViewById(R.id.conversation_search_nav); + messageRequestBottomView = findViewById(R.id.conversation_activity_message_request_bottom_bar); + mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub); + wallpaper = findViewById(R.id.conversation_wallpaper); + wallpaperDim = findViewById(R.id.conversation_wallpaper_dim); + + ImageButton quickCameraToggle = findViewById(R.id.quick_camera_toggle); + ImageButton inlineAttachmentButton = findViewById(R.id.inline_attachment_button); + + Stub reactionOverlayStub = ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub); + reactionDelegate = new ConversationReactionDelegate(reactionOverlayStub); + + + noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner); + requestingMemberBanner = findViewById(R.id.conversation_requesting_banner); + cancelJoinRequest = findViewById(R.id.conversation_cancel_request); + joinGroupCallButton = findViewById(R.id.conversation_group_call_join); + + container.addOnKeyboardShownListener(this); + inputPanel.setListener(this); + inputPanel.setMediaListener(this); + + attachmentManager = new AttachmentManager(this, this); + audioRecorder = new AudioRecorder(this); + typingTextWatcher = new TypingStatusTextWatcher(); + + SendButtonListener sendButtonListener = new SendButtonListener(); + ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); + + composeText.setOnEditorActionListener(sendButtonListener); + composeText.setCursorPositionChangedListener(this); + attachButton.setOnClickListener(new AttachButtonListener()); + attachButton.setOnLongClickListener(new AttachButtonLongClickListener()); + sendButton.setOnClickListener(sendButtonListener); + sendButton.setEnabled(true); + sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { + calculateCharactersRemaining(); + updateLinkPreviewState(); + linkPreviewViewModel.onTransportChanged(newTransport.isSms()); + composeText.setTransport(newTransport); + + buttonToggle.getBackground().setColorFilter(newTransport.getBackgroundColor(), PorterDuff.Mode.MULTIPLY); + buttonToggle.getBackground().invalidateSelf(); + + if (manuallySelected) recordTransportPreference(newTransport); + }); + + titleView.setOnClickListener(v -> handleConversationSettings()); + titleView.setOnLongClickListener(v -> handleDisplayQuickContact()); + unblockButton.setOnClickListener(v -> handleUnblock()); + makeDefaultSmsButton.setOnClickListener(v -> handleMakeDefaultSms()); + registerButton.setOnClickListener(v -> handleRegisterForSignal()); + + composeText.setOnKeyListener(composeKeyPressedListener); + composeText.addTextChangedListener(composeKeyPressedListener); + composeText.setOnEditorActionListener(sendButtonListener); + composeText.setOnClickListener(composeKeyPressedListener); + composeText.setOnFocusChangeListener(composeKeyPressedListener); + + if (Camera.getNumberOfCameras() > 0) { + quickCameraToggle.setVisibility(View.VISIBLE); + quickCameraToggle.setOnClickListener(new QuickCameraToggleListener()); + } else { + quickCameraToggle.setVisibility(View.GONE); + } + + searchNav.setEventListener(this); + + inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment()); + + reactionDelegate.setOnReactionSelectedListener(this); + + joinGroupCallButton.setOnClickListener(v -> handleVideo(getRecipient())); + } + + private void updateWallpaper(@Nullable ChatWallpaper chatWallpaper) { + Log.d(TAG, "Setting wallpaper."); + if (chatWallpaper != null) { + chatWallpaper.loadInto(wallpaper); + ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(wallpaperDim, chatWallpaper); + inputPanel.setWallpaperEnabled(true); + if (attachmentKeyboardStub.resolved()) { + attachmentKeyboardStub.get().setWallpaperEnabled(true); + } + + int toolbarColor = getResources().getColor(R.color.conversation_toolbar_color_wallpaper); + toolbar.setBackgroundColor(toolbarColor); + if (Build.VERSION.SDK_INT > 21) { + WindowUtil.setStatusBarColor(getWindow(), toolbarColor); + } + } else { + wallpaper.setImageDrawable(null); + wallpaperDim.setVisibility(View.GONE); + inputPanel.setWallpaperEnabled(false); + if (attachmentKeyboardStub.resolved()) { + attachmentKeyboardStub.get().setWallpaperEnabled(false); + } + + int toolbarColor = getResources().getColor(R.color.conversation_toolbar_color); + toolbar.setBackgroundColor(toolbarColor); + if (Build.VERSION.SDK_INT > 21) { + WindowUtil.setStatusBarColor(getWindow(), toolbarColor); + } + } + fragment.onWallpaperChanged(chatWallpaper); + } + + protected void initializeActionBar() { + toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + ActionBar supportActionBar = getSupportActionBar(); + if (supportActionBar == null) throw new AssertionError(); + + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setDisplayShowTitleEnabled(false); + + if (isInBubble()) { + supportActionBar.setHomeAsUpIndicator(DrawableUtil.tint(ContextUtil.requireDrawable(this, R.drawable.ic_notification), + ContextCompat.getColor(this, R.color.signal_accent_primary))); + toolbar.setNavigationOnClickListener(unused -> startActivity(MainActivity.clearTop(this))); + } + } + + private boolean isInBubble() { + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + Display display = getDisplay(); + + return display != null && display.getDisplayId() != Display.DEFAULT_DISPLAY; + } else { + return false; + } + } + + private void initializeResources(@NonNull ConversationIntents.Args args) { + if (recipient != null) { + recipient.removeObservers(this); + } + + recipient = Recipient.live(args.getRecipientId()); + threadId = args.getThreadId(); + distributionType = args.getDistributionType(); + glideRequests = GlideApp.with(this); + + recipient.observe(this, this::onRecipientChanged); + } + + private void initializeLinkPreviewObserver() { + linkPreviewViewModel = ViewModelProviders.of(this, new LinkPreviewViewModel.Factory(new LinkPreviewRepository())).get(LinkPreviewViewModel.class); + + linkPreviewViewModel.getLinkPreviewState().observe(this, previewState -> { + if (previewState == null) return; + + if (previewState.isLoading()) { + inputPanel.setLinkPreviewLoading(); + } else if (previewState.hasLinks() && !previewState.getLinkPreview().isPresent()) { + inputPanel.setLinkPreviewNoPreview(previewState.getError()); + } else { + inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview()); + } + + updateToggleButtonState(); + }); + } + + private void initializeSearchObserver() { + searchViewModel = ViewModelProviders.of(this).get(ConversationSearchViewModel.class); + + searchViewModel.getSearchResults().observe(this, result -> { + if (result == null) return; + + if (!result.getResults().isEmpty()) { + MessageResult messageResult = result.getResults().get(result.getPosition()); + fragment.jumpToMessage(messageResult.messageRecipient.getId(), messageResult.receivedTimestampMs, searchViewModel::onMissingResult); + } + + searchNav.setData(result.getPosition(), result.getResults().size()); + }); + } + + private void initializeStickerObserver() { + StickerSearchRepository repository = new StickerSearchRepository(this); + + stickerViewModel = ViewModelProviders.of(this, new ConversationStickerViewModel.Factory(getApplication(), repository)) + .get(ConversationStickerViewModel.class); + + stickerViewModel.getStickerResults().observe(this, stickers -> { + if (stickers == null) return; + + inputPanel.setStickerSuggestions(stickers); + }); + + stickerViewModel.getStickersAvailability().observe(this, stickersAvailable -> { + if (stickersAvailable == null) return; + + boolean isSystemEmojiPreferred = TextSecurePreferences.isSystemEmojiPreferred(this); + MediaKeyboardMode keyboardMode = TextSecurePreferences.getMediaKeyboardMode(this); + boolean stickerIntro = !TextSecurePreferences.hasSeenStickerIntroTooltip(this); + + if (stickersAvailable) { + inputPanel.showMediaKeyboardToggle(true); + inputPanel.setMediaKeyboardToggleMode(isSystemEmojiPreferred || keyboardMode == MediaKeyboardMode.STICKER); + if (stickerIntro) showStickerIntroductionTooltip(); + } + + if (emojiDrawerStub.resolved()) { + initializeMediaKeyboardProviders(emojiDrawerStub.get(), stickersAvailable); + } + }); + } + + private void initializeViewModel(@NonNull ConversationIntents.Args args) { + this.viewModel = ViewModelProviders.of(this, new ConversationViewModel.Factory()).get(ConversationViewModel.class); + + this.viewModel.setArgs(args); + this.viewModel.getWallpaper().observe(this, this::updateWallpaper); + } + + private void initializeGroupViewModel() { + groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class); + recipient.observe(this, groupViewModel::onRecipientChange); + groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu()); + groupViewModel.getReviewState().observe(this, this::presentGroupReviewBanner); + } + + private void initializeMentionsViewModel() { + mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); + + recipient.observe(this, r -> { + if (r.isPushV2Group() && !mentionsSuggestions.resolved()) { + mentionsSuggestions.get(); + } + mentionsViewModel.onRecipientChange(r); + }); + + composeText.setMentionQueryChangedListener(query -> { + if (getRecipient().isPushV2Group() && getRecipient().isActiveGroup()) { + if (!mentionsSuggestions.resolved()) { + mentionsSuggestions.get(); + } + mentionsViewModel.onQueryChange(query); + } + }); + + composeText.setMentionValidator(annotations -> { + if (!getRecipient().isPushV2Group() || !getRecipient().isActiveGroup()) { + return annotations; + } + + Set validRecipientIds = Stream.of(getRecipient().getParticipants()) + .map(r -> MentionAnnotation.idToMentionAnnotationValue(r.getId())) + .collect(Collectors.toSet()); + + return Stream.of(annotations) + .filterNot(a -> validRecipientIds.contains(a.getValue())) + .toList(); + }); + + mentionsViewModel.getSelectedRecipient().observe(this, recipient -> { + composeText.replaceTextWithMention(recipient.getDisplayName(this), recipient.getId()); + }); + } + + public void initializeGroupCallViewModel() { + groupCallViewModel = ViewModelProviders.of(this, new GroupCallViewModel.Factory()).get(GroupCallViewModel.class); + + recipient.observe(this, r -> { + groupCallViewModel.onRecipientChange(this, r); + }); + + groupCallViewModel.hasActiveGroupCall().observe(this, hasActiveCall -> { + invalidateOptionsMenu(); + joinGroupCallButton.setVisibility(hasActiveCall ? View.VISIBLE : View.GONE); + }); + + groupCallViewModel.groupCallHasCapacity().observe(this, hasCapacity -> joinGroupCallButton.setText(hasCapacity ? R.string.ConversationActivity_join : R.string.ConversationActivity_full)); + } + + private void showGroupCallingTooltip() { + if (!FeatureFlags.groupCalling() || !SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) { + return; + } + + View anchor = findViewById(R.id.menu_video_secure); + if (anchor == null) { + Log.w(TAG, "Video Call tooltip anchor is null. Skipping tooltip..."); + return; + } + + callingTooltipShown = true; + + SignalStore.tooltips().markGroupCallSpeakerViewSeen(); + TooltipPopup.forTarget(anchor) + .setBackgroundTint(ContextCompat.getColor(this, R.color.signal_accent_green)) + .setTextColor(getResources().getColor(R.color.core_white)) + .setText(R.string.ConversationActivity__tap_here_to_start_a_group_call) + .setOnDismissListener(() -> SignalStore.tooltips().markGroupCallingTooltipSeen()) + .show(TooltipPopup.POSITION_BELOW); + } + + private void showStickerIntroductionTooltip() { + TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER); + inputPanel.setMediaKeyboardToggleMode(true); + + TooltipPopup.forTarget(inputPanel.getMediaKeyboardToggleAnchorView()) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) + .setTextColor(getResources().getColor(R.color.core_white)) + .setText(R.string.ConversationActivity_new_say_it_with_stickers) + .setOnDismissListener(() -> { + TextSecurePreferences.setHasSeenStickerIntroTooltip(this, true); + EventBus.getDefault().removeStickyEvent(StickerPackInstallEvent.class); + }) + .show(TooltipPopup.POSITION_ABOVE); + } + + @Override + public void onReactionSelected(MessageRecord messageRecord, String emoji) { + final Context context = getApplicationContext(); + + reactionDelegate.hide(); + + SignalExecutors.BOUNDED.execute(() -> { + ReactionRecord oldRecord = Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor().equals(Recipient.self().getId())) + .findFirst() + .orElse(null); + + if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) { + MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord); + } else { + MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji); + } + }); + } + + @Override + public void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji) { + ReactionRecord oldRecord = Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor().equals(Recipient.self().getId())) + .findFirst() + .orElse(null); + + if (oldRecord != null && hasAddedCustomEmoji) { + final Context context = getApplicationContext(); + + reactionDelegate.hide(); + + SignalExecutors.BOUNDED.execute(() -> MessageSender.sendReactionRemoval(context, + messageRecord.getId(), + messageRecord.isMms(), + oldRecord)); + } else { + reactionDelegate.hideAllButMask(); + + ReactWithAnyEmojiBottomSheetDialogFragment.createForMessageRecord(messageRecord, reactWithAnyEmojiStartPage) + .show(getSupportFragmentManager(), "BOTTOM"); + } + } + + @Override + public void onReactWithAnyEmojiDialogDismissed() { + reactionDelegate.hideMask(); + } + + @Override + public void onReactWithAnyEmojiPageChanged(int page) { + reactWithAnyEmojiStartPage = page; + } + + @Override + public void onReactWithAnyEmojiSelected(@NonNull String emoji) { + } + + @Override + public void onSearchMoveUpPressed() { + searchViewModel.onMoveUp(); + } + + @Override + public void onSearchMoveDownPressed() { + searchViewModel.onMoveDown(); + } + + private void initializeProfiles() { + if (!isSecureText) { + Log.i(TAG, "SMS contact, no profile fetch needed."); + return; + } + + RetrieveProfileJob.enqueueAsync(recipient.getId()); + } + + private void initializeGv1Migration() { + GroupV1MigrationJob.enqueuePossibleAutoMigrate(recipient.getId()); + } + + private void onRecipientChanged(@NonNull Recipient recipient) { + Log.i(TAG, "onModified(" + recipient.getId() + ") " + recipient.getRegistered()); + titleView.setTitle(glideRequests, recipient); + titleView.setVerified(identityRecords.isVerified()); + setBlockedUserState(recipient, isSecureText, isDefaultSms); + updateReminders(); + updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId()); + initializeSecurity(isSecureText, isDefaultSms); + + if (searchViewItem == null || !searchViewItem.isActionViewExpanded()) { + invalidateOptionsMenu(); + } + + if (groupViewModel != null) { + groupViewModel.onRecipientChange(recipient); + } + + if (mentionsViewModel != null) { + mentionsViewModel.onRecipientChange(recipient); + } + + if (groupCallViewModel != null) { + groupCallViewModel.onRecipientChange(this, recipient); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onIdentityRecordUpdate(final IdentityRecord event) { + initializeIdentityRecords(); + } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + public void onStickerPackInstalled(final StickerPackInstallEvent event) { + if (!TextSecurePreferences.hasSeenStickerIntroTooltip(this)) return; + + EventBus.getDefault().removeStickyEvent(event); + + if (!inputPanel.isStickerMode()) { + TooltipPopup.forTarget(inputPanel.getMediaKeyboardToggleAnchorView()) + .setText(R.string.ConversationActivity_sticker_pack_installed) + .setIconGlideModel(event.getIconGlideModel()) + .show(TooltipPopup.POSITION_ABOVE); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + public void onGroupCallPeekEvent(@NonNull GroupCallPeekEvent event) { + if (groupCallViewModel != null) { + groupCallViewModel.onGroupCallPeekEvent(event); + } + } + + private void initializeReceivers() { + securityUpdateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + initializeSecurity(isSecureText, isDefaultSms); + calculateCharactersRemaining(); + } + }; + + registerReceiver(securityUpdateReceiver, + new IntentFilter(SecurityEvent.SECURITY_UPDATE_EVENT), + KeyCachingService.KEY_PERMISSION, null); + } + + //////// Helper Methods + + private ListenableFuture setMedia(@Nullable Uri uri, @NonNull MediaType mediaType) { + return setMedia(uri, mediaType, 0, 0, false); + } + + private ListenableFuture setMedia(@Nullable Uri uri, @NonNull MediaType mediaType, int width, int height, boolean borderless) { + if (uri == null) { + return new SettableFuture<>(false); + } + + if (SlideFactory.MediaType.VCARD.equals(mediaType) && isSecureText) { + openContactShareEditor(uri); + return new SettableFuture<>(false); + } else if (SlideFactory.MediaType.IMAGE.equals(mediaType) || SlideFactory.MediaType.GIF.equals(mediaType) || SlideFactory.MediaType.VIDEO.equals(mediaType)) { + String mimeType = MediaUtil.getMimeType(this, uri); + if (mimeType == null) { + mimeType = mediaType.toFallbackMimeType(); + } + + Media media = new Media(uri, mimeType, 0, width, height, 0, 0, borderless, Optional.absent(), Optional.absent(), Optional.absent()); + startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); + return new SettableFuture<>(false); + } else { + return attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height); + } + } + + private void openContactShareEditor(Uri contactUri) { + Intent intent = ContactShareEditActivity.getIntent(this, Collections.singletonList(contactUri)); + startActivityForResult(intent, GET_CONTACT_DETAILS); + } + + private void addAttachmentContactInfo(Uri contactUri) { + ContactAccessor contactDataList = ContactAccessor.getInstance(); + ContactData contactData = contactDataList.getContactData(this, contactUri); + + if (contactData.numbers.size() == 1) composeText.append(contactData.numbers.get(0).number); + else if (contactData.numbers.size() > 1) selectContactInfo(contactData); + } + + private void sendSharedContact(List contacts) { + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + long expiresIn = recipient.get().getExpireMessages() * 1000L; + boolean initiating = threadId == -1; + + sendMediaMessage(recipient.getId(), isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false); + } + + private void selectContactInfo(ContactData contactData) { + final CharSequence[] numbers = new CharSequence[contactData.numbers.size()]; + final CharSequence[] numberItems = new CharSequence[contactData.numbers.size()]; + + for (int i = 0; i < contactData.numbers.size(); i++) { + numbers[i] = contactData.numbers.get(i).number; + numberItems[i] = contactData.numbers.get(i).type + ": " + contactData.numbers.get(i).number; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setIcon(R.drawable.ic_account_box); + builder.setTitle(R.string.ConversationActivity_select_contact_info); + + builder.setItems(numberItems, (dialog, which) -> composeText.append(numbers[which])); + builder.show(); + } + + private Drafts getDraftsForCurrentState() { + Drafts drafts = new Drafts(); + + if (recipient.get().isGroup() && !recipient.get().isActiveGroup()) { + return drafts; + } + + if (!Util.isEmpty(composeText)) { + drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString())); + List draftMentions = composeText.getMentions(); + if (!draftMentions.isEmpty()) { + drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray()))); + } + } + + for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) { + if (slide.hasAudio() && slide.getUri() != null) drafts.add(new Draft(Draft.AUDIO, slide.getUri().toString())); + else if (slide.hasVideo() && slide.getUri() != null) drafts.add(new Draft(Draft.VIDEO, slide.getUri().toString())); + else if (slide.hasLocation()) drafts.add(new Draft(Draft.LOCATION, ((LocationSlide)slide).getPlace().serialize())); + else if (slide.hasImage() && slide.getUri() != null) drafts.add(new Draft(Draft.IMAGE, slide.getUri().toString())); + } + + Optional quote = inputPanel.getQuote(); + + if (quote.isPresent()) { + drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize())); + } + + return drafts; + } + + protected ListenableFuture saveDraft() { + final SettableFuture future = new SettableFuture<>(); + + if (this.recipient == null) { + future.set(threadId); + return future; + } + + final Drafts drafts = getDraftsForCurrentState(); + final long thisThreadId = this.threadId; + final int thisDistributionType = this.distributionType; + + new AsyncTask() { + @Override + protected Long doInBackground(Long... params) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(ConversationActivity.this); + DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this); + long threadId = params[0]; + + if (drafts.size() > 0) { + if (threadId == -1) threadId = threadDatabase.getThreadIdFor(getRecipient(), thisDistributionType); + + draftDatabase.insertDrafts(threadId, drafts); + threadDatabase.updateSnippet(threadId, drafts.getSnippet(ConversationActivity.this), + drafts.getUriSnippet(), + System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true); + } else if (threadId > 0) { + threadDatabase.update(threadId, false); + } + + return threadId; + } + + @Override + protected void onPostExecute(Long result) { + future.set(result); + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, thisThreadId); + + return future; + } + + private void setBlockedUserState(Recipient recipient, boolean isSecureText, boolean isDefaultSms) { + if (!isSecureText && isPushGroupConversation()) { + unblockButton.setVisibility(View.GONE); + inputPanel.setVisibility(View.GONE); + makeDefaultSmsButton.setVisibility(View.GONE); + registerButton.setVisibility(View.VISIBLE); + } else if (!isSecureText && !isDefaultSms) { + unblockButton.setVisibility(View.GONE); + inputPanel.setVisibility(View.GONE); + makeDefaultSmsButton.setVisibility(View.VISIBLE); + registerButton.setVisibility(View.GONE); + } else { + boolean inactivePushGroup = isPushGroupConversation() && !recipient.isActiveGroup(); + inputPanel.setVisibility(inactivePushGroup ? View.GONE : View.VISIBLE); + unblockButton.setVisibility(View.GONE); + makeDefaultSmsButton.setVisibility(View.GONE); + registerButton.setVisibility(View.GONE); + } + } + + private void calculateCharactersRemaining() { + String messageBody = composeText.getTextTrimmed().toString(); + TransportOption transportOption = sendButton.getSelectedTransport(); + CharacterState characterState = transportOption.calculateCharacters(messageBody); + + if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { + charactersLeft.setText(String.format(dynamicLanguage.getCurrentLocale(), + "%d/%d (%d)", + characterState.charactersRemaining, + characterState.maxTotalMessageSize, + characterState.messagesSpent)); + charactersLeft.setVisibility(View.VISIBLE); + } else { + charactersLeft.setVisibility(View.GONE); + } + } + + private void initializeMediaKeyboardProviders(@NonNull MediaKeyboard mediaKeyboard, boolean stickersAvailable) { + boolean isSystemEmojiPreferred = TextSecurePreferences.isSystemEmojiPreferred(this); + + if (stickersAvailable) { + if (isSystemEmojiPreferred) { + mediaKeyboard.setProviders(0, new StickerKeyboardProvider(this, this)); + } else { + MediaKeyboardMode keyboardMode = TextSecurePreferences.getMediaKeyboardMode(this); + int index = keyboardMode == MediaKeyboardMode.STICKER ? 1 : 0; + + mediaKeyboard.setProviders(index, + new EmojiKeyboardProvider(this, inputPanel), + new StickerKeyboardProvider(this, this)); + } + } else if (!isSystemEmojiPreferred) { + mediaKeyboard.setProviders(0, new EmojiKeyboardProvider(this, inputPanel)); + } + } + + private boolean isInMessageRequest() { + return messageRequestBottomView.getVisibility() == View.VISIBLE; + } + + private boolean isSingleConversation() { + return getRecipient() != null && !getRecipient().isGroup(); + } + + private boolean isActiveGroup() { + if (!isGroupConversation()) return false; + + Optional record = DatabaseFactory.getGroupDatabase(this).getGroup(getRecipient().getId()); + return record.isPresent() && record.get().isActive(); + } + + @SuppressWarnings("SimplifiableIfStatement") + private boolean isSelfConversation() { + if (!TextSecurePreferences.isPushRegistered(this)) return false; + if (recipient.get().isGroup()) return false; + + return recipient.get().isSelf(); + } + + private boolean isGroupConversation() { + return getRecipient() != null && getRecipient().isGroup(); + } + + private boolean isPushGroupConversation() { + return getRecipient() != null && getRecipient().isPushGroup(); + } + + private boolean isPushGroupV1Conversation() { + return getRecipient() != null && getRecipient().isPushV1Group(); + } + + private boolean isSmsForced() { + return sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms(); + } + + protected Recipient getRecipient() { + return this.recipient.get(); + } + + protected long getThreadId() { + return this.threadId; + } + + private String getMessage() throws InvalidMessageException { + String rawText = composeText.getTextTrimmed().toString(); + + if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent()) + throw new InvalidMessageException(getString(R.string.ConversationActivity_message_is_empty_exclamation)); + + return rawText; + } + + private MediaConstraints getCurrentMediaConstraints() { + return sendButton.getSelectedTransport().getType() == Type.TEXTSECURE + ? MediaConstraints.getPushMediaConstraints() + : MediaConstraints.getMmsMediaConstraints(sendButton.getSelectedTransport().getSimSubscriptionId().or(-1)); + } + + private void markLastSeen() { + new AsyncTask() { + @Override + protected Void doInBackground(Long... params) { + DatabaseFactory.getThreadDatabase(ConversationActivity.this).setLastSeen(params[0]); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId); + } + + protected void sendComplete(long threadId) { + boolean refreshFragment = (threadId != this.threadId); + this.threadId = threadId; + + if (fragment == null || !fragment.isVisible() || isFinishing()) { + return; + } + + fragment.setLastSeen(0); + + if (refreshFragment) { + fragment.reload(recipient.get(), threadId); + setVisibleThread(threadId); + } + + fragment.scrollToBottom(); + attachmentManager.cleanup(); + + updateLinkPreviewState(); + linkPreviewViewModel.onSend(); + } + + private void sendMessage() { + if (inputPanel.isRecordingInLockedMode()) { + inputPanel.releaseRecordingLock(); + return; + } + //Moti Amae send message clicked + try { + Recipient recipient = getRecipient(); + + if (recipient == null) { + throw new RecipientFormattingException("Badly formatted"); + } + + String message = getMessage(); + TransportOption transport = sendButton.getSelectedTransport(); + boolean forceSms = (recipient.isForceSmsSelection() || sendButton.isManualSelection()) && transport.isSms(); + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + boolean initiating = threadId == -1; + boolean needsSplit = !transport.isSms() && message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize; + boolean isMediaMessage = attachmentManager.isAttachmentPresent() || + recipient.isGroup() || + recipient.getEmail().isPresent() || + inputPanel.getQuote().isPresent() || + composeText.hasMentions() || + linkPreviewViewModel.hasLinkPreview() || + needsSplit; + + Log.i(TAG, "isManual Selection: " + sendButton.isManualSelection()); + Log.i(TAG, "forceSms: " + forceSms); + + if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { + handleManualMmsRequired(); + } else if (!forceSms && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) { + handleRecentSafetyNumberChange(); + } else if (isMediaMessage) { + sendMediaMessage(forceSms, expiresIn, false, subscriptionId, initiating); + } else { + sendTextMessage(forceSms, expiresIn, subscriptionId, initiating); + } + } catch (RecipientFormattingException ex) { + Toast.makeText(ConversationActivity.this, + R.string.ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation, + Toast.LENGTH_LONG).show(); + Log.w(TAG, ex); + } catch (InvalidMessageException ex) { + Toast.makeText(ConversationActivity.this, R.string.ConversationActivity_message_is_empty_exclamation, + Toast.LENGTH_SHORT).show(); + Log.w(TAG, ex); + } + } + + private void sendMediaMessage(@NonNull MediaSendActivityResult result) { + long thread = this.threadId; + long expiresIn = recipient.get().getExpireMessages() * 1000L; + QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); + List mentions = new ArrayList<>(result.getMentions()); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList(), mentions); + OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message); + + ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); + + inputPanel.clearQuote(); + attachmentManager.clear(glideRequests, false); + silentlySetComposeText(""); + + long id = fragment.stageOutgoingMessage(message); + archiveMediaMessage(result, message, id); + // secureMessage.getAttachments().get(0).getUri(); + SimpleTask.run(() -> { + long resultId = MessageSender.sendPushWithPreUploadedMedia(this, secureMessage, result.getPreUploadResults(), thread, () -> fragment.releaseOutgoingMessage(id)); + int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments(); + Log.i(TAG, "Deleted " + deleted + " abandoned attachments."); + + return resultId; + }, this::sendComplete); + } + + private void archiveMediaMessage(MediaSendActivityResult result, OutgoingMediaMessage message, long id) { +// long length = BlobProvider.getInstance().calculateFileSize(context, writeDetails.uri); + //TODO BlobProvider need to take the file. + if(checkWriteExternalPermission()) { + + File tempFileForArchiving = new File(/*Environment.getExternalStorageDirectory(), */URI.create(FileUtilTestMoti.getUriRealPath(this,Uri.parse(result.getUriList().get(0)))).getPath()); + ArchiveSender.Companion.archiveMessageOutboxMMS(this, ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_SEND, message.getRecipient(), message, id, tempFileForArchiving); + ArchiveSender.Companion.updateArchiveSDKToSendMMSMessage(this, tempFileForArchiving.getName(), false); + + }else { + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 132); + } + + } + + private boolean checkWriteExternalPermission() + { + String permission = Manifest.permission.READ_EXTERNAL_STORAGE; + int res = this.checkCallingOrSelfPermission(permission); + return (res == PackageManager.PERMISSION_GRANTED); + } + + public void FileToByteArray(File file ){ + + + FileInputStream fis = null; + try { + fis = new FileInputStream(file); + + byte buffer[] = new byte[4096]; + int read = 0; + + while((read = fis.read(buffer)) != -1) { + // Do what you want with the buffer of bytes here. + // Make sure you only work with bytes 0 - read. + // Sending it with your protocol for example. + } + } catch (FileNotFoundException e) { + System.out.println("File not found: " + e.toString()); + } catch (IOException e) { + System.out.println("Exception reading file: " + e.toString()); + } finally { + try { + if (fis != null) { + fis.close(); + } + } catch (IOException ignored) { + } + } + + } + + private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, final boolean initiating) + throws InvalidMessageException + { + Log.i(TAG, "Sending media message..."); + sendMediaMessage(recipient.getId(), + forceSms, + getMessage(), + attachmentManager.buildSlideDeck(), + inputPanel.getQuote().orNull(), + Collections.emptyList(), + linkPreviewViewModel.getActiveLinkPreviews(), + composeText.getMentions(), + expiresIn, + viewOnce, + subscriptionId, + initiating, + true); + } + + private ListenableFuture sendMediaMessage(@NonNull RecipientId recipientId, + final boolean forceSms, + @NonNull String body, + SlideDeck slideDeck, + QuoteModel quote, + List contacts, + List previews, + List mentions, + final long expiresIn, + final boolean viewOnce, + final int subscriptionId, + final boolean initiating, + final boolean clearComposeBox) + { + if (!isDefaultSms && (!isSecureText || forceSms)) { + showDefaultSmsPrompt(); + return new SettableFuture<>(null); + } + + final long thread = this.threadId; + + if (isSecureText && !forceSms) { + MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(this, body, sendButton.getSelectedTransport().calculateCharacters(body).maxPrimaryMessageSize); + body = splitMessage.getBody(); + + if (splitMessage.getTextSlide().isPresent()) { + slideDeck.addSlide(splitMessage.getTextSlide().get()); + } + } + + OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions); + + final SettableFuture future = new SettableFuture<>(); + final Context context = getApplicationContext(); + + final OutgoingMediaMessage outgoingMessage; + + if (isSecureText && !forceSms) { + outgoingMessage = new OutgoingSecureMediaMessage(outgoingMessageCandidate); + ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); + } else { + outgoingMessage = outgoingMessageCandidate; + } + + Permissions.with(this) + .request(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) + .ifNecessary(!isSecureText || forceSms) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_sms_permission_in_order_to_send_an_sms)) + .onAllGranted(() -> { + if (clearComposeBox) { + inputPanel.clearQuote(); + attachmentManager.clear(glideRequests, false); + silentlySetComposeText(""); + } + + final long id = fragment.stageOutgoingMessage(outgoingMessage); + + SimpleTask.run(() -> { + return MessageSender.send(context, outgoingMessage, thread, forceSms, () -> fragment.releaseOutgoingMessage(id)); + }, result -> { + sendComplete(result); + future.set(null); + }); + }) + .onAnyDenied(() -> future.set(null)) + .execute(); + + return future; + } + + private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiating) + throws InvalidMessageException + { + if (!isDefaultSms && (!isSecureText || forceSms)) { + showDefaultSmsPrompt(); + return; + } + + final long thread = this.threadId; + final Context context = getApplicationContext(); + final String messageBody = getMessage(); + + OutgoingTextMessage message; + + if (isSecureText && !forceSms) { + message = new OutgoingEncryptedMessage(recipient.get(), messageBody, expiresIn); + ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); + } else { + message = new OutgoingTextMessage(recipient.get(), messageBody, expiresIn, subscriptionId); + } + + Permissions.with(this) + .request(Manifest.permission.SEND_SMS) + .ifNecessary(forceSms || !isSecureText) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_sms_permission_in_order_to_send_an_sms)) + .onAllGranted(() -> { + silentlySetComposeText(""); + final long id = fragment.stageOutgoingMessage(message); + + new AsyncTask() { + @Override + protected Long doInBackground(OutgoingTextMessage... messages) { + return MessageSender.send(context, messages[0], thread, forceSms, () -> fragment.releaseOutgoingMessage(id)); + } + + @Override + protected void onPostExecute(Long result) { + sendComplete(result); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, message); + + }) + .execute(); + } + + private void showDefaultSmsPrompt() { + new AlertDialog.Builder(this) + .setMessage(R.string.ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app) + .setNegativeButton(R.string.ConversationActivity_no, (dialog, which) -> dialog.dismiss()) + .setPositiveButton(R.string.ConversationActivity_yes, (dialog, which) -> handleMakeDefaultSms()) + .show(); + } + + private void updateToggleButtonState() { + if (inputPanel.isRecordingInLockedMode()) { + buttonToggle.display(sendButton); + quickAttachmentToggle.show(); + inlineAttachmentToggle.hide(); + return; + } + + if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) { + buttonToggle.display(attachButton); + quickAttachmentToggle.show(); + inlineAttachmentToggle.hide(); + } else { + buttonToggle.display(sendButton); + quickAttachmentToggle.hide(); + + if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreviewUi()) { + inlineAttachmentToggle.show(); + } else { + inlineAttachmentToggle.hide(); + } + } + } + + private void updateLinkPreviewState() { + if (SignalStore.settings().isLinkPreviewsEnabled() && isSecureText && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) { + linkPreviewViewModel.onEnabled(); + linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), composeText.getSelectionStart(), composeText.getSelectionEnd()); + } else { + linkPreviewViewModel.onUserCancel(); + } + } + + private void recordTransportPreference(TransportOption transportOption) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(ConversationActivity.this); + + recipientDatabase.setDefaultSubscriptionId(recipient.getId(), transportOption.getSimSubscriptionId().or(-1)); + + if (!recipient.resolve().isPushGroup()) { + recipientDatabase.setForceSmsSelection(recipient.getId(), recipient.get().getRegistered() == RegisteredState.REGISTERED && transportOption.isSms()); + } + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @Override + public void onRecorderPermissionRequired() { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) + .execute(); + } + + @Override + public void onRecorderStarted() { + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(20); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED); + + audioRecorder.startRecording(); + } + + @Override + public void onRecorderLocked() { + updateToggleButtonState(); + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + + @Override + public void onRecorderFinished() { + updateToggleButtonState(); + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(20); + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + + ListenableFuture> future = audioRecorder.stopRecording(); + future.addListener(new ListenableFuture.Listener>() { + @Override + public void onSuccess(final @NonNull Pair result) { + boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms(); + boolean initiating = threadId == -1; + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + long expiresIn = recipient.get().getExpireMessages() * 1000L; + AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true); + SlideDeck slideDeck = new SlideDeck(); + slideDeck.addSlide(audioSlide); + + ListenableFuture sendResult = sendMediaMessage(recipient.getId(), + forceSms, + "", + slideDeck, + inputPanel.getQuote().orNull(), + Collections.emptyList(), + Collections.emptyList(), + composeText.getMentions(), + expiresIn, + false, + subscriptionId, + initiating, + true); + + sendResult.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Void nothing) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + BlobProvider.getInstance().delete(ConversationActivity.this, result.first()); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + } + + @Override + public void onFailure(ExecutionException e) { + Toast.makeText(ConversationActivity.this, R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public void onRecorderCanceled() { + updateToggleButtonState(); + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(50); + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + + ListenableFuture> future = audioRecorder.stopRecording(); + future.addListener(new ListenableFuture.Listener>() { + @Override + public void onSuccess(final Pair result) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + BlobProvider.getInstance().delete(ConversationActivity.this, result.first()); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @Override + public void onFailure(ExecutionException e) {} + }); + } + + @Override + public void onEmojiToggle() { + if (!emojiDrawerStub.resolved()) { + Boolean stickersAvailable = stickerViewModel.getStickersAvailability().getValue(); + + initializeMediaKeyboardProviders(emojiDrawerStub.get(), stickersAvailable == null ? false : stickersAvailable); + + inputPanel.setMediaKeyboard(emojiDrawerStub.get()); + } + + if (container.getCurrentInput() == emojiDrawerStub.get()) { + container.showSoftkey(composeText); + } else { + container.show(composeText, emojiDrawerStub.get()); + } + } + + @Override + public void onLinkPreviewCanceled() { + linkPreviewViewModel.onUserCancel(); + } + + @Override + public void onStickerSuggestionSelected(@NonNull StickerRecord sticker) { + sendSticker(sticker, true); + } + + @Override + public void onMediaSelected(@NonNull Uri uri, String contentType) { + if (MediaUtil.isGif(contentType) || MediaUtil.isImageType(contentType)) { + SimpleTask.run(getLifecycle(), + () -> getKeyboardImageDetails(uri), + details -> sendKeyboardImage(uri, contentType, details)); + } else if (MediaUtil.isVideoType(contentType)) { + setMedia(uri, SlideFactory.MediaType.VIDEO); + } else if (MediaUtil.isAudioType(contentType)) { + setMedia(uri, SlideFactory.MediaType.AUDIO); + } + } + + @Override + public void onCursorPositionChanged(int start, int end) { + linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), start, end); + } + + @Override + public void onStickerSelected(@NonNull StickerRecord stickerRecord) { + sendSticker(stickerRecord, false); + } + + @Override + public void onStickerManagementClicked() { + startActivity(StickerManagementActivity.getIntent(this)); + container.hideAttachedInput(true); + } + + private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) { + sendSticker(new StickerLocator(stickerRecord.getPackId(), stickerRecord.getPackKey(), stickerRecord.getStickerId(), stickerRecord.getEmoji()), stickerRecord.getContentType(), stickerRecord.getUri(), stickerRecord.getSize(), clearCompose); + + SignalExecutors.BOUNDED.execute(() -> + DatabaseFactory.getStickerDatabase(getApplicationContext()) + .updateStickerLastUsedTime(stickerRecord.getRowId(), System.currentTimeMillis()) + ); + } + + private void sendSticker(@NonNull StickerLocator stickerLocator, @NonNull String contentType, @NonNull Uri uri, long size, boolean clearCompose) { + if (sendButton.getSelectedTransport().isSms()) { + Media media = new Media(uri, contentType, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, false, Optional.absent(), Optional.absent(), Optional.absent()); + Intent intent = MediaSendActivity.buildEditorIntent(this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()); + startActivityForResult(intent, MEDIA_SENDER); + return; + } + + long expiresIn = recipient.get().getExpireMessages() * 1000L; + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + boolean initiating = threadId == -1; + TransportOption transport = sendButton.getSelectedTransport(); + SlideDeck slideDeck = new SlideDeck(); + Slide stickerSlide = new StickerSlide(this, uri, size, stickerLocator, contentType); + + slideDeck.addSlide(stickerSlide); + + sendMediaMessage(recipient.getId(), transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose); + } + + private void silentlySetComposeText(String text) { + typingTextWatcher.setEnabled(false); + composeText.setText(text); + typingTextWatcher.setEnabled(true); + } + + @Override + public void onReactionsDialogDismissed() { + reactionDelegate.hideMask(); + } + + // Listeners + + private class QuickCameraToggleListener implements OnClickListener { + @Override + public void onClick(View v) { + Permissions.with(ConversationActivity.this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) + .onAllGranted(() -> { + composeText.clearFocus(); + startActivityForResult(MediaSendActivity.buildCameraIntent(ConversationActivity.this, recipient.get(), sendButton.getSelectedTransport()), MEDIA_SENDER); + overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary); + }) + .onAnyDenied(() -> Toast.makeText(ConversationActivity.this, R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show()) + .execute(); + } + } + + private class SendButtonListener implements OnClickListener, TextView.OnEditorActionListener { + @Override + public void onClick(View v) { + sendMessage(); + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + sendButton.performClick(); + return true; + } + return false; + } + } + + private class AttachButtonListener implements OnClickListener { + @Override + public void onClick(View v) { + handleAddAttachment(); + } + } + + private class AttachButtonLongClickListener implements View.OnLongClickListener { + @Override + public boolean onLongClick(View v) { + return sendButton.performLongClick(); + } + } + + private class ComposeKeyPressedListener implements OnKeyListener, OnClickListener, TextWatcher, OnFocusChangeListener { + + int beforeLength; + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (TextSecurePreferences.isEnterSendsEnabled(ConversationActivity.this)) { + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); + return true; + } + } + } + return false; + } + + @Override + public void onClick(View v) { + container.showSoftkey(composeText); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count,int after) { + beforeLength = composeText.getTextTrimmed().length(); + } + + @Override + public void afterTextChanged(Editable s) { + calculateCharactersRemaining(); + + if (composeText.getTextTrimmed().length() == 0 || beforeLength == 0) { + composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50); + } + + stickerViewModel.onInputTextUpdated(s.toString()); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before,int count) {} + + @Override + public void onFocusChange(View v, boolean hasFocus) {} + } + + private class TypingStatusTextWatcher extends SimpleTextWatcher { + + private boolean enabled = true; + + private String previousText = ""; + + @Override + public void onTextChanged(String text) { + if (enabled && threadId > 0 && isSecureText && !isSmsForced() && !recipient.get().isBlocked() && !recipient.get().isSelf()) { + TypingStatusSender typingStatusSender = ApplicationDependencies.getTypingStatusSender(); + + if (text.length() == 0) { + typingStatusSender.onTypingStoppedWithNotify(threadId); + } else if (text.length() < previousText.length() && previousText.contains(text)) { + typingStatusSender.onTypingStopped(threadId); + } else { + typingStatusSender.onTypingStarted(threadId); + } + + previousText = text; + } + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + + @Override + public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) { + messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept()); + messageRequestBottomView.setDeleteOnClickListener(v -> onMessageRequestDeleteClicked(viewModel)); + messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel)); + messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel)); + messageRequestBottomView.setGroupV1MigrationContinueListener(v -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(getSupportFragmentManager(), recipient.getId())); + + viewModel.getRequestReviewDisplayState().observe(this, this::presentRequestReviewBanner); + viewModel.getMessageData().observe(this, this::presentMessageRequestState); + viewModel.getFailures().observe(this, this::showGroupChangeErrorToast); + viewModel.getMessageRequestStatus().observe(this, status -> { + switch (status) { + case IDLE: + hideMessageRequestBusy(); + break; + case ACCEPTING: + case BLOCKING: + case DELETING: + showMessageRequestBusy(); + break; + case ACCEPTED: + hideMessageRequestBusy(); + break; + case DELETED: + case BLOCKED: + hideMessageRequestBusy(); + finish(); + } + }); + } + + private void presentRequestReviewBanner(@NonNull MessageRequestViewModel.RequestReviewDisplayState state) { + switch (state) { + case SHOWN: + reviewBanner.get().setVisibility(View.VISIBLE); + + CharSequence message = new SpannableStringBuilder().append(SpanUtil.bold(getString(R.string.ConversationFragment__review_requests_carefully))) + .append(" ") + .append(getString(R.string.ConversationFragment__signal_found_another_contact_with_the_same_name)); + + reviewBanner.get().setBannerMessage(message); + + Drawable drawable = ContextUtil.requireDrawable(this, R.drawable.ic_info_white_24).mutate(); + DrawableCompat.setTint(drawable, ContextCompat.getColor(this, R.color.signal_icon_tint_primary)); + + reviewBanner.get().setBannerIcon(drawable); + reviewBanner.get().setOnClickListener(unused -> handleReviewRequest(recipient.getId())); + break; + case HIDDEN: + reviewBanner.get().setVisibility(View.GONE); + break; + default: + break; + } + } + + private void presentGroupReviewBanner(@NonNull ConversationGroupViewModel.ReviewState groupReviewState) { + if (groupReviewState.getCount() > 0) { + reviewBanner.get().setVisibility(View.VISIBLE); + reviewBanner.get().setBannerMessage(getString(R.string.ConversationFragment__d_group_members_have_the_same_name, groupReviewState.getCount())); + reviewBanner.get().setBannerRecipient(groupReviewState.getRecipient()); + reviewBanner.get().setOnClickListener(unused -> handleReviewGroupMembers(groupReviewState.getGroupId())); + } else if (reviewBanner.resolved()) { + reviewBanner.get().setVisibility(View.GONE); + } + } + + private void showMessageRequestBusy() { + messageRequestBottomView.showBusy(); + } + + private void hideMessageRequestBusy() { + messageRequestBottomView.hideBusy(); + } + + private void handleReviewGroupMembers(@Nullable GroupId.V2 groupId) { + if (groupId == null) { + return; + } + + ReviewCardDialogFragment.createForReviewMembers(groupId) + .show(getSupportFragmentManager(), null); + } + + private void handleReviewRequest(@NonNull RecipientId recipientId) { + if (recipientId == Recipient.UNKNOWN.getId()) { + return; + } + + ReviewCardDialogFragment.createForReviewRequest(recipientId) + .show(getSupportFragmentManager(), null); + } + + private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) { + Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show(); + } + + @Override + public void handleReaction(@NonNull View maskTarget, + @NonNull MessageRecord messageRecord, + @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnHideListener onHideListener) + { + reactionDelegate.setOnToolbarItemClickedListener(toolbarListener); + reactionDelegate.setOnHideListener(onHideListener); + reactionDelegate.show(this, maskTarget, recipient.get(), messageRecord, inputAreaHeight()); + } + + @Override + public void onListVerticalTranslationChanged(float translationY) { + reactionDelegate.setListVerticalTranslation(translationY); + } + + @Override + public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) { + if (messageRecord.hasFailedWithNetworkFailures()) { + new AlertDialog.Builder(this) + .setMessage(R.string.conversation_activity__message_could_not_be_sent) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.conversation_activity__send, (dialog, which) -> MessageSender.resend(this, messageRecord)) + .show(); + } else if (messageRecord.isIdentityMismatchFailure()) { + SafetyNumberChangeDialog.show(this, messageRecord); + } else { + startActivity(MessageDetailsActivity.getIntentForMessageDetails(this, messageRecord, messageRecord.getRecipient().getId(), messageRecord.getThreadId())); + } + } + + @Override + public void handleReactionDetails(@NonNull View maskTarget) { + reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight()); + } + + @Override + public void onCursorChanged() { + if (!reactionDelegate.isShowing()) { + return; + } + + SimpleTask.run(() -> { + //noinspection CodeBlock2Expr + return DatabaseFactory.getMmsSmsDatabase(this) + .checkMessageExists(reactionDelegate.getMessageRecord()); + }, messageExists -> { + if (!messageExists) { + reactionDelegate.hide(); + } + }); + } + + @Override + public void setThreadId(long threadId) { + this.threadId = threadId; + } + + @Override + public void handleReplyMessage(ConversationMessage conversationMessage) { + MessageRecord messageRecord = conversationMessage.getMessageRecord(); + + Recipient author; + + if (messageRecord.isOutgoing()) { + author = Recipient.self(); + } else { + author = messageRecord.getIndividualRecipient(); + } + + if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) { + Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0); + String displayName = ContactUtil.getDisplayName(contact); + String body = getString(R.string.ConversationActivity_quoted_contact_message, EmojiStrings.BUST_IN_SILHOUETTE, displayName); + SlideDeck slideDeck = new SlideDeck(); + + if (contact.getAvatarAttachment() != null) { + slideDeck.addSlide(MediaUtil.getSlideForAttachment(this, contact.getAvatarAttachment())); + } + + inputPanel.setQuote(GlideApp.with(this), + messageRecord.getDateSent(), + author, + body, + slideDeck); + + } else if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) { + LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0); + SlideDeck slideDeck = new SlideDeck(); + + if (linkPreview.getThumbnail().isPresent()) { + slideDeck.addSlide(MediaUtil.getSlideForAttachment(this, linkPreview.getThumbnail().get())); + } + + inputPanel.setQuote(GlideApp.with(this), + messageRecord.getDateSent(), + author, + conversationMessage.getDisplayBody(this), + slideDeck); + } else { + SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck(); + + if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).isViewOnce()) { + Attachment attachment = new TombstoneAttachment(MediaUtil.VIEW_ONCE, true); + slideDeck = new SlideDeck(); + slideDeck.addSlide(MediaUtil.getSlideForAttachment(this, attachment)); + } + + inputPanel.setQuote(GlideApp.with(this), + messageRecord.getDateSent(), + author, + conversationMessage.getDisplayBody(this), + slideDeck); + } + + inputPanel.clickOnComposeInput(); + } + + @Override + public void onMessageActionToolbarOpened() { + searchViewItem.collapseActionView(); + } + + @Override + public void onForwardClicked() { + inputPanel.clearQuote(); + } + + @Override + public void onAttachmentChanged() { + handleSecurityChange(isSecureText, isDefaultSms); + updateToggleButtonState(); + updateLinkPreviewState(); + } + + private int inputAreaHeight() { + int height = panelParent.getMeasuredHeight(); + + if (attachmentKeyboardStub.resolved()) { + View keyboard = attachmentKeyboardStub.get(); + if (keyboard.getVisibility() == View.VISIBLE) { + return height + keyboard.getMeasuredHeight(); + } + } + + return height; + } + + private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestDeleteClicked] No recipient!"); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss()); + + if (recipient.isGroup() && recipient.isBlocked()) { + builder.setTitle(R.string.ConversationActivity_delete_conversation); + builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices); + builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete()); + } else if (recipient.isGroup()) { + builder.setTitle(R.string.ConversationActivity_delete_and_leave_group); + builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices); + builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete()); + } else { + builder.setTitle(R.string.ConversationActivity_delete_conversation); + builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices); + builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete()); + } + + builder.show(); + } + + private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestBlockClicked] No recipient!"); + return; + } + + BlockUnblockDialog.showBlockAndDeleteFor(this, getLifecycle(), recipient, requestModel::onBlock, requestModel::onBlockAndDelete); + } + + private void onMessageRequestUnblockClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestUnblockClicked] No recipient!"); + return; + } + + BlockUnblockDialog.showUnblockFor(this, getLifecycle(), recipient, requestModel::onUnblock); + } + + private static void hideMenuItem(@NonNull Menu menu, @IdRes int menuItem) { + if (menu.findItem(menuItem) != null) { + menu.findItem(menuItem).setVisible(false); + } + } + + @WorkerThread + private @Nullable KeyboardImageDetails getKeyboardImageDetails(@NonNull Uri uri) { + try { + Bitmap bitmap = glideRequests.asBitmap() + .load(new DecryptableStreamUriLoader.DecryptableUri(uri)) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit() + .get(1000, TimeUnit.MILLISECONDS); + int topLeft = bitmap.getPixel(0, 0); + return new KeyboardImageDetails(bitmap.getWidth(), bitmap.getHeight(), Color.alpha(topLeft) < 255); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + return null; + } + } + + private void sendKeyboardImage(@NonNull Uri uri, @NonNull String contentType, @Nullable KeyboardImageDetails details) { + if (details == null || !details.hasTransparency) { + setMedia(uri, Objects.requireNonNull(SlideFactory.MediaType.from(contentType))); + return; + } + + long expiresIn = recipient.get().getExpireMessages() * 1000L; + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + boolean initiating = threadId == -1; + SlideDeck slideDeck = new SlideDeck(); + + if (MediaUtil.isGif(contentType)) { + slideDeck.addSlide(new GifSlide(this, uri, 0, details.width, details.height, details.hasTransparency, null)); + } else if (MediaUtil.isImageType(contentType)) { + slideDeck.addSlide(new ImageSlide(this, uri, contentType, 0, details.width, details.height, details.hasTransparency, null, null)); + } else { + throw new AssertionError("Only images are supported!"); + } + + sendMediaMessage(recipient.getId(), + isSmsForced(), + "", + slideDeck, + null, + Collections.emptyList(), + Collections.emptyList(), + composeText.getMentions(), + expiresIn, + false, + subscriptionId, + initiating, + false); + } + + private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener { + @Override + public void onDismissed(final List unverifiedIdentities) { + final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + for (IdentityRecord identityRecord : unverifiedIdentities) { + identityDatabase.setVerified(identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + VerifiedStatus.DEFAULT); + } + } + + return null; + } + + @Override + protected void onPostExecute(Void result) { + initializeIdentityRecords(); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private class UnverifiedClickedListener implements UnverifiedBannerView.ClickListener { + @Override + public void onClicked(final List unverifiedIdentities) { + Log.i(TAG, "onClicked: " + unverifiedIdentities.size()); + if (unverifiedIdentities.size() == 1) { + startActivity(VerifyIdentityActivity.newIntent(ConversationActivity.this, unverifiedIdentities.get(0), false)); + } else { + String[] unverifiedNames = new String[unverifiedIdentities.size()]; + + for (int i=0;i { + startActivity(VerifyIdentityActivity.newIntent(ConversationActivity.this, unverifiedIdentities.get(which), false)); + }); + builder.show(); + } + } + } + + private class QuoteRestorationTask extends AsyncTask { + + private final String serialized; + private final SettableFuture future; + + QuoteRestorationTask(@NonNull String serialized, @NonNull SettableFuture future) { + this.serialized = serialized; + this.future = future; + } + + @Override + protected ConversationMessage doInBackground(Void... voids) { + QuoteId quoteId = QuoteId.deserialize(ConversationActivity.this, serialized); + + if (quoteId == null) { + return null; + } + + Context context = getApplicationContext(); + + MessageRecord messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quoteId.getId(), quoteId.getAuthor()); + if (messageRecord == null) { + return null; + } + + return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord); + } + + @Override + protected void onPostExecute(ConversationMessage conversationMessage) { + if (conversationMessage != null) { + handleReplyMessage(conversationMessage); + future.set(true); + } else { + Log.e(TAG, "Failed to restore a quote from a draft. No matching message record."); + future.set(false); + } + } + } + + private void presentMessageRequestState(@Nullable MessageRequestViewModel.MessageData messageData) { + if (!Util.isEmpty(viewModel.getArgs().getDraftText()) || + viewModel.getArgs().getMedia() != null || + viewModel.getArgs().getStickerLocator() != null) + { + Log.d(TAG, "[presentMessageRequestState] Have extra, so ignoring provided state."); + messageRequestBottomView.setVisibility(View.GONE); + } else if (isPushGroupV1Conversation() && !isActiveGroup()) { + Log.d(TAG, "[presentMessageRequestState] Inactive push group V1, so ignoring provided state."); + messageRequestBottomView.setVisibility(View.GONE); + } else if (messageData == null) { + Log.d(TAG, "[presentMessageRequestState] Null messageData. Ignoring."); + } else if (messageData.getMessageState() == MessageRequestState.NONE) { + Log.d(TAG, "[presentMessageRequestState] No message request necessary."); + messageRequestBottomView.setVisibility(View.GONE); + } else { + Log.d(TAG, "[presentMessageRequestState] " + messageData.getMessageState()); + messageRequestBottomView.setMessageData(messageData); + messageRequestBottomView.setVisibility(View.VISIBLE); + } + + invalidateOptionsMenu(); + } + + private static class KeyboardImageDetails { + private final int width; + private final int height; + private final boolean hasTransparency; + + private KeyboardImageDetails(int width, int height, boolean hasTransparency) { + this.width = width; + this.height = height; + this.hasTransparency = hasTransparency; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java new file mode 100644 index 00000000..9b51a52e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -0,0 +1,688 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.AnyThread; +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.signal.paging.PagingController; +import org.thoughtcrime.securesms.BindableConversationItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Adapter that renders a conversation. + * + * Important spacial thing to keep in mind: The adapter is intended to be shown on a reversed layout + * manager, so position 0 is at the bottom of the screen. That's why the "header" is at the bottom, + * the "footer" is at the top, and we refer to the "next" record as having a lower index. + */ +public class ConversationAdapter + extends ListAdapter + implements StickyHeaderDecoration.StickyHeaderAdapter +{ + + private static final String TAG = Log.tag(ConversationAdapter.class); + + public static final int HEADER_TYPE_POPOVER_DATE = 1; + public static final int HEADER_TYPE_INLINE_DATE = 2; + public static final int HEADER_TYPE_LAST_SEEN = 3; + + private static final int MESSAGE_TYPE_OUTGOING_MULTIMEDIA = 0; + private static final int MESSAGE_TYPE_OUTGOING_TEXT = 1; + private static final int MESSAGE_TYPE_INCOMING_MULTIMEDIA = 2; + private static final int MESSAGE_TYPE_INCOMING_TEXT = 3; + private static final int MESSAGE_TYPE_UPDATE = 4; + private static final int MESSAGE_TYPE_HEADER = 5; + private static final int MESSAGE_TYPE_FOOTER = 6; + private static final int MESSAGE_TYPE_PLACEHOLDER = 7; + + private static final long HEADER_ID = Long.MIN_VALUE; + private static final long FOOTER_ID = Long.MIN_VALUE + 1; + + private final ItemClickListener clickListener; + private final LifecycleOwner lifecycleOwner; + private final GlideRequests glideRequests; + private final Locale locale; + private final Recipient recipient; + + private final Set selected; + private final List fastRecords; + private final Set releasedFastRecords; + private final Calendar calendar; + private final MessageDigest digest; + + private String searchQuery; + private ConversationMessage recordToPulse; + private View headerView; + private View footerView; + private PagingController pagingController; + private boolean hasWallpaper; + private boolean isMessageRequestAccepted; + + ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @Nullable ItemClickListener clickListener, + @NonNull Recipient recipient) + { + super(new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) { + return oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId(); + } + + @Override + public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) { + return false; + } + }); + + this.lifecycleOwner = lifecycleOwner; + + this.glideRequests = glideRequests; + this.locale = locale; + this.clickListener = clickListener; + this.recipient = recipient; + this.selected = new HashSet<>(); + this.fastRecords = new ArrayList<>(); + this.releasedFastRecords = new HashSet<>(); + this.calendar = Calendar.getInstance(); + this.digest = getMessageDigestOrThrow(); + this.hasWallpaper = recipient.hasWallpaper(); + this.isMessageRequestAccepted = true; + + setHasStableIds(true); + } + + @Override + public int getItemViewType(int position) { + if (hasHeader() && position == 0) { + return MESSAGE_TYPE_HEADER; + } + + if (hasFooter() && position == getItemCount() - 1) { + return MESSAGE_TYPE_FOOTER; + } + + ConversationMessage conversationMessage = getItem(position); + MessageRecord messageRecord = (conversationMessage != null) ? conversationMessage.getMessageRecord() : null; + + if (messageRecord == null) { + return MESSAGE_TYPE_PLACEHOLDER; + } else if (messageRecord.isUpdate()) { + return MESSAGE_TYPE_UPDATE; + } else if (messageRecord.isOutgoing()) { + return messageRecord.isMms() ? MESSAGE_TYPE_OUTGOING_MULTIMEDIA : MESSAGE_TYPE_OUTGOING_TEXT; + } else { + return messageRecord.isMms() ? MESSAGE_TYPE_INCOMING_MULTIMEDIA : MESSAGE_TYPE_INCOMING_TEXT; + } + } + + @Override + public long getItemId(int position) { + if (hasHeader() && position == 0) { + return HEADER_ID; + } + + if (hasFooter() && position == getItemCount() - 1) { + return FOOTER_ID; + } + + ConversationMessage message = getItem(position); + + if (message == null) { + return -1; + } + + return message.getUniqueId(digest); + } + + @Override + public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case MESSAGE_TYPE_INCOMING_TEXT: + case MESSAGE_TYPE_INCOMING_MULTIMEDIA: + case MESSAGE_TYPE_OUTGOING_TEXT: + case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: + case MESSAGE_TYPE_UPDATE: + View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false); + BindableConversationItem bindable = (BindableConversationItem) itemView; + + itemView.setOnClickListener(view -> { + if (clickListener != null) { + clickListener.onItemClick(bindable.getConversationMessage()); + } + }); + + itemView.setOnLongClickListener(view -> { + if (clickListener != null) { + clickListener.onItemLongClick(itemView, bindable.getConversationMessage()); + } + return true; + }); + + bindable.setEventListener(clickListener); + + return new ConversationViewHolder(itemView); + case MESSAGE_TYPE_PLACEHOLDER: + View v = new FrameLayout(parent.getContext()); + v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100))); + return new PlaceholderViewHolder(v); + case MESSAGE_TYPE_HEADER: + case MESSAGE_TYPE_FOOTER: + return new HeaderFooterViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false)); + default: + throw new IllegalStateException("Cannot create viewholder for type: " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + switch (getItemViewType(position)) { + case MESSAGE_TYPE_INCOMING_TEXT: + case MESSAGE_TYPE_INCOMING_MULTIMEDIA: + case MESSAGE_TYPE_OUTGOING_TEXT: + case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: + case MESSAGE_TYPE_UPDATE: + ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder; + ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position)); + int adapterPosition = holder.getAdapterPosition(); + + ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null; + ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null; + + conversationViewHolder.getBindable().bind(lifecycleOwner, + conversationMessage, + Optional.fromNullable(previousMessage != null ? previousMessage.getMessageRecord() : null), + Optional.fromNullable(nextMessage != null ? nextMessage.getMessageRecord() : null), + glideRequests, + locale, + selected, + recipient, + searchQuery, + conversationMessage == recordToPulse, + hasWallpaper, + isMessageRequestAccepted); + + if (conversationMessage == recordToPulse) { + recordToPulse = null; + } + break; + case MESSAGE_TYPE_HEADER: + ((HeaderFooterViewHolder) holder).bind(headerView); + break; + case MESSAGE_TYPE_FOOTER: + ((HeaderFooterViewHolder) holder).bind(footerView); + break; + } + } + + @Override + public int getItemCount() { + boolean hasHeader = headerView != null; + boolean hasFooter = footerView != null; + return super.getItemCount() + fastRecords.size() + (hasHeader ? 1 : 0) + (hasFooter ? 1 : 0); + } + + @Override + public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof ConversationViewHolder) { + ((ConversationViewHolder) holder).getBindable().unbind(); + } else if (holder instanceof HeaderFooterViewHolder) { + ((HeaderFooterViewHolder) holder).unbind(); + } + } + + @Override + public long getHeaderId(int position) { + if (isHeaderPosition(position)) return -1; + if (isFooterPosition(position)) return -1; + if (position >= getItemCount()) return -1; + if (position < 0) return -1; + + ConversationMessage conversationMessage = getItem(position); + + if (conversationMessage == null) return -1; + + calendar.setTime(new Date(conversationMessage.getMessageRecord().getDateSent())); + return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR)); + } + + @Override + public StickyHeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) { + return new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_header, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(StickyHeaderViewHolder viewHolder, int position, int type) { + Context context = viewHolder.itemView.getContext(); + ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position)); + + viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateReceived())); + + if (type == HEADER_TYPE_POPOVER_DATE) { + if (hasWallpaper) { + viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_8); + } else { + viewHolder.setBackgroundRes(R.drawable.sticky_date_header_background); + } + } else if (type == HEADER_TYPE_INLINE_DATE) { + if (hasWallpaper) { + viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_8); + } else { + viewHolder.clearBackground(); + } + } + + if (hasWallpaper && ThemeUtil.isDarkTheme(context)) { + viewHolder.setTextColor(ContextCompat.getColor(context, R.color.core_grey_15)); + } else { + viewHolder.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary)); + } + } + + public @Nullable ConversationMessage getItem(int position) { + position = hasHeader() ? position - 1 : position; + + if (position == -1) { + return null; + } else if (position < fastRecords.size()) { + return fastRecords.get(position); + } else { + int correctedPosition = position - fastRecords.size(); + if (pagingController != null) { + pagingController.onDataNeededAroundIndex(correctedPosition); + } + return super.getItem(correctedPosition); + } + } + + public void submitList(@Nullable List pagedList) { + cleanFastRecords(); + super.submitList(pagedList); + } + + public void setPagingController(@Nullable PagingController pagingController) { + this.pagingController = pagingController; + } + + void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) { + viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1))); + + if (hasWallpaper) { + viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_8); + viewHolder.setDividerColor(viewHolder.itemView.getResources().getColor(R.color.transparent_black_80)); + } else { + viewHolder.clearBackground(); + viewHolder.setDividerColor(viewHolder.itemView.getResources().getColor(R.color.core_grey_45)); + } + } + + boolean hasNoConversationMessages() { + return getItemCount() + fastRecords.size() == 0; + } + + /** + * The presence of a header may throw off the position you'd like to jump to. This will return + * an adjusted message position based on adapter state. + */ + @MainThread + int getAdapterPositionForMessagePosition(int messagePosition) { + return hasHeader() ? messagePosition + 1 : messagePosition; + } + + /** + * Finds the received timestamp for the item at the requested adapter position. Will return 0 if + * the position doesn't refer to an incoming message. + */ + @MainThread + long getReceivedTimestamp(int position) { + if (isHeaderPosition(position)) return 0; + if (isFooterPosition(position)) return 0; + if (position >= getItemCount()) return 0; + if (position < 0) return 0; + + ConversationMessage conversationMessage = getItem(position); + + if (conversationMessage == null || conversationMessage.getMessageRecord().isOutgoing()) { + return 0; + } else { + return conversationMessage.getMessageRecord().getDateReceived(); + } + } + + /** + * Sets the view the appears at the top of the list (because the list is reversed). + */ + void setFooterView(@Nullable View view) { + boolean hadFooter = hasFooter(); + + this.footerView = view; + + if (view == null && hadFooter) { + notifyItemRemoved(getItemCount()); + } else if (view != null && hadFooter) { + notifyItemChanged(getItemCount() - 1); + } else if (view != null) { + notifyItemInserted(getItemCount() - 1); + } + } + + /** + * Sets the view that appears at the bottom of the list (because the list is reversed). + */ + void setHeaderView(@Nullable View view) { + boolean hadHeader = hasHeader(); + + this.headerView = view; + + if (view == null && hadHeader) { + notifyItemRemoved(0); + } else if (view != null && hadHeader) { + notifyItemChanged(0); + } else if (view != null) { + notifyItemInserted(0); + } + } + + /** + * Returns the header view, if one was set. + */ + @Nullable View getHeaderView() { + return headerView; + } + + /** + * Momentarily highlights a mention at the requested position. + */ + void pulseAtPosition(int position) { + if (position >= 0 && position < getItemCount()) { + int correctedPosition = isHeaderPosition(position) ? position + 1 : position; + + recordToPulse = getItem(correctedPosition); + notifyItemChanged(correctedPosition); + } + } + + /** + * Conversation search query updated. Allows rendering of text highlighting. + */ + void onSearchQueryUpdated(String query) { + this.searchQuery = query; + notifyDataSetChanged(); + } + + /** + * Lets the adapter know that the wallpaper state has changed. + * @return True if the internal wallpaper state changed, otherwise false. + */ + boolean onHasWallpaperChanged(boolean hasWallpaper) { + if (this.hasWallpaper != hasWallpaper) { + Log.d(TAG, "Resetting adapter due to wallpaper change."); + this.hasWallpaper = hasWallpaper; + notifyDataSetChanged(); + return true; + } else { + return false; + } + } + + /** + * Adds a record to a memory cache to allow it to be rendered immediately, as opposed to waiting + * for a database change. + */ + @MainThread + void addFastRecord(ConversationMessage conversationMessage) { + fastRecords.add(0, conversationMessage); + notifyDataSetChanged(); + } + + /** + * Marks a record as no-longer-needed. Will be removed from the adapter the next time the database + * changes. + */ + @AnyThread + void releaseFastRecord(long id) { + synchronized (releasedFastRecords) { + releasedFastRecords.add(id); + } + } + + /** + * Returns set of records that are selected in multi-select mode. + */ + Set getSelectedItems() { + return new HashSet<>(selected); + } + + /** + * Clears all selected records from multi-select mode. + */ + void clearSelection() { + selected.clear(); + } + + /** + * Toggles the selected state of a record in multi-select mode. + */ + void toggleSelection(ConversationMessage conversationMessage) { + if (selected.contains(conversationMessage)) { + selected.remove(conversationMessage); + } else { + selected.add(conversationMessage); + } + } + + /** + * Provided a pool, this will initialize it with view counts that make sense. + */ + @MainThread + static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) { + pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_TEXT, 15); + pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_MULTIMEDIA, 15); + pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_TEXT, 15); + pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_MULTIMEDIA, 15); + pool.setMaxRecycledViews(MESSAGE_TYPE_PLACEHOLDER, 15); + pool.setMaxRecycledViews(MESSAGE_TYPE_HEADER, 1); + pool.setMaxRecycledViews(MESSAGE_TYPE_FOOTER, 1); + pool.setMaxRecycledViews(MESSAGE_TYPE_UPDATE, 5); + } + + @MainThread + private void cleanFastRecords() { + Util.assertMainThread(); + + synchronized (releasedFastRecords) { + Iterator messageIterator = fastRecords.iterator(); + while (messageIterator.hasNext()) { + long id = messageIterator.next().getMessageRecord().getId(); + if (releasedFastRecords.contains(id)) { + messageIterator.remove(); + releasedFastRecords.remove(id); + } + } + } + } + + private boolean hasHeader() { + return headerView != null; + } + + public boolean hasFooter() { + return footerView != null; + } + + private boolean isHeaderPosition(int position) { + return hasHeader() && position == 0; + } + + private boolean isFooterPosition(int position) { + return hasFooter() && position == (getItemCount() - 1); + } + + private static @LayoutRes int getLayoutForViewType(int viewType) { + switch (viewType) { + case MESSAGE_TYPE_OUTGOING_TEXT: return R.layout.conversation_item_sent_text_only; + case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: return R.layout.conversation_item_sent_multimedia; + case MESSAGE_TYPE_INCOMING_TEXT: return R.layout.conversation_item_received_text_only; + case MESSAGE_TYPE_INCOMING_MULTIMEDIA: return R.layout.conversation_item_received_multimedia; + case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update; + default: throw new IllegalArgumentException("Unknown type!"); + } + } + + private static MessageDigest getMessageDigestOrThrow() { + try { + return MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + public @Nullable ConversationMessage getLastVisibleConversationMessage(int position) { + return getItem(position - ((hasFooter() && position == getItemCount() - 1) ? 1 : 0)); + } + + public void setMessageRequestAccepted(boolean messageRequestAccepted) { + if (this.isMessageRequestAccepted != messageRequestAccepted) { + this.isMessageRequestAccepted = messageRequestAccepted; + notifyDataSetChanged(); + } + } + + static class ConversationViewHolder extends RecyclerView.ViewHolder { + public ConversationViewHolder(final @NonNull View itemView) { + super(itemView); + } + + public BindableConversationItem getBindable() { + return (BindableConversationItem) itemView; + } + } + + static class StickyHeaderViewHolder extends RecyclerView.ViewHolder { + TextView textView; + View divider; + + StickyHeaderViewHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.text); + divider = itemView.findViewById(R.id.last_seen_divider); + } + + StickyHeaderViewHolder(TextView textView) { + super(textView); + this.textView = textView; + } + + public void setText(CharSequence text) { + textView.setText(text); + } + + public void setTextColor(@ColorInt int color) { + textView.setTextColor(color); + } + + public void setBackgroundRes(@DrawableRes int resId) { + textView.setBackgroundResource(resId); + } + + public void setDividerColor(@ColorInt int color) { + if (divider != null) { + divider.setBackgroundColor(color); + } + } + + public void clearBackground() { + textView.setBackground(null); + } + } + + private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder { + + private ViewGroup container; + + HeaderFooterViewHolder(@NonNull View itemView) { + super(itemView); + this.container = (ViewGroup) itemView; + } + + void bind(@Nullable View view) { + unbind(); + + if (view != null) { + container.addView(view); + } + } + + void unbind() { + container.removeAllViews(); + } + } + + private static class PlaceholderViewHolder extends RecyclerView.ViewHolder { + PlaceholderViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + interface ItemClickListener extends BindableConversationItem.EventListener { + void onItemClick(ConversationMessage item); + void onItemLongClick(View maskTarget, ConversationMessage item); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java new file mode 100644 index 00000000..d0f54334 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class ConversationBannerView extends ConstraintLayout { + + private AvatarImageView contactAvatar; + private TextView contactTitle; + private TextView contactAbout; + private TextView contactSubtitle; + private TextView contactDescription; + + public ConversationBannerView(Context context) { + this(context, null); + } + + public ConversationBannerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ConversationBannerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(getContext(), R.layout.conversation_banner_view, this); + + contactAvatar = findViewById(R.id.message_request_avatar); + contactTitle = findViewById(R.id.message_request_title); + contactAbout = findViewById(R.id.message_request_about); + contactSubtitle = findViewById(R.id.message_request_subtitle); + contactDescription = findViewById(R.id.message_request_description); + + contactAvatar.setFallbackPhotoProvider(new FallbackPhotoProvider()); + } + + public void setAvatar(@NonNull GlideRequests requests, @Nullable Recipient recipient) { + contactAvatar.setAvatar(requests, recipient, false); + } + + public void setTitle(@Nullable CharSequence title) { + contactTitle.setText(title); + } + + public void setAbout(@Nullable String about) { + contactAbout.setText(about); + contactAbout.setVisibility(TextUtils.isEmpty(about) ? GONE : VISIBLE); + } + + public void setSubtitle(@Nullable CharSequence subtitle) { + contactSubtitle.setText(subtitle); + contactSubtitle.setVisibility(TextUtils.isEmpty(subtitle) ? GONE : VISIBLE); + } + + public void setDescription(@Nullable CharSequence description) { + contactDescription.setText(description); + } + + public void showBackgroundBubble(boolean enabled) { + if (enabled) { + setBackgroundResource(R.drawable.wallpaper_bubble_background_12); + } else { + setBackground(null); + } + } + + public void hideSubtitle() { + contactSubtitle.setVisibility(View.GONE); + } + + public void showDescription() { + contactDescription.setVisibility(View.VISIBLE); + } + + public void hideDescription() { + contactDescription.setVisibility(View.GONE); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_80); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new ResourceContactPhoto(R.drawable.ic_group_80); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new ResourceContactPhoto(R.drawable.ic_note_80); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java new file mode 100644 index 00000000..61d8682a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.conversation; + +/** + * Represents metadata about a conversation. + */ +final class ConversationData { + private final long threadId; + private final long lastSeen; + private final int lastSeenPosition; + private final int lastScrolledPosition; + private final boolean hasSent; + private final boolean isMessageRequestAccepted; + private final int jumpToPosition; + private final int threadSize; + + ConversationData(long threadId, + long lastSeen, + int lastSeenPosition, + int lastScrolledPosition, + boolean hasSent, + boolean isMessageRequestAccepted, + int jumpToPosition, + int threadSize) + { + this.threadId = threadId; + this.lastSeen = lastSeen; + this.lastSeenPosition = lastSeenPosition; + this.lastScrolledPosition = lastScrolledPosition; + this.hasSent = hasSent; + this.isMessageRequestAccepted = isMessageRequestAccepted; + this.jumpToPosition = jumpToPosition; + this.threadSize = threadSize; + } + + public long getThreadId() { + return threadId; + } + + long getLastSeen() { + return lastSeen; + } + + int getLastSeenPosition() { + return lastSeenPosition; + } + + int getLastScrolledPosition() { + return lastScrolledPosition; + } + + boolean hasSent() { + return hasSent; + } + + boolean isMessageRequestAccepted() { + return isMessageRequestAccepted; + } + + boolean shouldJumpToMessage() { + return jumpToPosition >= 0; + } + + boolean shouldScrollToLastSeen() { + return lastSeenPosition > 0; + } + + int getJumpToPosition() { + return jumpToPosition; + } + + int getThreadSize() { + return threadSize; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java new file mode 100644 index 00000000..6bdfdc6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.paging.PagedDataSource; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.util.Stopwatch; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Core data source for loading an individual conversation. + */ +class ConversationDataSource implements PagedDataSource { + + private static final String TAG = Log.tag(ConversationDataSource.class); + + private final Context context; + private final long threadId; + + ConversationDataSource(@NonNull Context context, long threadId) { + this.context = context; + this.threadId = threadId; + } + + @Override + public int size() { + long startTime = System.currentTimeMillis(); + int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); + + Log.d(TAG, "size() for thread " + threadId + ": " + (System.currentTimeMillis() - startTime) + " ms"); + + return size; + } + + @Override + public @NonNull List load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId); + MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); + List records = new ArrayList<>(length); + MentionHelper mentionHelper = new MentionHelper(); + + try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, start, length))) { + MessageRecord record; + while ((record = reader.getNext()) != null && !cancellationSignal.isCanceled()) { + records.add(record); + mentionHelper.add(record); + } + } + + stopwatch.split("messages"); + + mentionHelper.fetchMentions(context); + + stopwatch.split("mentions"); + + List messages = Stream.of(records) + .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId()))) + .toList(); + + stopwatch.split("conversion"); + stopwatch.stop(TAG); + + return messages; + } + + private static class MentionHelper { + + private Collection messageIds = new LinkedList<>(); + private Map> messageIdToMentions = new HashMap<>(); + + void add(MessageRecord record) { + if (record.isMms()) { + messageIds.add(record.getId()); + } + } + + void fetchMentions(Context context) { + messageIdToMentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessages(messageIds); + } + + @Nullable List getMentions(long id) { + return messageIdToMentions.get(id); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java new file mode 100644 index 00000000..baaca784 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -0,0 +1,1771 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversation; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.ViewSwitcher; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.app.ActivityOptionsCompat; +import androidx.core.text.HtmlCompat; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnScrollListener; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.components.ConversationScrollToView; +import org.thoughtcrime.securesms.components.ConversationTypingView; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.components.TypingStatusRepository; +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity; +import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; +import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.longmessage.LongMessageActivity; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity; +import org.thoughtcrime.securesms.messagerequests.MessageRequestState; +import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity; +import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.sharing.ShareIntents; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.HtmlUtil; +import org.thoughtcrime.securesms.util.RemoteDeleteUtil; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.SignalProxyUtil; +import org.thoughtcrime.securesms.util.SnapToTopDataObserver; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@SuppressLint("StaticFieldLeak") +public class ConversationFragment extends LoggingFragment { + private static final String TAG = ConversationFragment.class.getSimpleName(); + + private static final int SCROLL_ANIMATION_THRESHOLD = 50; + private static final int CODE_ADD_EDIT_CONTACT = 77; + + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener(); + + private ConversationFragmentListener listener; + + private LiveRecipient recipient; + private long threadId; + private boolean isReacting; + private ActionMode actionMode; + private Locale locale; + private RecyclerView list; + private RecyclerView.ItemDecoration lastSeenDecoration; + private RecyclerView.ItemDecoration inlineDateDecoration; + private ViewSwitcher topLoadMoreView; + private ViewSwitcher bottomLoadMoreView; + private ConversationTypingView typingView; + private View composeDivider; + private ConversationScrollToView scrollToBottomButton; + private ConversationScrollToView scrollToMentionButton; + private TextView scrollDateHeader; + private ConversationBannerView conversationBanner; + private ConversationBannerView emptyConversationBanner; + private MessageRequestViewModel messageRequestViewModel; + private MessageCountsViewModel messageCountsViewModel; + private ConversationViewModel conversationViewModel; + private SnapToTopDataObserver snapToTopDataObserver; + private MarkReadHelper markReadHelper; + private Animation scrollButtonInAnimation; + private Animation mentionButtonInAnimation; + private Animation scrollButtonOutAnimation; + private Animation mentionButtonOutAnimation; + private OnScrollListener conversationScrollListener; + private int pulsePosition = -1; + private VoiceNoteMediaController voiceNoteMediaController; + private View toolbarShadow; + + public static void prepare(@NonNull Context context) { + FrameLayout parent = new FrameLayout(context); + parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)); + + CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_text_only, parent, 15); + CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_text_only, parent, 15); + CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_multimedia, parent, 10); + CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_multimedia, parent, 10); + CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_update, parent, 5); + CachedInflater.from(context).cacheUntilLimit(R.layout.cursor_adapter_header_footer_view, parent, 2); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + this.locale = (Locale) getArguments().getSerializable(PassphraseRequiredActivity.LOCALE_EXTRA); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { + final View view = inflater.inflate(R.layout.conversation_fragment, container, false); + list = view.findViewById(android.R.id.list); + composeDivider = view.findViewById(R.id.compose_divider); + + scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom); + scrollToMentionButton = view.findViewById(R.id.scroll_to_mention); + scrollDateHeader = view.findViewById(R.id.scroll_date_header); + emptyConversationBanner = view.findViewById(R.id.empty_conversation_banner); + toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow); + + final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); + list.setHasFixedSize(false); + list.setLayoutManager(layoutManager); + list.setItemAnimator(null); + + snapToTopDataObserver = new ConversationSnapToTopDataObserver(list, new ConversationScrollRequestValidator()); + conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false); + topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false); + bottomLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false); + + initializeLoadMoreView(topLoadMoreView); + initializeLoadMoreView(bottomLoadMoreView); + + typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false); + + new ConversationItemSwipeCallback( + conversationMessage -> actionMode == null && + MenuState.canReplyToMessage(recipient.get(), + MenuState.isActionMessage(conversationMessage.getMessageRecord()), + conversationMessage.getMessageRecord(), + messageRequestViewModel.shouldShowMessageRequest()), + this::handleReplyMessage + ).attachToRecyclerView(list); + + setupListLayoutListeners(); + + this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class); + this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class); + + conversationViewModel.getMessages().observe(getViewLifecycleOwner(), messages -> { + ConversationAdapter adapter = getListAdapter(); + if (adapter != null) { + getListAdapter().submitList(messages); + } + }); + + conversationViewModel.getConversationMetadata().observe(getViewLifecycleOwner(), this::presentConversationMetadata); + + conversationViewModel.getShowMentionsButton().observe(getViewLifecycleOwner(), shouldShow -> { + if (shouldShow) { + ViewUtil.animateIn(scrollToMentionButton, mentionButtonInAnimation); + } else { + ViewUtil.animateOut(scrollToMentionButton, mentionButtonOutAnimation, View.INVISIBLE); + } + }); + + conversationViewModel.getShowScrollToBottom().observe(getViewLifecycleOwner(), shouldShow -> { + if (shouldShow) { + ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation); + } else { + ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE); + } + }); + + scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); + scrollToMentionButton.setOnClickListener(v -> scrollToNextMention()); + + updateToolbarDependentMargins(); + + return view; + } + + private void setupListLayoutListeners() { + list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation()); + + list.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(@NonNull View view) { + setListVerticalTranslation(); + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View view) { + setListVerticalTranslation(); + } + }); + } + + private void setListVerticalTranslation() { + if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) { + list.setTranslationY(0); + list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS); + } else { + int chTop = list.getChildAt(list.getChildCount() - 1).getTop(); + list.setTranslationY(Math.min(0, -chTop)); + list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER); + } + + int offset = WindowUtil.isStatusBarPresent(requireActivity().getWindow()) ? ViewUtil.getStatusBarHeight(list) : 0; + listener.onListVerticalTranslationChanged(list.getTranslationY() - offset); + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + initializeScrollButtonAnimations(); + initializeResources(); + initializeMessageRequestViewModel(); + initializeListAdapter(); + voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.listener = (ConversationFragmentListener)activity; + } + + @Override + public void onStart() { + super.onStart(); + initializeTypingObserver(); + SignalProxyUtil.startListeningToWebsocket(); + } + + @Override + public void onPause() { + super.onPause(); + int lastVisiblePosition = getListLayoutManager().findLastVisibleItemPosition(); + int firstVisiblePosition = getListLayoutManager().findFirstCompletelyVisibleItemPosition(); + + final long lastVisibleMessageTimestamp; + if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) { + ConversationMessage message = getListAdapter().getLastVisibleConversationMessage(lastVisiblePosition); + + lastVisibleMessageTimestamp = message != null ? message.getMessageRecord().getDateReceived() : 0; + } else { + lastVisibleMessageTimestamp = 0; + } + SignalExecutors.BOUNDED.submit(() -> DatabaseFactory.getThreadDatabase(requireContext()).setLastScrolled(threadId, lastVisibleMessageTimestamp)); + } + + @Override + public void onStop() { + super.onStop(); + ApplicationDependencies.getTypingStatusRepository().getTypists(threadId).removeObservers(getViewLifecycleOwner()); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + updateToolbarDependentMargins(); + } + + public void onNewIntent() { + if (actionMode != null) { + actionMode.finish(); + } + + long oldThreadId = threadId; + + initializeResources(); + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); + + int startingPosition = getStartPosition(); + if (startingPosition != -1 && oldThreadId == threadId) { + list.post(() -> moveToPosition(startingPosition, () -> Log.w(TAG, "Could not scroll to requested message."))); + } else { + initializeListAdapter(); + } + } + + public void moveToLastSeen() { + if (conversationViewModel.getLastSeenPosition() <= 0) { + Log.i(TAG, "No need to move to last seen."); + return; + } + + if (list == null || getListAdapter() == null) { + Log.w(TAG, "Tried to move to last seen position, but we hadn't initialized the view yet."); + return; + } + + int position = getListAdapter().getAdapterPositionForMessagePosition(conversationViewModel.getLastSeenPosition()); + snapToTopDataObserver.requestScrollPosition(position); + } + + public void onWallpaperChanged(@Nullable ChatWallpaper wallpaper) { + if (list != null) { + ConversationAdapter adapter = getListAdapter(); + + if (adapter != null) { + Log.d(TAG, "Notifying adapter that wallpaper state has changed."); + + if (adapter.onHasWallpaperChanged(wallpaper != null)) { + setInlineDateDecoration(adapter); + } + } + } + } + + private int getStartPosition() { + return conversationViewModel.getArgs().getStartingPosition(); + } + + private void initializeMessageRequestViewModel() { + MessageRequestViewModel.Factory factory = new MessageRequestViewModel.Factory(requireContext()); + + messageRequestViewModel = ViewModelProviders.of(requireActivity(), factory).get(MessageRequestViewModel.class); + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); + + listener.onMessageRequest(messageRequestViewModel); + + messageRequestViewModel.getRecipientInfo().observe(getViewLifecycleOwner(), recipientInfo -> { + presentMessageRequestProfileView(requireContext(), recipientInfo, conversationBanner); + presentMessageRequestProfileView(requireContext(), recipientInfo, emptyConversationBanner); + }); + + messageRequestViewModel.getMessageData().observe(getViewLifecycleOwner(), data -> { + ConversationAdapter adapter = getListAdapter(); + if (adapter != null) { + adapter.setMessageRequestAccepted(data.getMessageState() == MessageRequestState.NONE); + } + }); + } + + private static void presentMessageRequestProfileView(@NonNull Context context, @NonNull MessageRequestViewModel.RecipientInfo recipientInfo, @Nullable ConversationBannerView conversationBanner) { + if (conversationBanner == null) { + return; + } + + Recipient recipient = recipientInfo.getRecipient(); + boolean isSelf = Recipient.self().equals(recipient); + int memberCount = recipientInfo.getGroupMemberCount(); + int pendingMemberCount = recipientInfo.getGroupPendingMemberCount(); + List groups = recipientInfo.getSharedGroups(); + + if (recipient != null) { + conversationBanner.setAvatar(GlideApp.with(context), recipient); + conversationBanner.showBackgroundBubble(recipient.hasWallpaper()); + + String title = isSelf ? context.getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(context); + conversationBanner.setTitle(title); + conversationBanner.setAbout(recipient.getCombinedAboutAndEmoji()); + + if (recipient.isGroup()) { + if (pendingMemberCount > 0) { + conversationBanner.setSubtitle(context.getResources() + .getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount, + memberCount, pendingMemberCount)); + } else if (memberCount > 0) { + conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount, + memberCount)); + } else { + conversationBanner.setSubtitle(null); + } + } else if (isSelf) { + conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation)); + } else { + String subtitle = recipient.getE164().transform(PhoneNumberFormatter::prettyPrint).orNull(); + + if (subtitle == null || subtitle.equals(title)) { + conversationBanner.hideSubtitle(); + } else { + conversationBanner.setSubtitle(subtitle); + } + } + } + + if (groups.isEmpty() || isSelf) { + conversationBanner.hideDescription(); + } else { + final String description; + + switch (groups.size()) { + case 1: + description = context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(groups.get(0))); + break; + case 2: + description = context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1))); + break; + case 3: + description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1)), HtmlUtil.bold(groups.get(2))); + break; + default: + int others = groups.size() - 2; + description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups, + HtmlUtil.bold(groups.get(0)), + HtmlUtil.bold(groups.get(1)), + context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)); + } + + conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0)); + conversationBanner.showDescription(); + } + } + + private void initializeResources() { + long oldThreadId = threadId; + + int startingPosition = getStartPosition(); + + this.recipient = Recipient.live(conversationViewModel.getArgs().getRecipientId()); + this.threadId = conversationViewModel.getArgs().getThreadId(); + this.markReadHelper = new MarkReadHelper(threadId, requireContext()); + + conversationViewModel.onConversationDataAvailable(recipient.getId(), threadId, startingPosition); + messageCountsViewModel.setThreadId(threadId); + + messageCountsViewModel.getUnreadMessagesCount().observe(getViewLifecycleOwner(), scrollToBottomButton::setUnreadCount); + messageCountsViewModel.getUnreadMentionsCount().observe(getViewLifecycleOwner(), count -> { + scrollToMentionButton.setUnreadCount(count); + conversationViewModel.setHasUnreadMentions(count > 0); + }); + + conversationScrollListener = new ConversationScrollListener(requireContext()); + list.addOnScrollListener(conversationScrollListener); + list.addOnScrollListener(new ShadowScrollListener()); + + if (oldThreadId != threadId) { + ApplicationDependencies.getTypingStatusRepository().getTypists(oldThreadId).removeObservers(getViewLifecycleOwner()); + } + } + + private void initializeListAdapter() { + if (this.recipient != null && this.threadId != -1) { + Log.d(TAG, "Initializing adapter for " + recipient.getId()); + ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get()); + adapter.setPagingController(conversationViewModel.getPagingController()); + list.setAdapter(adapter); + setInlineDateDecoration(adapter); + ConversationAdapter.initializePool(list.getRecycledViewPool()); + + adapter.registerAdapterDataObserver(snapToTopDataObserver); + + setLastSeen(conversationViewModel.getLastSeen()); + + emptyConversationBanner.setVisibility(View.GONE); + } else if (threadId == -1) { + emptyConversationBanner.setVisibility(View.VISIBLE); + toolbarShadow.setVisibility(View.GONE); + } + } + + private void initializeLoadMoreView(ViewSwitcher loadMoreView) { + loadMoreView.setOnClickListener(v -> { + loadMoreView.showNext(); + loadMoreView.setOnClickListener(null); + }); + } + + private void initializeTypingObserver() { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(requireContext())) { + return; + } + + LiveData typists = ApplicationDependencies.getTypingStatusRepository().getTypists(threadId); + + typists.removeObservers(getViewLifecycleOwner()); + typists.observe(getViewLifecycleOwner(), typingState -> { + List recipients; + boolean replacedByIncomingMessage; + + if (typingState != null) { + recipients = typingState.getTypists(); + replacedByIncomingMessage = typingState.isReplacedByIncomingMessage(); + } else { + recipients = Collections.emptyList(); + replacedByIncomingMessage = false; + } + + typingView.setTypists(GlideApp.with(ConversationFragment.this), recipients, recipient.get().isGroup()); + + ConversationAdapter adapter = getListAdapter(); + + if (adapter.getHeaderView() != null && adapter.getHeaderView() != typingView) { + Log.i(TAG, "Skipping typing indicator -- the header slot is occupied."); + return; + } + + if (recipients.size() > 0) { + if (!isTypingIndicatorShowing() && isAtBottom()) { + Context context = requireContext(); + list.setVerticalScrollBarEnabled(false); + list.post(() -> { + if (!isReacting) { + getListLayoutManager().smoothScrollToPosition(context, 0, 250); + } + }); + list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300); + adapter.setHeaderView(typingView); + } else { + if (isTypingIndicatorShowing()) { + adapter.setHeaderView(typingView); + } else { + adapter.setHeaderView(typingView); + } + } + } else { + if (isTypingIndicatorShowing() && getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0 && getListLayoutManager().getItemCount() > 1 && !replacedByIncomingMessage) { + if (!isReacting) { + getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250); + } + list.setVerticalScrollBarEnabled(false); + list.postDelayed(() -> { + adapter.setHeaderView(null); + list.post(() -> list.setVerticalScrollBarEnabled(true)); + }, 200); + } else if (!replacedByIncomingMessage) { + adapter.setHeaderView(null); + } else { + adapter.setHeaderView(null); + } + } + }); + } + + private void setCorrectMenuVisibility(@NonNull Menu menu) { + Set messages = getListAdapter().getSelectedItems(); + + if (actionMode != null && messages.size() == 0) { + actionMode.finish(); + return; + } + + MenuState menuState = MenuState.getMenuState(recipient.get(), Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest()); + + menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction()); + menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction()); + menu.findItem(R.id.menu_context_details).setVisible(menuState.shouldShowDetailsAction()); + menu.findItem(R.id.menu_context_save_attachment).setVisible(menuState.shouldShowSaveAttachmentAction()); + menu.findItem(R.id.menu_context_resend).setVisible(menuState.shouldShowResendAction()); + menu.findItem(R.id.menu_context_copy).setVisible(menuState.shouldShowCopyAction()); + } + + private @Nullable ConversationAdapter getListAdapter() { + return (ConversationAdapter) list.getAdapter(); + } + + private SmoothScrollingLinearLayoutManager getListLayoutManager() { + return (SmoothScrollingLinearLayoutManager) list.getLayoutManager(); + } + + private ConversationMessage getSelectedConversationMessage() { + Set messageRecords = getListAdapter().getSelectedItems(); + + if (messageRecords.size() == 1) return messageRecords.iterator().next(); + else throw new AssertionError(); + } + + public void reload(Recipient recipient, long threadId) { + this.recipient = recipient.live(); + + if (this.threadId != threadId) { + this.threadId = threadId; + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); + + snapToTopDataObserver.requestScrollPosition(0); + conversationViewModel.onConversationDataAvailable(recipient.getId(), threadId, -1); + messageCountsViewModel.setThreadId(threadId); + initializeListAdapter(); + } + } + + public void scrollToBottom() { + if (getListLayoutManager().findFirstVisibleItemPosition() < SCROLL_ANIMATION_THRESHOLD) { + list.smoothScrollToPosition(0); + } else { + list.scrollToPosition(0); + } + } + + public void setInlineDateDecoration(@NonNull ConversationAdapter adapter) { + if (inlineDateDecoration != null) { + list.removeItemDecoration(inlineDateDecoration); + } + + inlineDateDecoration = new StickyHeaderDecoration(adapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE); + list.addItemDecoration(inlineDateDecoration); + } + + public void setLastSeen(long lastSeen) { + if (lastSeenDecoration != null) { + list.removeItemDecoration(lastSeenDecoration); + } + + lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen); + list.addItemDecoration(lastSeenDecoration); + } + + private void handleCopyMessage(final Set conversationMessages) { + List messageList = new ArrayList<>(conversationMessages); + Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived())); + + SpannableStringBuilder bodyBuilder = new SpannableStringBuilder(); + ClipboardManager clipboard = (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE); + + for (ConversationMessage message : messageList) { + CharSequence body = message.getDisplayBody(requireContext()); + if (!TextUtils.isEmpty(body)) { + if (bodyBuilder.length() > 0) { + bodyBuilder.append('\n'); + } + bodyBuilder.append(body); + } + } + + if (!TextUtils.isEmpty(bodyBuilder)) { + clipboard.setPrimaryClip(ClipData.newPlainText(null, bodyBuilder)); + } + } + + private void handleDeleteMessages(final Set conversationMessages) { + Set messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()); + buildRemoteDeleteConfirmationDialog(messageRecords).show(); + } + + private AlertDialog.Builder buildRemoteDeleteConfirmationDialog(Set messageRecords) { + Context context = requireActivity(); + int messagesCount = messageRecords.size(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + + builder.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messagesCount, messagesCount)); + builder.setCancelable(true); + + builder.setPositiveButton(R.string.ConversationFragment_delete_for_me, (dialog, which) -> { + new ProgressDialogAsyncTask(getActivity(), + R.string.ConversationFragment_deleting, + R.string.ConversationFragment_deleting_messages) + { + @Override + protected Void doInBackground(Void... voids) { + for (MessageRecord messageRecord : messageRecords) { + boolean threadDeleted; + + if (messageRecord.isMms()) { + threadDeleted = DatabaseFactory.getMmsDatabase(context).deleteMessage(messageRecord.getId()); + } else { + threadDeleted = DatabaseFactory.getSmsDatabase(context).deleteMessage(messageRecord.getId()); + } + + if (threadDeleted) { + threadId = -1; + conversationViewModel.clearThreadId(); + messageCountsViewModel.clearThreadId(); + listener.setThreadId(threadId); + } + } + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + }); + + if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis())) { + builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> handleDeleteForEveryone(messageRecords)); + } + + builder.setNegativeButton(android.R.string.cancel, null); + return builder; + } + + private void handleDeleteForEveryone(Set messageRecords) { + Runnable deleteForEveryone = () -> { + SignalExecutors.BOUNDED.execute(() -> { + for (MessageRecord message : messageRecords) { + MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.getId(), message.isMms()); + } + }); + }; + + if (SignalStore.uiHints().hasConfirmedDeleteForEveryoneOnce()) { + deleteForEveryone.run(); + } else { + new AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ConversationFragment_this_message_will_be_deleted_for_everyone_in_the_conversation) + .setPositiveButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> { + SignalStore.uiHints().markHasConfirmedDeleteForEveryoneOnce(); + deleteForEveryone.run(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + } + + private void handleDisplayDetails(ConversationMessage message) { + startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId)); + } + + private void handleForwardMessage(ConversationMessage conversationMessage) { + if (conversationMessage.getMessageRecord().isViewOnce()) { + throw new AssertionError("Cannot forward a view-once message."); + } + + listener.onForwardClicked(); + + SimpleTask.run(getLifecycle(), () -> { + ShareIntents.Builder shareIntentBuilder = new ShareIntents.Builder(requireActivity()); + shareIntentBuilder.setText(conversationMessage.getDisplayBody(requireContext())); + + if (conversationMessage.getMessageRecord().isMms()) { + MmsMessageRecord mediaMessage = (MmsMessageRecord) conversationMessage.getMessageRecord(); + boolean isAlbum = mediaMessage.containsMediaSlide() && + mediaMessage.getSlideDeck().getSlides().size() > 1 && + mediaMessage.getSlideDeck().getAudioSlide() == null && + mediaMessage.getSlideDeck().getDocumentSlide() == null && + mediaMessage.getSlideDeck().getStickerSlide() == null; + + if (isAlbum) { + ArrayList mediaList = new ArrayList<>(mediaMessage.getSlideDeck().getSlides().size()); + List attachments = Stream.of(mediaMessage.getSlideDeck().getSlides()) + .filter(s -> s.hasImage() || s.hasVideo()) + .map(Slide::asAttachment) + .toList(); + + for (Attachment attachment : attachments) { + Uri uri = attachment.getUri(); + + if (uri != null) { + mediaList.add(new Media(uri, + attachment.getContentType(), + System.currentTimeMillis(), + attachment.getWidth(), + attachment.getHeight(), + attachment.getSize(), + 0, + attachment.isBorderless(), + Optional.absent(), + Optional.fromNullable(attachment.getCaption()), + Optional.absent())); + } + } + + if (!mediaList.isEmpty()) { + shareIntentBuilder.setMedia(mediaList); + } + } else if (mediaMessage.containsMediaSlide()) { + Slide slide = mediaMessage.getSlideDeck().getSlides().get(0); + shareIntentBuilder.setSlide(slide); + } + + if (mediaMessage.getSlideDeck().getTextSlide() != null && mediaMessage.getSlideDeck().getTextSlide().getUri() != null) { + try (InputStream stream = PartAuthority.getAttachmentStream(requireContext(), mediaMessage.getSlideDeck().getTextSlide().getUri())) { + String fullBody = StreamUtil.readFullyAsString(stream); + shareIntentBuilder.setText(fullBody); + } catch (IOException e) { + Log.w(TAG, "Failed to read long message text when forwarding."); + } + } + } + + return shareIntentBuilder.build(); + }, this::startActivity); + } + + private void handleResendMessage(final MessageRecord message) { + final Context context = getActivity().getApplicationContext(); + new AsyncTask() { + @Override + protected Void doInBackground(MessageRecord... messageRecords) { + MessageSender.resend(context, messageRecords[0]); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, message); + } + + private void handleReplyMessage(final ConversationMessage message) { + if (getActivity() != null) { + //noinspection ConstantConditions + ((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView(); + } + + listener.handleReplyMessage(message); + } + + private void handleSaveAttachment(final MediaMmsMessageRecord message) { + if (message.isViewOnce()) { + throw new AssertionError("Cannot save a view-once message."); + } + + SaveAttachmentTask.showWarningDialog(getActivity(), (dialog, which) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSave(message); + return; + } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(() -> performSave(message)) + .execute(); + }); + } + + private void performSave(final MediaMmsMessageRecord message) { + List attachments = Stream.of(message.getSlideDeck().getSlides()) + .filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument())) + .map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull())) + .toList(); + + if (!Util.isEmpty(attachments)) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity()); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments.toArray(new SaveAttachmentTask.Attachment[0])); + return; + } + + Log.w(TAG, "No slide with attachable media found, failing nicely."); + Toast.makeText(getActivity(), + getResources().getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), + Toast.LENGTH_LONG).show(); + } + + private void clearHeaderIfNotTyping(ConversationAdapter adapter) { + if (adapter.getHeaderView() != typingView) { + adapter.setHeaderView(null); + } + } + + public long stageOutgoingMessage(OutgoingMediaMessage message) { + MessageRecord messageRecord = MmsDatabase.readerFor(message, threadId).getCurrent(); + + if (getListAdapter() != null) { + clearHeaderIfNotTyping(getListAdapter()); + setLastSeen(0); + getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord, messageRecord.getDisplayBody(requireContext()), message.getMentions())); + list.post(() -> list.scrollToPosition(0)); + } + + return messageRecord.getId(); + } + + public long stageOutgoingMessage(OutgoingTextMessage message) { + MessageRecord messageRecord = SmsDatabase.readerFor(message, threadId).getCurrent(); + + if (getListAdapter() != null) { + clearHeaderIfNotTyping(getListAdapter()); + setLastSeen(0); + getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord)); + list.post(() -> list.scrollToPosition(0)); + } + + return messageRecord.getId(); + } + + public void releaseOutgoingMessage(long id) { + if (getListAdapter() != null) { + getListAdapter().releaseFastRecord(id); + } + } + + private void presentConversationMetadata(@NonNull ConversationData conversation) { + ConversationAdapter adapter = getListAdapter(); + if (adapter == null) { + return; + } + + adapter.setFooterView(conversationBanner); + + Runnable afterScroll = () -> { + if (!conversation.isMessageRequestAccepted()) { + snapToTopDataObserver.requestScrollPosition(adapter.getItemCount() - 1); + } + + setLastSeen(conversation.getLastSeen()); + + clearHeaderIfNotTyping(adapter); + + listener.onCursorChanged(); + + conversationScrollListener.onScrolled(list, 0, 0); + }; + + int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition()); + int lastScrolledPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastScrolledPosition()); + + if (conversation.getThreadSize() == 0) { + afterScroll.run(); + } else if (conversation.shouldJumpToMessage()) { + snapToTopDataObserver.buildScrollPosition(conversation.getJumpToPosition()) + .withOnScrollRequestComplete(() -> { + afterScroll.run(); + getListAdapter().pulseAtPosition(conversation.getJumpToPosition()); + }) + .submit(); + } else if (conversation.isMessageRequestAccepted()) { + snapToTopDataObserver.buildScrollPosition(conversation.shouldScrollToLastSeen() ? lastSeenPosition : lastScrolledPosition) + .withOnPerformScroll((layoutManager, position) -> layoutManager.scrollToPositionWithOffset(position, list.getHeight())) + .withOnScrollRequestComplete(afterScroll) + .submit(); + } else { + snapToTopDataObserver.buildScrollPosition(adapter.getItemCount() - 1) + .withOnScrollRequestComplete(afterScroll) + .submit(); + } + } + + private boolean isAtBottom() { + if (list.getChildCount() == 0) return true; + + int firstVisiblePosition = getListLayoutManager().findFirstVisibleItemPosition(); + + if (isTypingIndicatorShowing()) { + RecyclerView.ViewHolder item1 = list.findViewHolderForAdapterPosition(1); + return firstVisiblePosition <= 1 && item1 != null && item1.itemView.getBottom() <= list.getHeight(); + } + + return firstVisiblePosition == 0 && list.getChildAt(0).getBottom() <= list.getHeight(); + } + + private boolean isTypingIndicatorShowing() { + return getListAdapter().getHeaderView() == typingView; + } + + public void onSearchQueryUpdated(@Nullable String query) { + if (getListAdapter() != null) { + getListAdapter().onSearchQueryUpdated(query); + } + } + + @SuppressWarnings("CodeBlock2Expr") + public void jumpToMessage(@NonNull RecipientId author, long timestamp, @Nullable Runnable onMessageNotFound) { + SimpleTask.run(getLifecycle(), () -> { + return DatabaseFactory.getMmsSmsDatabase(getContext()) + .getMessagePositionInConversation(threadId, timestamp, author); + }, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), onMessageNotFound)); + } + + private void moveToPosition(int position, @Nullable Runnable onMessageNotFound) { + Log.d(TAG, "moveToPosition(" + position + ")"); + conversationViewModel.getPagingController().onDataNeededAroundIndex(position); + snapToTopDataObserver.buildScrollPosition(position) + .withOnPerformScroll(((layoutManager, p) -> + list.post(() -> { + if (Math.abs(layoutManager.findFirstVisibleItemPosition() - p) < SCROLL_ANIMATION_THRESHOLD) { + View child = layoutManager.findViewByPosition(position); + + if (child != null && layoutManager.isViewPartiallyVisible(child, true, false)) { + getListAdapter().pulseAtPosition(position); + } else { + pulsePosition = position; + } + + layoutManager.scrollToPositionWithOffset(p, list.getHeight() / 4); + } else { + layoutManager.scrollToPositionWithOffset(p, list.getHeight() / 4); + getListAdapter().pulseAtPosition(position); + } + }) + )) + .withOnInvalidPosition(() -> { + if (onMessageNotFound != null) { + onMessageNotFound.run(); + } + Log.w(TAG, "[moveToMentionPosition] Tried to navigate to mention, but it wasn't found."); + }) + .submit(); + } + + private void maybeShowSwipeToReplyTooltip() { + if (!TextSecurePreferences.hasSeenSwipeToReplyTooltip(requireContext())) { + int text = ViewUtil.isLtr(requireContext()) ? R.string.ConversationFragment_you_can_swipe_to_the_right_reply + : R.string.ConversationFragment_you_can_swipe_to_the_left_reply; + TooltipPopup.forTarget(requireActivity().findViewById(R.id.menu_context_reply)) + .setText(text) + .setTextColor(getResources().getColor(R.color.core_white)) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) + .show(TooltipPopup.POSITION_BELOW); + + TextSecurePreferences.setHasSeenSwipeToReplyTooltip(requireContext(), true); + } + } + + private void initializeScrollButtonAnimations() { + scrollButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in); + scrollButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out); + + mentionButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in); + mentionButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out); + + scrollButtonInAnimation.setDuration(100); + scrollButtonOutAnimation.setDuration(50); + + mentionButtonInAnimation.setDuration(100); + mentionButtonOutAnimation.setDuration(50); + } + + private void scrollToNextMention() { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(ApplicationDependencies.getApplication()); + return mmsDatabase.getOldestUnreadMentionDetails(threadId); + }, (pair) -> { + if (pair != null) { + jumpToMessage(pair.first(), pair.second(), () -> {}); + } + }); + } + + private void postMarkAsReadRequest() { + if (getListAdapter().hasNoConversationMessages()) { + return; + } + + int position = getListLayoutManager().findFirstVisibleItemPosition(); + if (position == getListAdapter().getItemCount() - 1) { + return; + } + + if (position >= (isTypingIndicatorShowing() ? 1 : 0)) { + ConversationMessage item = getListAdapter().getItem(position); + if (item != null) { + long timestamp = item.getMessageRecord() + .getDateReceived(); + + markReadHelper.onViewsRevealed(timestamp); + } + } + } + + private void updateToolbarDependentMargins() { + Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); + toolbar.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + Rect rect = new Rect(); + toolbar.getGlobalVisibleRect(rect); + ViewUtil.setTopMargin(scrollDateHeader, rect.bottom + ViewUtil.dpToPx(8)); + ViewUtil.setTopMargin(conversationBanner, rect.bottom + ViewUtil.dpToPx(16)); + ViewUtil.setTopMargin(emptyConversationBanner, rect.bottom + ViewUtil.dpToPx(16)); + toolbar.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + }); + + } + + public interface ConversationFragmentListener { + void setThreadId(long threadId); + void handleReplyMessage(ConversationMessage conversationMessage); + void onMessageActionToolbarOpened(); + void onForwardClicked(); + void onMessageRequest(@NonNull MessageRequestViewModel viewModel); + void handleReaction(@NonNull View maskTarget, + @NonNull MessageRecord messageRecord, + @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnHideListener onHideListener); + void onCursorChanged(); + void onListVerticalTranslationChanged(float translationY); + void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); + void handleReactionDetails(@NonNull View maskTarget); + } + + private class ConversationScrollListener extends OnScrollListener { + + private final ConversationDateHeader conversationDateHeader; + + private boolean wasAtBottom = true; + private long lastPositionId = -1; + + ConversationScrollListener(@NonNull Context context) { + this.conversationDateHeader = new ConversationDateHeader(context, scrollDateHeader); + + } + + @Override + public void onScrolled(@NonNull final RecyclerView rv, final int dx, final int dy) { + boolean currentlyAtBottom = !rv.canScrollVertically(1); + boolean currentlyAtZoomScrollHeight = isAtZoomScrollHeight(); + int positionId = getHeaderPositionId(); + + if (currentlyAtBottom && !wasAtBottom) { + ViewUtil.fadeOut(composeDivider, 50, View.INVISIBLE); + } else if (!currentlyAtBottom && wasAtBottom) { + ViewUtil.fadeIn(composeDivider, 500); + } + + if (currentlyAtBottom) { + conversationViewModel.setShowScrollButtons(false); + } else if (currentlyAtZoomScrollHeight) { + conversationViewModel.setShowScrollButtons(true); + } + + if (positionId != lastPositionId) { + bindScrollHeader(conversationDateHeader, positionId); + } + + wasAtBottom = currentlyAtBottom; + lastPositionId = positionId; + + postMarkAsReadRequest(); + } + + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + conversationDateHeader.show(); + } else if (newState == RecyclerView.SCROLL_STATE_IDLE) { + conversationDateHeader.hide(); + + if (pulsePosition != -1) { + getListAdapter().pulseAtPosition(pulsePosition); + pulsePosition = -1; + } + } + } + + private boolean isAtZoomScrollHeight() { + return getListLayoutManager().findFirstCompletelyVisibleItemPosition() > 4; + } + + private int getHeaderPositionId() { + return getListLayoutManager().findLastVisibleItemPosition(); + } + + private void bindScrollHeader(StickyHeaderViewHolder headerViewHolder, int positionId) { + if (((ConversationAdapter)list.getAdapter()).getHeaderId(positionId) != -1) { + ((ConversationAdapter) list.getAdapter()).onBindHeaderViewHolder(headerViewHolder, positionId, ConversationAdapter.HEADER_TYPE_POPOVER_DATE); + } + } + } + + private class ConversationFragmentItemClickListener implements ItemClickListener { + + @Override + public void onItemClick(ConversationMessage conversationMessage) { + if (actionMode != null) { + ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + list.getAdapter().notifyDataSetChanged(); + + if (getListAdapter().getSelectedItems().size() == 0) { + actionMode.finish(); + } else { + setCorrectMenuVisibility(actionMode.getMenu()); + actionMode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size())); + } + } + } + + @Override + public void onItemLongClick(View maskTarget, ConversationMessage conversationMessage) { + + if (actionMode != null) return; + + MessageRecord messageRecord = conversationMessage.getMessageRecord(); + + if (messageRecord.isSecure() && + !messageRecord.isRemoteDelete() && + !messageRecord.isUpdate() && + !recipient.get().isBlocked() && + !messageRequestViewModel.shouldShowMessageRequest() && + (!recipient.get().isGroup() || recipient.get().isActiveGroup()) && + ((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty()) + { + isReacting = true; + list.setLayoutFrozen(true); + listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(conversationMessage), () -> { + isReacting = false; + list.setLayoutFrozen(false); + WindowUtil.setLightStatusBarFromTheme(requireActivity()); + }); + } else { + ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + list.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); + } + } + + @Override + public void onQuoteClicked(MmsMessageRecord messageRecord) { + if (messageRecord.getQuote() == null) { + Log.w(TAG, "Received a 'quote clicked' event, but there's no quote..."); + return; + } + + if (messageRecord.getQuote().isOriginalMissing()) { + Log.i(TAG, "Clicked on a quote whose original message we never had."); + Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT).show(); + return; + } + + SimpleTask.run(getLifecycle(), () -> { + return DatabaseFactory.getMmsSmsDatabase(getContext()) + .getQuotedMessagePosition(threadId, + messageRecord.getQuote().getId(), + messageRecord.getQuote().getAuthor()); + }, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> { + Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show(); + })); + } + + @Override + public void onLinkPreviewClicked(@NonNull LinkPreview linkPreview) { + if (getContext() != null && getActivity() != null) { + CommunicationActions.openBrowserLink(getActivity(), linkPreview.getUrl()); + } + } + + @Override + public void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms) { + if (getContext() != null && getActivity() != null) { + startActivity(LongMessageActivity.getIntent(getContext(), conversationRecipientId, messageId, isMms)); + } + } + + @Override + public void onStickerClicked(@NonNull StickerLocator sticker) { + if (getContext() != null && getActivity() != null) { + startActivity(StickerPackPreviewActivity.getIntent(sticker.getPackId(), sticker.getPackKey())); + } + } + + @Override + public void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord) { + if (!messageRecord.isViewOnce()) { + throw new AssertionError("Non-revealable message clicked."); + } + + if (!ViewOnceUtil.isViewable(messageRecord)) { + int stringRes = messageRecord.isOutgoing() ? R.string.ConversationFragment_outgoing_view_once_media_files_are_automatically_removed + : R.string.ConversationFragment_you_already_viewed_this_message; + Toast.makeText(requireContext(), stringRes, Toast.LENGTH_SHORT).show(); + return; + } + + SimpleTask.run(getLifecycle(), () -> { + Log.i(TAG, "Copying the view-once photo to temp storage and deleting underlying media."); + + try { + Slide thumbnailSlide = messageRecord.getSlideDeck().getThumbnailSlide(); + InputStream inputStream = PartAuthority.getAttachmentStream(requireContext(), thumbnailSlide.getUri()); + Uri tempUri = BlobProvider.getInstance().forData(inputStream, thumbnailSlide.getFileSize()) + .withMimeType(thumbnailSlide.getContentType()) + .createForSingleSessionOnDisk(requireContext()); + + DatabaseFactory.getAttachmentDatabase(requireContext()).deleteAttachmentFilesForViewOnceMessage(messageRecord.getId()); + + ApplicationContext.getInstance(requireContext()) + .getViewOnceMessageManager() + .scheduleIfNecessary(); + + ApplicationDependencies.getJobManager().add(new MultiDeviceViewOnceOpenJob(new MessageDatabase.SyncMessageId(messageRecord.getIndividualRecipient().getId(), messageRecord.getDateSent()))); + + return tempUri; + } catch (IOException e) { + return null; + } + }, (uri) -> { + if (uri != null) { + startActivity(ViewOnceMessageActivity.getIntent(requireContext(), messageRecord.getId(), uri)); + } else { + Log.w(TAG, "Failed to open view-once photo. Showing a toast and deleting the attachments for the message just in case."); + Toast.makeText(requireContext(), R.string.ConversationFragment_failed_to_open_message, Toast.LENGTH_SHORT).show(); + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getAttachmentDatabase(requireContext()).deleteAttachmentFilesForViewOnceMessage(messageRecord.getId())); + } + }); + } + + @Override + public void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView) { + if (getContext() != null && getActivity() != null) { + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), avatarTransitionView, "avatar").toBundle(); + ActivityCompat.startActivity(getActivity(), SharedContactDetailsActivity.getIntent(getContext(), contact), bundle); + } + } + + @Override + public void onAddToContactsClicked(@NonNull Contact contactWithAvatar) { + if (getContext() != null) { + new AsyncTask() { + @Override + protected Intent doInBackground(Void... voids) { + return ContactUtil.buildAddToContactsIntent(getContext(), contactWithAvatar); + } + + @Override + protected void onPostExecute(Intent intent) { + startActivityForResult(intent, CODE_ADD_EDIT_CONTACT); + } + }.execute(); + } + } + + @Override + public void onMessageSharedContactClicked(@NonNull List choices) { + if (getContext() == null) return; + + ContactUtil.selectRecipientThroughDialog(getContext(), choices, locale, recipient -> { + CommunicationActions.startConversation(getContext(), recipient, null); + }); + } + + @Override + public void onInviteSharedContactClicked(@NonNull List choices) { + if (getContext() == null) return; + + ContactUtil.selectRecipientThroughDialog(getContext(), choices, locale, recipient -> { + CommunicationActions.composeSmsThroughDefaultApp(getContext(), recipient, getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))); + }); + } + + @Override + public void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms) { + if (getContext() == null) return; + + listener.handleReactionDetails(reactionTarget); + ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null); + } + + @Override + public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) { + if (getContext() == null) return; + + RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM"); + } + + @Override + public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) { + listener.onMessageWithErrorClicked(messageRecord); + } + + @Override + public void onVoiceNotePause(@NonNull Uri uri) { + voiceNoteMediaController.pausePlayback(uri); + } + + @Override + public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) { + voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress); + } + + @Override + public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) { + voiceNoteMediaController.seekToPosition(uri, progress); + } + + @Override + public void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver); + } + + @Override + public void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver); + } + + @Override + public boolean onUrlClicked(@NonNull String url) { + return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url) || + CommunicationActions.handlePotentialProxyLinkUrl(requireActivity(), url); + } + + @Override + public void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange) { + GroupsV1MigrationInfoBottomSheetDialogFragment.show(requireFragmentManager(), membershipChange); + } + + @Override + public void onDecryptionFailedLearnMoreClicked() { + new AlertDialog.Builder(requireContext()) + .setView(R.layout.decryption_failed_dialog) + .setPositiveButton(android.R.string.ok, (d, w) -> { + d.dismiss(); + }) + .setNeutralButton(R.string.ConversationFragment_contact_us, (d, w) -> { + Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class); + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_HELP_FRAGMENT, true); + + startActivity(intent); + d.dismiss(); + }) + .show(); + } + + @Override + public void onSafetyNumberLearnMoreClicked(@NonNull Recipient recipient) { + if (recipient.isGroup()) { + throw new AssertionError("Must be individual"); + } + + AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setView(R.layout.safety_number_changed_learn_more_dialog) + .setPositiveButton(R.string.ConversationFragment_verify, (d, w) -> { + SimpleTask.run(getLifecycle(), () -> { + return DatabaseFactory.getIdentityDatabase(requireContext()).getIdentity(recipient.getId()); + }, identityRecord -> { + if (identityRecord.isPresent()) { + startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord.get())); + }}); + d.dismiss(); + }) + .setNegativeButton(R.string.ConversationFragment_not_now, (d, w) -> { + d.dismiss(); + }) + .create(); + dialog.setOnShowListener(d -> { + TextView title = Objects.requireNonNull(dialog.findViewById(R.id.safety_number_learn_more_title)); + TextView body = Objects.requireNonNull(dialog.findViewById(R.id.safety_number_learn_more_body)); + + title.setText(getString(R.string.ConversationFragment_your_safety_number_with_s_changed, recipient.getDisplayName(requireContext()))); + body.setText(getString(R.string.ConversationFragment_your_safety_number_with_s_changed_likey_because_they_reinstalled_signal, recipient.getDisplayName(requireContext()))); + }); + + dialog.show(); + } + @Override + public void onJoinGroupCallClicked() { + CommunicationActions.startVideoCall(requireActivity(), recipient.get()); + } + + @Override + public void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId) { + GroupLinkInviteFriendsBottomSheetDialogFragment.show(requireActivity().getSupportFragmentManager(), groupId); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CODE_ADD_EDIT_CONTACT && getContext() != null) { + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } + } + + private void handleEnterMultiSelect(@NonNull ConversationMessage conversationMessage) { + ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + list.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); + } + + private final class ConversationSnapToTopDataObserver extends SnapToTopDataObserver { + + public ConversationSnapToTopDataObserver(@NonNull RecyclerView recyclerView, + @Nullable ScrollRequestValidator scrollRequestValidator) + { + super(recyclerView, scrollRequestValidator, () -> { + list.scrollToPosition(0); + list.post(ConversationFragment.this::postMarkAsReadRequest); + }); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + // Do nothing. + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + if (positionStart == 0 && itemCount == 1 && isTypingIndicatorShowing()) { + return; + } + + super.onItemRangeInserted(positionStart, itemCount); + } + } + + private final class ConversationScrollRequestValidator implements SnapToTopDataObserver.ScrollRequestValidator { + + @Override + public boolean isPositionStillValid(int position) { + if (getListAdapter() == null) { + return position >= 0; + } else { + return position >= 0 && position < getListAdapter().getItemCount(); + } + } + + @Override + public boolean isItemAtPositionLoaded(int position) { + if (getListAdapter() == null) { + return false; + } else if (getListAdapter().hasFooter() && position == getListAdapter().getItemCount() - 1) { + return true; + } else { + return getListAdapter().getItem(position) != null; + } + } + } + + private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener { + + private final ConversationMessage conversationMessage; + + private ReactionsToolbarListener(@NonNull ConversationMessage conversationMessage) { + this.conversationMessage = conversationMessage; + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_info: handleDisplayDetails(conversationMessage); return true; + case R.id.action_delete: handleDeleteMessages(SetUtil.newHashSet(conversationMessage)); return true; + case R.id.action_copy: handleCopyMessage(SetUtil.newHashSet(conversationMessage)); return true; + case R.id.action_reply: handleReplyMessage(conversationMessage); return true; + case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true; + case R.id.action_forward: handleForwardMessage(conversationMessage); return true; + case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true; + default: return false; + } + } + } + + private class ActionModeCallback implements ActionMode.Callback { + + private int statusBarColor; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.conversation_context, menu); + + mode.setTitle("1"); + + if (Build.VERSION.SDK_INT >= 21) { + Window window = getActivity().getWindow(); + statusBarColor = window.getStatusBarColor(); + WindowUtil.setStatusBarColor(window, getResources().getColor(R.color.action_mode_status_bar)); + } + + if (!ThemeUtil.isDarkTheme(getContext())) { + WindowUtil.setLightStatusBar(getActivity().getWindow()); + } + + setCorrectMenuVisibility(menu); + AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth()); + listener.onMessageActionToolbarOpened(); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + ((ConversationAdapter)list.getAdapter()).clearSelection(); + list.getAdapter().notifyDataSetChanged(); + + if (Build.VERSION.SDK_INT >= 21) { + WindowUtil.setStatusBarColor(requireActivity().getWindow(), statusBarColor); + } + + WindowUtil.setLightStatusBarFromTheme(requireActivity()); + actionMode = null; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (actionMode == null) return false; + + switch(item.getItemId()) { + case R.id.menu_context_copy: + handleCopyMessage(getListAdapter().getSelectedItems()); + actionMode.finish(); + return true; + case R.id.menu_context_delete_message: + handleDeleteMessages(getListAdapter().getSelectedItems()); + actionMode.finish(); + return true; + case R.id.menu_context_details: + handleDisplayDetails(getSelectedConversationMessage()); + actionMode.finish(); + return true; + case R.id.menu_context_forward: + handleForwardMessage(getSelectedConversationMessage()); + actionMode.finish(); + return true; + case R.id.menu_context_resend: + handleResendMessage(getSelectedConversationMessage().getMessageRecord()); + actionMode.finish(); + return true; + case R.id.menu_context_save_attachment: + handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord()); + actionMode.finish(); + return true; + case R.id.menu_context_reply: + maybeShowSwipeToReplyTooltip(); + handleReplyMessage(getSelectedConversationMessage()); + actionMode.finish(); + return true; + } + + return false; + } + } + + private static class ConversationDateHeader extends StickyHeaderViewHolder { + + private final Animation animateIn; + private final Animation animateOut; + + private boolean pendingHide = false; + + private ConversationDateHeader(Context context, TextView textView) { + super(textView); + this.animateIn = AnimationUtils.loadAnimation(context, R.anim.slide_from_top); + this.animateOut = AnimationUtils.loadAnimation(context, R.anim.slide_to_top); + + this.animateIn.setDuration(100); + this.animateOut.setDuration(100); + } + + public void show() { + if (textView.getText() == null || textView.getText().length() == 0) { + return; + } + + if (pendingHide) { + pendingHide = false; + } else { + ViewUtil.animateIn(textView, animateIn); + } + } + + public void hide() { + pendingHide = true; + + textView.postDelayed(new Runnable() { + @Override + public void run() { + if (pendingHide) { + pendingHide = false; + ViewUtil.animateOut(textView, animateOut, View.GONE); + } + } + }, 400); + } + } + + private class ShadowScrollListener extends RecyclerView.OnScrollListener { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (recyclerView.canScrollVertically(-1)) { + if (toolbarShadow.getVisibility() != View.VISIBLE) { + ViewUtil.fadeIn(toolbarShadow, 250); + } + } else { + if (toolbarShadow.getVisibility() != View.GONE) { + ViewUtil.fadeOut(toolbarShadow, 250); + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java new file mode 100644 index 00000000..81be4636 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -0,0 +1,307 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class ConversationGroupViewModel extends ViewModel { + + private static final long GV1_MIGRATION_REMINDER_INTERVAL = TimeUnit.DAYS.toMillis(1); + + private final MutableLiveData liveRecipient; + private final LiveData groupActiveState; + private final LiveData selfMembershipLevel; + private final LiveData actionableRequestingMembers; + private final LiveData reviewState; + private final LiveData> gv1MigrationSuggestions; + private final LiveData gv1MigrationReminder; + + private boolean firstTimeInviteFriendsTriggered; + + private ConversationGroupViewModel() { + this.liveRecipient = new MutableLiveData<>(); + + LiveData groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient); + LiveData> duplicates = LiveDataUtil.mapAsync(groupRecord, record -> { + if (record != null && record.isV2Group()) { + return Stream.of(ReviewUtil.getDuplicatedRecipients(record.getId().requireV2())) + .map(ReviewRecipient::getRecipient) + .toList(); + } else { + return Collections.emptyList(); + } + }); + + this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState)); + this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel)); + this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount)); + this.gv1MigrationSuggestions = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationSuggestions)); + this.gv1MigrationReminder = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationReminder)); + this.reviewState = LiveDataUtil.combineLatest(groupRecord, + duplicates, + (record, dups) -> dups.isEmpty() + ? ReviewState.EMPTY + : new ReviewState(record.getId().requireV2(), dups.get(0), dups.size())); + + } + + void onRecipientChange(Recipient recipient) { + liveRecipient.setValue(recipient); + } + + void onSuggestedMembersBannerDismissed(@NonNull GroupId groupId, @NonNull List suggestions) { + SignalExecutors.BOUNDED.execute(() -> { + if (groupId.isV2()) { + DatabaseFactory.getGroupDatabase(ApplicationDependencies.getApplication()).removeUnmigratedV1Members(groupId.requireV2(), suggestions); + liveRecipient.postValue(liveRecipient.getValue()); + } + }); + } + + void onMigrationInitiationReminderBannerDismissed(@NonNull RecipientId recipientId) { + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).markGroupsV1MigrationReminderSeen(recipientId, System.currentTimeMillis()); + liveRecipient.postValue(liveRecipient.getValue()); + }); + } + + /** + * The number of pending group join requests that can be actioned by this client. + */ + LiveData getActionableRequestingMembers() { + return actionableRequestingMembers; + } + + LiveData getGroupActiveState() { + return groupActiveState; + } + + LiveData getSelfMemberLevel() { + return selfMembershipLevel; + } + + public LiveData getReviewState() { + return reviewState; + } + + @NonNull LiveData> getGroupV1MigrationSuggestions() { + return gv1MigrationSuggestions; + } + + @NonNull LiveData getShowGroupsV1MigrationBanner() { + return gv1MigrationReminder; + } + + private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) { + if (recipient != null && recipient.isGroup()) { + Application context = ApplicationDependencies.getApplication(); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + return groupDatabase.getGroup(recipient.getId()).orNull(); + } else { + return null; + } + } + + private static int mapToActionableRequestingMemberCount(@Nullable GroupRecord record) { + if (record != null && + record.isV2Group() && + record.memberLevel(Recipient.self()) == GroupDatabase.MemberLevel.ADMINISTRATOR) + { + return record.requireV2GroupProperties() + .getDecryptedGroup() + .getRequestingMembersCount(); + } else { + return 0; + } + } + + private static GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) { + if (record == null) { + return null; + } + return new GroupActiveState(record.isActive(), record.isV2Group()); + } + + private static GroupDatabase.MemberLevel mapToSelfMembershipLevel(@Nullable GroupRecord record) { + if (record == null) { + return null; + } + return record.memberLevel(Recipient.self()); + } + + @WorkerThread + private static List mapToGroupV1MigrationSuggestions(@Nullable GroupRecord record) { + if (record == null) { + return Collections.emptyList(); + } + + if (!record.isV2Group()) { + return Collections.emptyList(); + } + + if (!record.isActive() || record.isPendingMember(Recipient.self())) { + return Collections.emptyList(); + } + + return Stream.of(record.getUnmigratedV1Members()) + .filterNot(m -> record.getMembers().contains(m)) + .map(Recipient::resolved) + .filter(GroupsV1MigrationUtil::isAutoMigratable) + .map(Recipient::getId) + .toList(); + } + + @WorkerThread + private static boolean mapToGroupV1MigrationReminder(@Nullable GroupRecord record) { + if (record == null || + !record.isV1Group() || + !record.isActive() || + FeatureFlags.groupsV1ForcedMigration() || + Recipient.self().getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED || + !Recipient.resolved(record.getRecipientId()).isProfileSharing()) + { + return false; + } + + boolean canAutoMigrate = Stream.of(Recipient.resolvedList(record.getMembers())) + .allMatch(GroupsV1MigrationUtil::isAutoMigratable); + + if (canAutoMigrate) { + return false; + } + + Context context = ApplicationDependencies.getApplication(); + long lastReminderTime = DatabaseFactory.getRecipientDatabase(context).getGroupsV1MigrationReminderLastSeen(record.getRecipientId()); + + return System.currentTimeMillis() - lastReminderTime > GV1_MIGRATION_REMINDER_INTERVAL; + } + + public static void onCancelJoinRequest(@NonNull Recipient recipient, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + if (!recipient.isPushV2Group()) { + throw new AssertionError(); + } + + try { + GroupManager.cancelJoinRequest(ApplicationDependencies.getApplication(), recipient.getGroupId().get().requireV2()); + callback.onComplete(null); + } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void inviteFriendsOneTimeIfJustSelfInGroup(@NonNull FragmentManager supportFragmentManager, @NonNull GroupId.V2 groupId) { + if (firstTimeInviteFriendsTriggered) { + return; + } + + firstTimeInviteFriendsTriggered = true; + + SimpleTask.run(() -> DatabaseFactory.getGroupDatabase(ApplicationDependencies.getApplication()) + .requireGroup(groupId) + .getMembers().equals(Collections.singletonList(Recipient.self().getId())), + justSelf -> { + if (justSelf) { + inviteFriends(supportFragmentManager, groupId); + } + } + ); + } + + void inviteFriends(@NonNull FragmentManager supportFragmentManager, @NonNull GroupId.V2 groupId) { + GroupLinkInviteFriendsBottomSheetDialogFragment.show(supportFragmentManager, groupId); + } + + static final class ReviewState { + + private static final ReviewState EMPTY = new ReviewState(null, Recipient.UNKNOWN, 0); + + private final GroupId.V2 groupId; + private final Recipient recipient; + private final int count; + + ReviewState(@Nullable GroupId.V2 groupId, @NonNull Recipient recipient, int count) { + this.groupId = groupId; + this.recipient = recipient; + this.count = count; + } + + public @Nullable GroupId.V2 getGroupId() { + return groupId; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public int getCount() { + return count; + } + } + + static final class GroupActiveState { + private final boolean isActive; + private final boolean isActiveV2; + + public GroupActiveState(boolean isActive, boolean isV2) { + this.isActive = isActive; + this.isActiveV2 = isActive && isV2; + } + + public boolean isActiveGroup() { + return isActive; + } + + public boolean isActiveV2Group() { + return isActiveV2; + } + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ConversationGroupViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java new file mode 100644 index 00000000..16326495 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -0,0 +1,287 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public class ConversationIntents { + + private static final String BUBBLE_AUTHORITY = "bubble"; + private static final String EXTRA_RECIPIENT = "recipient_id"; + private static final String EXTRA_THREAD_ID = "thread_id"; + private static final String EXTRA_TEXT = "draft_text"; + private static final String EXTRA_MEDIA = "media_list"; + private static final String EXTRA_STICKER = "sticker_extra"; + private static final String EXTRA_BORDERLESS = "borderless_extra"; + private static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; + private static final String EXTRA_STARTING_POSITION = "starting_position"; + private static final String EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP = "first_time_in_group"; + + private ConversationIntents() { + } + + public static @NonNull Builder createBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + return new Builder(context, recipientId, threadId); + } + + public static @NonNull Builder createPopUpBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + return new Builder(context, ConversationPopupActivity.class, recipientId, threadId); + } + + public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + return new Builder(context, BubbleConversationActivity.class, recipientId, threadId).build(); + } + + static boolean isInvalid(@NonNull Intent intent) { + if (isBubbleIntent(intent)) { + return intent.getData().getQueryParameter(EXTRA_RECIPIENT) == null; + } else { + return !intent.hasExtra(EXTRA_RECIPIENT); + } + } + + private static boolean isBubbleIntent(@NonNull Intent intent) { + return intent.getData() != null && Objects.equals(intent.getData().getAuthority(), BUBBLE_AUTHORITY); + } + + final static class Args { + private final RecipientId recipientId; + private final long threadId; + private final String draftText; + private final ArrayList media; + private final StickerLocator stickerLocator; + private final boolean isBorderless; + private final int distributionType; + private final int startingPosition; + private final boolean firstTimeInSelfCreatedGroup; + + static Args from(@NonNull Intent intent) { + if (isBubbleIntent(intent)) { + return new Args(RecipientId.from(intent.getData().getQueryParameter(EXTRA_RECIPIENT)), + Long.parseLong(intent.getData().getQueryParameter(EXTRA_THREAD_ID)), + null, + null, + null, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + -1, + false); + } + + return new Args(RecipientId.from(Objects.requireNonNull(intent.getStringExtra(EXTRA_RECIPIENT))), + intent.getLongExtra(EXTRA_THREAD_ID, -1), + intent.getStringExtra(EXTRA_TEXT), + intent.getParcelableArrayListExtra(EXTRA_MEDIA), + intent.getParcelableExtra(EXTRA_STICKER), + intent.getBooleanExtra(EXTRA_BORDERLESS, false), + intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, ThreadDatabase.DistributionTypes.DEFAULT), + intent.getIntExtra(EXTRA_STARTING_POSITION, -1), + intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false)); + } + + private Args(@NonNull RecipientId recipientId, + long threadId, + @Nullable String draftText, + @Nullable ArrayList media, + @Nullable StickerLocator stickerLocator, + boolean isBorderless, + int distributionType, + int startingPosition, + boolean firstTimeInSelfCreatedGroup) + { + this.recipientId = recipientId; + this.threadId = threadId; + this.draftText = draftText; + this.media = media; + this.stickerLocator = stickerLocator; + this.isBorderless = isBorderless; + this.distributionType = distributionType; + this.startingPosition = startingPosition; + this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public long getThreadId() { + return threadId; + } + + public @Nullable String getDraftText() { + return draftText; + } + + public @Nullable ArrayList getMedia() { + return media; + } + + public @Nullable StickerLocator getStickerLocator() { + return stickerLocator; + } + + public int getDistributionType() { + return distributionType; + } + + public int getStartingPosition() { + return startingPosition; + } + + public boolean isBorderless() { + return isBorderless; + } + + public boolean isFirstTimeInSelfCreatedGroup() { + return firstTimeInSelfCreatedGroup; + } + + public @Nullable ChatWallpaper getWallpaper() { + // TODO [greyson][wallpaper] Is it worth it to do this beforehand? + return Recipient.resolved(recipientId).getWallpaper(); + } + } + + public final static class Builder { + private final Context context; + private final Class conversationActivityClass; + private final RecipientId recipientId; + private final long threadId; + + private String draftText; + private List media; + private StickerLocator stickerLocator; + private boolean isBorderless; + private int distributionType = ThreadDatabase.DistributionTypes.DEFAULT; + private int startingPosition = -1; + private Uri dataUri; + private String dataType; + private boolean firstTimeInSelfCreatedGroup; + + private Builder(@NonNull Context context, + @NonNull RecipientId recipientId, + long threadId) + { + this(context, ConversationActivity.class, recipientId, threadId); + } + + private Builder(@NonNull Context context, + @NonNull Class conversationActivityClass, + @NonNull RecipientId recipientId, + long threadId) + { + this.context = context; + this.conversationActivityClass = conversationActivityClass; + this.recipientId = recipientId; + this.threadId = threadId; + } + + public @NonNull Builder withDraftText(@Nullable String draftText) { + this.draftText = draftText; + return this; + } + + public @NonNull Builder withMedia(@Nullable Collection media) { + this.media = media != null ? new ArrayList<>(media) : null; + return this; + } + + public @NonNull Builder withStickerLocator(@Nullable StickerLocator stickerLocator) { + this.stickerLocator = stickerLocator; + return this; + } + + public @NonNull Builder asBorderless(boolean isBorderless) { + this.isBorderless = isBorderless; + return this; + } + + public @NonNull Builder withDistributionType(int distributionType) { + this.distributionType = distributionType; + return this; + } + + public @NonNull Builder withStartingPosition(int startingPosition) { + this.startingPosition = startingPosition; + return this; + } + + public @NonNull Builder withDataUri(@Nullable Uri dataUri) { + this.dataUri = dataUri; + return this; + } + + public @NonNull Builder withDataType(@Nullable String dataType) { + this.dataType = dataType; + return this; + } + + public Builder firstTimeInSelfCreatedGroup() { + this.firstTimeInSelfCreatedGroup = true; + return this; + } + + public @NonNull Intent build() { + if (stickerLocator != null && media != null) { + throw new IllegalStateException("Cannot have both sticker and media array"); + } + + Intent intent = new Intent(context, conversationActivityClass); + + intent.setAction(Intent.ACTION_DEFAULT); + + if (Objects.equals(conversationActivityClass, BubbleConversationActivity.class)) { + intent.setData(new Uri.Builder().authority(BUBBLE_AUTHORITY) + .appendQueryParameter(EXTRA_RECIPIENT, recipientId.serialize()) + .appendQueryParameter(EXTRA_THREAD_ID, String.valueOf(threadId)) + .build()); + + return intent; + } + + intent.putExtra(EXTRA_RECIPIENT, recipientId.serialize()); + intent.putExtra(EXTRA_THREAD_ID, threadId); + intent.putExtra(EXTRA_DISTRIBUTION_TYPE, distributionType); + intent.putExtra(EXTRA_STARTING_POSITION, startingPosition); + intent.putExtra(EXTRA_BORDERLESS, isBorderless); + intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup); + + if (draftText != null) { + intent.putExtra(EXTRA_TEXT, draftText); + } + + if (media != null) { + intent.putParcelableArrayListExtra(EXTRA_MEDIA, new ArrayList<>(media)); + } + + if (stickerLocator != null) { + intent.putExtra(EXTRA_STICKER, stickerLocator); + } + + if (dataUri != null && dataType != null) { + intent.setDataAndType(dataUri, dataType); + } else if (dataUri != null) { + intent.setData(dataUri); + } else if (dataType != null) { + intent.setType(dataType); + } + + return intent; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java new file mode 100644 index 00000000..ecd810a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -0,0 +1,1678 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversation; + +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.net.Uri; +import android.text.Annotation; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.BackgroundColorSpan; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleOwner; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BindableConversationItem; +import org.thoughtcrime.securesms.ConfirmIdentityDialog; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.components.AlertView; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.BorderlessImageView; +import org.thoughtcrime.securesms.components.ConversationItemFooter; +import org.thoughtcrime.securesms.components.ConversationItemThumbnail; +import org.thoughtcrime.securesms.components.DocumentView; +import org.thoughtcrime.securesms.components.LinkPreviewView; +import org.thoughtcrime.securesms.components.Outliner; +import org.thoughtcrime.securesms.components.QuoteView; +import org.thoughtcrime.securesms.components.SharedContactView; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.Quote; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; +import org.thoughtcrime.securesms.jobs.MmsDownloadJob; +import org.thoughtcrime.securesms.jobs.MmsSendJob; +import org.thoughtcrime.securesms.jobs.SmsSendJob; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.mms.TextSlide; +import org.thoughtcrime.securesms.reactions.ReactionsConversationView; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.revealable.ViewOnceMessageView; +import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.stickers.StickerUrl; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan; +import org.thoughtcrime.securesms.util.LongClickMovementMethod; +import org.thoughtcrime.securesms.util.SearchUtil; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.UrlClickHandler; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.VibrateUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.Stub; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme; + +/** + * A view that displays an individual conversation item within a conversation + * thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter. + * + * @author Moxie Marlinspike + * + */ + +public final class ConversationItem extends RelativeLayout implements BindableConversationItem, + RecipientForeverObserver +{ + private static final String TAG = ConversationItem.class.getSimpleName(); + + private static final int MAX_MEASURE_CALLS = 3; + private static final int MAX_BODY_DISPLAY_LENGTH = 1000; + + private static final Rect SWIPE_RECT = new Rect(); + + private ConversationMessage conversationMessage; + private MessageRecord messageRecord; + private Locale locale; + private boolean groupThread; + private LiveRecipient recipient; + private GlideRequests glideRequests; + private ValueAnimator pulseOutlinerAlphaAnimator; + + protected ConversationItemBodyBubble bodyBubble; + protected View reply; + protected View replyIcon; + @Nullable protected ViewGroup contactPhotoHolder; + @Nullable private QuoteView quoteView; + private EmojiTextView bodyText; + private ConversationItemFooter footer; + private ConversationItemFooter stickerFooter; + @Nullable private TextView groupSender; + @Nullable private View groupSenderHolder; + private AvatarImageView contactPhoto; + private AlertView alertView; + protected ReactionsConversationView reactionsView; + + private @NonNull Set batchSelected = new HashSet<>(); + private @NonNull Outliner outliner = new Outliner(); + private @NonNull Outliner pulseOutliner = new Outliner(); + private @NonNull List outliners = new ArrayList<>(2); + private LiveRecipient conversationRecipient; + private Stub mediaThumbnailStub; + private Stub audioViewStub; + private Stub documentViewStub; + private Stub sharedContactStub; + private Stub linkPreviewStub; + private Stub stickerStub; + private Stub revealableStub; + private @Nullable EventListener eventListener; + + private int defaultBubbleColor; + private int defaultBubbleColorForWallpaper; + private int measureCalls; + + private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); + private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); + private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); + private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); + private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); + private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); + private final UrlClickListener urlClickListener = new UrlClickListener(); + + private final Context context; + + public ConversationItem(Context context) { + this(context, null); + } + + public ConversationItem(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + } + + @Override + public void setOnClickListener(OnClickListener l) { + super.setOnClickListener(new ClickListener(l)); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + initializeAttributes(); + + this.bodyText = findViewById(R.id.conversation_item_body); + this.footer = findViewById(R.id.conversation_item_footer); + this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer); + this.groupSender = findViewById(R.id.group_message_sender); + this.alertView = findViewById(R.id.indicators_parent); + this.contactPhoto = findViewById(R.id.contact_photo); + this.contactPhotoHolder = findViewById(R.id.contact_photo_container); + this.bodyBubble = findViewById(R.id.body_bubble); + this.mediaThumbnailStub = new Stub<>(findViewById(R.id.image_view_stub)); + this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); + this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); + this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub)); + this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub)); + this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); + this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub)); + this.groupSenderHolder = findViewById(R.id.group_sender_holder); + this.quoteView = findViewById(R.id.quote_view); + this.reply = findViewById(R.id.reply_icon_wrapper); + this.replyIcon = findViewById(R.id.reply_icon); + this.reactionsView = findViewById(R.id.reactions_view); + + setOnClickListener(new ClickListener(null)); + + bodyText.setOnLongClickListener(passthroughClickListener); + bodyText.setOnClickListener(passthroughClickListener); + } + + @Override + public void bind(@NonNull LifecycleOwner lifecycleOwner, + @NonNull ConversationMessage conversationMessage, + @NonNull Optional previousMessageRecord, + @NonNull Optional nextMessageRecord, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set batchSelected, + @NonNull Recipient conversationRecipient, + @Nullable String searchQuery, + boolean pulse, + boolean hasWallpaper, + boolean isMessageRequestAccepted) + { + if (this.recipient != null) this.recipient.removeForeverObserver(this); + if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this); + + conversationRecipient = conversationRecipient.resolve(); + + this.conversationMessage = conversationMessage; + this.messageRecord = conversationMessage.getMessageRecord(); + this.locale = locale; + this.glideRequests = glideRequests; + this.batchSelected = batchSelected; + this.conversationRecipient = conversationRecipient.live(); + this.groupThread = conversationRecipient.isGroup(); + this.recipient = messageRecord.getIndividualRecipient().live(); + + this.recipient.observeForever(this); + this.conversationRecipient.observeForever(this); + + setGutterSizes(messageRecord, groupThread); + setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread); + setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted); + setBodyText(messageRecord, searchQuery, isMessageRequestAccepted); + setBubbleState(messageRecord, hasWallpaper); + setInteractionState(conversationMessage, pulse); + setStatusIcons(messageRecord, hasWallpaper); + setContactPhoto(recipient.get()); + setGroupMessageStatus(messageRecord, recipient.get()); + setGroupAuthorColor(messageRecord, hasWallpaper); + setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper); + setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread); + setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread); + setReactions(messageRecord); + setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper); + } + + @Override + protected void onDetachedFromWindow() { + ConversationSwipeAnimationHelper.update(this, 0f, 1f); + unbind(); + super.onDetachedFromWindow(); + } + + @Override + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + } + + public boolean disallowSwipe(float downX, float downY) { + if (!hasAudio(messageRecord)) return false; + + audioViewStub.get().getSeekBarGlobalVisibleRect(SWIPE_RECT); + return SWIPE_RECT.contains((int) downX, (int) downY); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (isInEditMode()) { + return; + } + + boolean needsMeasure = false; + + if (hasQuote(messageRecord)) { + if (quoteView == null) { + throw new AssertionError(); + } + int quoteWidth = quoteView.getMeasuredWidth(); + int availableWidth = getAvailableMessageBubbleWidth(quoteView); + + if (quoteWidth != availableWidth) { + quoteView.getLayoutParams().width = availableWidth; + needsMeasure = true; + } + } + + if (hasSharedContact(messageRecord)) { + int contactWidth = sharedContactStub.get().getMeasuredWidth(); + int availableWidth = getAvailableMessageBubbleWidth(sharedContactStub.get()); + + if (contactWidth != availableWidth) { + sharedContactStub.get().getLayoutParams().width = availableWidth; + needsMeasure = true; + } + } + + if (!hasNoBubble(messageRecord)) { + ConversationItemFooter activeFooter = getActiveFooter(messageRecord); + int availableWidth = getAvailableMessageBubbleWidth(footer); + + if (activeFooter.getVisibility() != GONE && activeFooter.getMeasuredWidth() != availableWidth) { + activeFooter.getLayoutParams().width = availableWidth; + needsMeasure = true; + } + } + + if (needsMeasure) { + if (measureCalls < MAX_MEASURE_CALLS) { + measureCalls++; + measure(widthMeasureSpec, heightMeasureSpec); + } else { + Log.w(TAG, "Hit measure() cap of " + MAX_MEASURE_CALLS); + } + } else { + measureCalls = 0; + } + } + + @Override + public void onRecipientChanged(@NonNull Recipient modified) { + setBubbleState(messageRecord, modified.hasWallpaper()); + if (recipient.getId().equals(modified.getId())) { + setContactPhoto(modified); + setGroupMessageStatus(messageRecord, modified); + } + } + + private int getAvailableMessageBubbleWidth(@NonNull View forView) { + int availableWidth; + if (hasAudio(messageRecord)) { + availableWidth = audioViewStub.get().getMeasuredWidth() + ViewUtil.getLeftMargin(audioViewStub.get()) + ViewUtil.getRightMargin(audioViewStub.get()); + } else if (!isViewOnceMessage(messageRecord) && (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord))) { + availableWidth = mediaThumbnailStub.get().getMeasuredWidth(); + } else { + availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight(); + } + + availableWidth -= ViewUtil.getLeftMargin(forView) + ViewUtil.getRightMargin(forView); + + return availableWidth; + } + + private void initializeAttributes() { + defaultBubbleColor = ContextCompat.getColor(context, R.color.signal_background_secondary); + defaultBubbleColorForWallpaper = ContextCompat.getColor(context, R.color.conversation_item_wallpaper_bubble_color); + } + + private @ColorInt int getDefaultBubbleColor(boolean hasWallpaper) { + return hasWallpaper ? defaultBubbleColorForWallpaper : defaultBubbleColor; + } + + @Override + public void unbind() { + if (recipient != null) { + recipient.removeForeverObserver(this); + } + if (conversationRecipient != null) { + conversationRecipient.removeForeverObserver(this); + } + cancelPulseOutlinerAnimation(); + } + + public ConversationMessage getConversationMessage() { + return conversationMessage; + } + + /// MessageRecord Attribute Parsers + + private void setBubbleState(MessageRecord messageRecord, boolean hasWallpaper) { + if (messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) { + bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY); + footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary)); + footer.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary)); + footer.setOnlyShowSendingStatus(false, messageRecord); + } else if (messageRecord.isRemoteDelete() || (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord))) { + if (hasWallpaper) { + bodyBubble.getBackground().setColorFilter(ContextCompat.getColor(context, R.color.wallpaper_bubble_color), PorterDuff.Mode.SRC_IN); + } else { + bodyBubble.getBackground().setColorFilter(ContextCompat.getColor(context, R.color.signal_background_primary), PorterDuff.Mode.MULTIPLY); + footer.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary)); + } + footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary)); + footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord); + } else { + bodyBubble.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY); + footer.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color)); + footer.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color)); + footer.setOnlyShowSendingStatus(false, messageRecord); + } + + outliner.setColor(ContextCompat.getColor(context, R.color.signal_text_secondary)); + + pulseOutliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent)); + pulseOutliner.setStrokeWidth(ViewUtil.dpToPx(4)); + + outliners.clear(); + if (shouldDrawBodyBubbleOutline(messageRecord, hasWallpaper)) { + outliners.add(outliner); + } + outliners.add(pulseOutliner); + + bodyBubble.setOutliners(outliners); + + if (mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().setPulseOutliner(pulseOutliner); + } + + if (audioViewStub.resolved()) { + setAudioViewTint(messageRecord); + } + + if (hasWallpaper) { + replyIcon.setBackgroundResource(R.drawable.wallpaper_message_decoration_background); + } else { + replyIcon.setBackground(null); + } + } + + private void setAudioViewTint(MessageRecord messageRecord) { + if (hasAudio(messageRecord)) { + if (messageRecord.isOutgoing()) { + if (DynamicTheme.isDarkTheme(context)) { + audioViewStub.get().setTint(Color.WHITE); + } else { + audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60)); + } + } else { + audioViewStub.get().setTint(Color.WHITE); + } + } + } + + private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) { + if (batchSelected.contains(conversationMessage)) { + setBackgroundResource(R.drawable.conversation_item_background); + setSelected(true); + } else if (pulseMention) { + setBackground(null); + setSelected(false); + startPulseOutlinerAnimation(); + } else { + setSelected(false); + } + + if (mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty()); + mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty()); + mediaThumbnailStub.get().setLongClickable(batchSelected.isEmpty()); + } + + if (audioViewStub.resolved()) { + audioViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty()); + audioViewStub.get().setClickable(batchSelected.isEmpty()); + audioViewStub.get().setEnabled(batchSelected.isEmpty()); + } + + if (documentViewStub.resolved()) { + documentViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty()); + documentViewStub.get().setClickable(batchSelected.isEmpty()); + } + } + + private void startPulseOutlinerAnimation() { + pulseOutlinerAlphaAnimator = ValueAnimator.ofInt(0, 0x66, 0).setDuration(600); + pulseOutlinerAlphaAnimator.addUpdateListener(animator -> { + pulseOutliner.setAlpha((Integer) animator.getAnimatedValue()); + bodyBubble.invalidate(); + + if (mediaThumbnailStub.resolved()) { + mediaThumbnailStub.get().invalidate(); + } + }); + pulseOutlinerAlphaAnimator.start(); + } + + private void cancelPulseOutlinerAnimation() { + if (pulseOutlinerAlphaAnimator != null) { + pulseOutlinerAlphaAnimator.cancel(); + pulseOutlinerAlphaAnimator = null; + } + + pulseOutliner.setAlpha(0); + } + + private boolean shouldDrawBodyBubbleOutline(MessageRecord messageRecord, boolean hasWallpaper) { + if (hasWallpaper) { + return false; + } else { + boolean isIncomingViewedOnce = !messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord); + return isIncomingViewedOnce || messageRecord.isRemoteDelete(); + } + } + + private boolean isCaptionlessMms(MessageRecord messageRecord) { + return TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getSlideDeck().getTextSlide() == null; + } + + private boolean hasAudio(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null; + } + + private boolean hasThumbnail(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null; + } + + private boolean hasSticker(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null; + } + + private boolean isBorderless(MessageRecord messageRecord) { + //noinspection ConstantConditions + return isCaptionlessMms(messageRecord) && + hasThumbnail(messageRecord) && + ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide().isBorderless(); + } + + private boolean hasNoBubble(MessageRecord messageRecord) { + return hasSticker(messageRecord) || isBorderless(messageRecord); + } + + private boolean hasOnlyThumbnail(MessageRecord messageRecord) { + return hasThumbnail(messageRecord) && + !hasAudio(messageRecord) && + !hasDocument(messageRecord) && + !hasSharedContact(messageRecord) && + !hasSticker(messageRecord) && + !isBorderless(messageRecord) && + !isViewOnceMessage(messageRecord); + } + + private boolean hasDocument(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide() != null; + } + + private boolean hasExtraText(MessageRecord messageRecord) { + boolean hasTextSlide = messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getTextSlide() != null; + boolean hasOverflowText = messageRecord.getBody().length() > MAX_BODY_DISPLAY_LENGTH; + + return hasTextSlide || hasOverflowText; + } + + private boolean hasQuote(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getQuote() != null; + } + + private boolean hasSharedContact(MessageRecord messageRecord) { + return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty(); + } + + private boolean hasLinkPreview(MessageRecord messageRecord) { + return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty(); + } + + private boolean hasBigImageLinkPreview(MessageRecord messageRecord) { + if (!hasLinkPreview(messageRecord)) { + return false; + } + + LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0); + + if (linkPreview.getThumbnail().isPresent() && !Util.isEmpty(linkPreview.getDescription())) { + return true; + } + + int minWidth = getResources().getDimensionPixelSize(R.dimen.media_bubble_min_width_solo); + + return linkPreview.getThumbnail().isPresent() && + linkPreview.getThumbnail().get().getWidth() >= minWidth && + !StickerUrl.isValidShareLink(linkPreview.getUrl()); + } + + private boolean isViewOnceMessage(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord) messageRecord).isViewOnce(); + } + + private void setBodyText(@NonNull MessageRecord messageRecord, + @Nullable String searchQuery, + boolean messageRequestAccepted) + { + bodyText.setClickable(false); + bodyText.setFocusable(false); + bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context)); + bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext())); + + if (messageRecord.isRemoteDelete()) { + String deletedMessage = context.getString(messageRecord.isOutgoing() ? R.string.ConversationItem_you_deleted_this_message : R.string.ConversationItem_this_message_was_deleted); + SpannableString italics = new SpannableString(deletedMessage); + italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + italics.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, R.color.signal_text_primary)), + 0, + deletedMessage.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + bodyText.setText(italics); + bodyText.setVisibility(View.VISIBLE); + bodyText.setOverflowText(null); + } else if (isCaptionlessMms(messageRecord)) { + bodyText.setVisibility(View.GONE); + } else { + Spannable styledText = conversationMessage.getDisplayBody(getContext()); + if (messageRequestAccepted) { + linkifyMessageBody(styledText, batchSelected.isEmpty()); + } + styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery); + styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery); + + if (hasExtraText(messageRecord)) { + bodyText.setOverflowText(getLongMessageSpan(messageRecord)); + } else { + bodyText.setOverflowText(null); + } + + if (messageRecord.isOutgoing()) { + bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20)); + } else { + bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_40)); + } + + bodyText.setText(StringUtil.trim(styledText)); + bodyText.setVisibility(View.VISIBLE); + } + } + + private void setMediaAttributes(@NonNull MessageRecord messageRecord, + @NonNull Optional previousRecord, + @NonNull Optional nextRecord, + boolean isGroupThread, + boolean hasWallpaper, + boolean messageRequestAccepted) + { + boolean showControls = !messageRecord.isFailed(); + + if (eventListener != null && audioViewStub.resolved()) { + Log.d(TAG, "setMediaAttributes: unregistering voice note callbacks for audio slide " + audioViewStub.get().getAudioSlideUri()); + eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); + } + + if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) { + revealableStub.get().setVisibility(VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + + revealableStub.get().setMessage((MmsMessageRecord) messageRecord); + revealableStub.get().setOnClickListener(revealableClickListener); + revealableStub.get().setOnLongClickListener(passthroughClickListener); + + footer.setVisibility(VISIBLE); + } else if (hasSharedContact(messageRecord)) { + sharedContactStub.get().setVisibility(VISIBLE); + if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale); + sharedContactStub.get().setEventListener(sharedContactEventListener); + sharedContactStub.get().setOnClickListener(sharedContactClickListener); + sharedContactStub.get().setOnLongClickListener(passthroughClickListener); + + setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(GONE); + } else if (hasLinkPreview(messageRecord) && messageRequestAccepted) { + linkPreviewStub.get().setVisibility(View.VISIBLE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + //noinspection ConstantConditions + LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0); + + if (hasBigImageLinkPreview(messageRecord)) { + mediaThumbnailStub.get().setVisibility(VISIBLE); + mediaThumbnailStub.get().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content)); + mediaThumbnailStub.get().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false); + mediaThumbnailStub.get().setThumbnailClickListener(new LinkPreviewThumbnailClickListener()); + mediaThumbnailStub.get().setDownloadClickListener(downloadClickListener); + mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener); + + linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false); + + setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread); + setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, true); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } else { + linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true); + linkPreviewStub.get().setDownloadClickedListener(downloadClickListener); + setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false); + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + linkPreviewStub.get().setOnClickListener(linkPreviewClickListener); + linkPreviewStub.get().setOnLongClickListener(passthroughClickListener); + + + footer.setVisibility(VISIBLE); + } else if (hasAudio(messageRecord)) { + audioViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, false); + audioViewStub.get().setDownloadClickListener(singleDownloadClickListener); + audioViewStub.get().setOnLongClickListener(passthroughClickListener); + + if (eventListener != null) { + Log.d(TAG, "setMediaAttributes: registered listener for audio slide " + audioViewStub.get().getAudioSlideUri()); + eventListener.onRegisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); + } else { + Log.w(TAG, "setMediaAttributes: could not register listener for audio slide " + audioViewStub.get().getAudioSlideUri()); + } + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } else if (hasDocument(messageRecord)) { + documentViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + //noinspection ConstantConditions + documentViewStub.get().setDocument(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(), showControls); + documentViewStub.get().setDocumentClickListener(new ThumbnailClickListener()); + documentViewStub.get().setDownloadClickListener(singleDownloadClickListener); + documentViewStub.get().setOnLongClickListener(passthroughClickListener); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } else if ((hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) || isBorderless(messageRecord)) { + bodyBubble.setBackgroundColor(Color.TRANSPARENT); + + stickerStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + if (hasSticker(messageRecord)) { + //noinspection ConstantConditions + stickerStub.get().setSlide(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide()); + stickerStub.get().setThumbnailClickListener(new StickerClickListener()); + } else { + //noinspection ConstantConditions + stickerStub.get().setSlide(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlide()); + stickerStub.get().setThumbnailClickListener((v, slide) -> performClick()); + } + + stickerStub.get().setDownloadClickListener(downloadClickListener); + stickerStub.get().setOnLongClickListener(passthroughClickListener); + stickerStub.get().setOnClickListener(passthroughClickListener); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } else if (hasThumbnail(messageRecord)) { + mediaThumbnailStub.get().setVisibility(View.VISIBLE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + List thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides(); + mediaThumbnailStub.get().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo + : R.dimen.media_bubble_min_width_with_content)); + mediaThumbnailStub.get().setImageResource(glideRequests, + thumbnailSlides, + showControls, + false); + mediaThumbnailStub.get().setThumbnailClickListener(new ThumbnailClickListener()); + mediaThumbnailStub.get().setDownloadClickListener(downloadClickListener); + mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener); + mediaThumbnailStub.get().setOnClickListener(passthroughClickListener); + mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord)); + mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? getDefaultBubbleColor(hasWallpaper) + : messageRecord.getRecipient().getColor().toConversationColor(context)); + mediaThumbnailStub.get().setBorderless(false); + + setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } else { + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE); + if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + footer.setVisibility(VISIBLE); + } + } + + private void setThumbnailCorners(@NonNull MessageRecord current, + @NonNull Optional previous, + @NonNull Optional next, + boolean isGroupThread) + { + int defaultRadius = readDimen(R.dimen.message_corner_radius); + int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius); + + int topLeft = defaultRadius; + int topRight = defaultRadius; + int bottomLeft = defaultRadius; + int bottomRight = defaultRadius; + + if (isSingularMessage(current, previous, next, isGroupThread)) { + topLeft = defaultRadius; + topRight = defaultRadius; + bottomLeft = defaultRadius; + bottomRight = defaultRadius; + } else if (isStartOfMessageCluster(current, previous, isGroupThread)) { + if (current.isOutgoing()) { + bottomRight = collapseRadius; + } else { + bottomLeft = collapseRadius; + } + } else if (isEndOfMessageCluster(current, next, isGroupThread)) { + if (current.isOutgoing()) { + topRight = collapseRadius; + } else { + topLeft = collapseRadius; + } + } else { + if (current.isOutgoing()) { + topRight = collapseRadius; + bottomRight = collapseRadius; + } else { + topLeft = collapseRadius; + bottomLeft = collapseRadius; + } + } + + if (!TextUtils.isEmpty(current.getDisplayBody(getContext()))) { + bottomLeft = 0; + bottomRight = 0; + } + + if (isStartOfMessageCluster(current, previous, isGroupThread) && !current.isOutgoing() && isGroupThread) { + topLeft = 0; + topRight = 0; + } + + if (hasQuote(messageRecord)) { + topLeft = 0; + topRight = 0; + } + + if (hasLinkPreview(messageRecord) || hasExtraText(messageRecord)) { + bottomLeft = 0; + bottomRight = 0; + } + + mediaThumbnailStub.get().setCorners(topLeft, topRight, bottomRight, bottomLeft); + } + + private void setSharedContactCorners(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + if (TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))){ + if (isSingularMessage(current, previous, next, isGroupThread) || isEndOfMessageCluster(current, next, isGroupThread)) { + sharedContactStub.get().setSingularStyle(); + } else if (current.isOutgoing()) { + sharedContactStub.get().setClusteredOutgoingStyle(); + } else { + sharedContactStub.get().setClusteredIncomingStyle(); + } + } + } + + private void setLinkPreviewCorners(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread, boolean bigImage) { + int defaultRadius = readDimen(R.dimen.message_corner_radius); + int collapseRadius = readDimen(R.dimen.message_corner_collapse_radius); + + if (bigImage) { + linkPreviewStub.get().setCorners(0, 0); + } else if (isStartOfMessageCluster(current, previous, isGroupThread) && !current.isOutgoing() && isGroupThread) { + linkPreviewStub.get().setCorners(0, 0); + } else if (isSingularMessage(current, previous, next, isGroupThread) || isStartOfMessageCluster(current, previous, isGroupThread)) { + linkPreviewStub.get().setCorners(defaultRadius, defaultRadius); + } else if (current.isOutgoing()) { + linkPreviewStub.get().setCorners(defaultRadius, collapseRadius); + } else { + linkPreviewStub.get().setCorners(collapseRadius, defaultRadius); + } + } + + private void setContactPhoto(@NonNull Recipient recipient) { + if (contactPhoto == null) return; + + final RecipientId recipientId = recipient.getId(); + + contactPhoto.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId()); + } + }); + + contactPhoto.setAvatar(glideRequests, recipient, false); + } + + private void linkifyMessageBody(@NonNull Spannable messageBody, + boolean shouldLinkifyAllLinks) + { + int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS; + boolean hasLinks = Linkify.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0); + + if (hasLinks) { + Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class)) + .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) + .forEach(messageBody::removeSpan); + + URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class); + + for (URLSpan urlSpan : urlSpans) { + int start = messageBody.getSpanStart(urlSpan); + int end = messageBody.getSpanEnd(urlSpan); + URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickListener); + messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody); + for (Annotation annotation : mentionAnnotations) { + messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + private void setStatusIcons(MessageRecord messageRecord, boolean hasWallpaper) { + bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0); + + if (messageRecord.isFailed()) { + alertView.setFailed(); + } else if (messageRecord.isPendingInsecureSmsFallback()) { + alertView.setPendingApproval(); + } else { + alertView.setNone(); + } + + if (hasWallpaper) { + alertView.setBackgroundResource(R.drawable.wallpaper_message_decoration_background); + } else { + alertView.setBackground(null); + } + } + + private void setQuote(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) { + if (quoteView == null) { + throw new AssertionError(); + } + Quote quote = ((MediaMmsMessageRecord)current).getQuote(); + //noinspection ConstantConditions + quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment()); + quoteView.setVisibility(View.VISIBLE); + quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; + + quoteView.setOnClickListener(view -> { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onQuoteClicked((MmsMessageRecord) current); + } else { + passthroughClickListener.onClick(view); + } + }); + + quoteView.setOnLongClickListener(passthroughClickListener); + + if (isStartOfMessageCluster(current, previous, isGroupThread)) { + if (current.isOutgoing()) { + quoteView.setTopCornerSizes(true, true); + } else if (isGroupThread) { + quoteView.setTopCornerSizes(false, false); + } else { + quoteView.setTopCornerSizes(true, true); + } + } else if (!isSingularMessage(current, previous, next, isGroupThread)) { + if (current.isOutgoing()) { + quoteView.setTopCornerSizes(true, false); + } else { + quoteView.setTopCornerSizes(false, true); + } + } + + if (mediaThumbnailStub.resolved()) { + ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding)); + } + } else { + if (quoteView != null) { + quoteView.dismiss(); + } + + if (mediaThumbnailStub.resolved()) { + ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0); + } + } + } + + private void setGutterSizes(@NonNull MessageRecord current, boolean isGroupThread) { + if (isGroupThread && current.isOutgoing()) { + ViewUtil.setPaddingStart(this, readDimen(R.dimen.conversation_group_left_gutter)); + ViewUtil.setPaddingEnd(this, readDimen(R.dimen.conversation_individual_right_gutter)); + } else if (current.isOutgoing()) { + ViewUtil.setPaddingStart(this, readDimen(R.dimen.conversation_individual_left_gutter)); + ViewUtil.setPaddingEnd(this, readDimen(R.dimen.conversation_individual_right_gutter)); + } + } + + private void setReactions(@NonNull MessageRecord current) { + bodyBubble.setOnSizeChangedListener(null); + + if (current.getReactions().isEmpty()) { + reactionsView.clear(); + return; + } + + setReactionsWithWidth(current, bodyBubble.getWidth()); + bodyBubble.setOnSizeChangedListener((width, height) -> setReactionsWithWidth(current, width)); + } + + private void setReactionsWithWidth(@NonNull MessageRecord current, int width) { + reactionsView.setReactions(current.getReactions(), width); + reactionsView.setOnClickListener(v -> { + if (eventListener == null) return; + + eventListener.onReactionClicked(this, current.getId(), current.isMms()); + }); + } + + private void setFooter(@NonNull MessageRecord current, @NonNull Optional next, @NonNull Locale locale, boolean isGroupThread, boolean hasWallpaper) { + ViewUtil.updateLayoutParams(footer, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + + footer.setVisibility(GONE); + stickerFooter.setVisibility(GONE); + if (sharedContactStub.resolved()) sharedContactStub.get().getFooter().setVisibility(GONE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().getFooter().setVisibility(GONE); + + boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp()); + + if (current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() || + current.isFailed() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread)) + { + ConversationItemFooter activeFooter = getActiveFooter(current); + activeFooter.setVisibility(VISIBLE); + activeFooter.setMessageRecord(current, locale); + + if (hasWallpaper && hasNoBubble((messageRecord))) { + if (messageRecord.isOutgoing()) { + activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper)); + } else { + activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, messageRecord.getRecipient().getColor().toConversationColor(context)); + activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color)); + activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color)); + } + } else if (hasNoBubble(messageRecord)){ + activeFooter.disableBubbleBackground(); + activeFooter.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary)); + activeFooter.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary)); + } else { + activeFooter.disableBubbleBackground(); + } + } + } + + private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) { + if (hasNoBubble(messageRecord)) { + return stickerFooter; + } else if (hasSharedContact(messageRecord) && TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))) { + return sharedContactStub.get().getFooter(); + } else if (hasOnlyThumbnail(messageRecord) && TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))) { + return mediaThumbnailStub.get().getFooter(); + } else { + return footer; + } + } + + private int readDimen(@DimenRes int dimenId) { + return context.getResources().getDimensionPixelOffset(dimenId); + } + + private boolean shouldInterceptClicks(MessageRecord messageRecord) { + return batchSelected.isEmpty() && + ((messageRecord.isFailed() && !messageRecord.isMmsNotification()) || + messageRecord.isPendingInsecureSmsFallback() || + messageRecord.isBundleKeyExchange()); + } + + @SuppressLint("SetTextI18n") + private void setGroupMessageStatus(MessageRecord messageRecord, Recipient recipient) { + if (groupThread && !messageRecord.isOutgoing() && groupSender != null) { + groupSender.setText(recipient.getDisplayName(getContext())); + } + } + + private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper) { + if (groupSender != null) { + int stickerAuthorColor = ContextCompat.getColor(context, R.color.signal_text_primary); + + if (shouldDrawBodyBubbleOutline(messageRecord, false)) { + groupSender.setTextColor(stickerAuthorColor); + } else if (!hasWallpaper && hasNoBubble(messageRecord)) { + groupSender.setTextColor(stickerAuthorColor); + } else { + groupSender.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_primary_color)); + } + } + } + + @SuppressWarnings("ConstantConditions") + private void setAuthor(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread, boolean hasWallpaper) { + if (isGroupThread && !current.isOutgoing()) { + contactPhotoHolder.setVisibility(VISIBLE); + + if (!previous.isPresent() || previous.get().isUpdate() || !current.getRecipient().equals(previous.get().getRecipient()) || + !DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp())) + { + groupSenderHolder.setVisibility(VISIBLE); + + if (hasWallpaper && hasNoBubble(current)) { + groupSenderHolder.setBackgroundResource(R.drawable.wallpaper_bubble_background_tintable_11); + groupSenderHolder.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY); + } else { + groupSenderHolder.setBackground(null); + } + } else { + groupSenderHolder.setVisibility(GONE); + } + + if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient())) { + contactPhoto.setVisibility(VISIBLE); + } else { + contactPhoto.setVisibility(GONE); + } + } else { + if (groupSenderHolder != null) { + groupSenderHolder.setVisibility(GONE); + } + + if (contactPhotoHolder != null) { + contactPhotoHolder.setVisibility(GONE); + } + } + } + + private void setMessageShape(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + int bigRadius = readDimen(R.dimen.message_corner_radius); + int smallRadius = readDimen(R.dimen.message_corner_collapse_radius); + + int background; + + if (isSingularMessage(current, previous, next, isGroupThread)) { + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_alone; + outliner.setRadius(bigRadius); + pulseOutliner.setRadius(bigRadius); + } else { + background = R.drawable.message_bubble_background_received_alone; + outliner.setRadius(bigRadius); + pulseOutliner.setRadius(bigRadius); + } + } else if (isStartOfMessageCluster(current, previous, isGroupThread)) { + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_start; + outliner.setRadii(bigRadius, bigRadius, smallRadius, bigRadius); + pulseOutliner.setRadii(bigRadius, bigRadius, smallRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_start; + outliner.setRadii(bigRadius, bigRadius, bigRadius, smallRadius); + pulseOutliner.setRadii(bigRadius, bigRadius, bigRadius, smallRadius); + } + } else if (isEndOfMessageCluster(current, next, isGroupThread)) { + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_end; + outliner.setRadii(bigRadius, smallRadius, bigRadius, bigRadius); + pulseOutliner.setRadii(bigRadius, smallRadius, bigRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_end; + outliner.setRadii(smallRadius, bigRadius, bigRadius, bigRadius); + pulseOutliner.setRadii(smallRadius, bigRadius, bigRadius, bigRadius); + } + } else { + if (current.isOutgoing()) { + background = R.drawable.message_bubble_background_sent_middle; + outliner.setRadii(bigRadius, smallRadius, smallRadius, bigRadius); + pulseOutliner.setRadii(bigRadius, smallRadius, smallRadius, bigRadius); + } else { + background = R.drawable.message_bubble_background_received_middle; + outliner.setRadii(smallRadius, bigRadius, bigRadius, smallRadius); + pulseOutliner.setRadii(smallRadius, bigRadius, bigRadius, smallRadius); + } + } + + bodyBubble.setBackgroundResource(background); + } + + private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional previous, boolean isGroupThread) { + if (isGroupThread) { + return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) || + !current.getRecipient().equals(previous.get().getRecipient()); + } else { + return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) || + current.isOutgoing() != previous.get().isOutgoing(); + } + } + + private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional next, boolean isGroupThread) { + if (isGroupThread) { + return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) || + !current.getRecipient().equals(next.get().getRecipient()); + } else { + return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) || + current.isOutgoing() != next.get().isOutgoing(); + } + } + + private boolean isSingularMessage(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + return isStartOfMessageCluster(current, previous, isGroupThread) && isEndOfMessageCluster(current, next, isGroupThread); + } + + private void setMessageSpacing(@NonNull Context context, @NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread) { + int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse); + int spacingBottom = spacingTop; + + if (isStartOfMessageCluster(current, previous, isGroupThread)) { + spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_default); + } + + if (isEndOfMessageCluster(current, next, isGroupThread)) { + spacingBottom = readDimen(context, R.dimen.conversation_vertical_message_spacing_default); + } + + ViewUtil.setPaddingTop(this, spacingTop); + ViewUtil.setPaddingBottom(this, spacingBottom); + } + + private int readDimen(@NonNull Context context, @DimenRes int dimenId) { + return context.getResources().getDimensionPixelOffset(dimenId); + } + + /// Event handlers + + private void handleApproveIdentity() { + List mismatches = messageRecord.getIdentityKeyMismatches(); + + if (mismatches.size() != 1) { + throw new AssertionError("Identity mismatch count: " + mismatches.size()); + } + + new ConfirmIdentityDialog(context, messageRecord, mismatches.get(0)).show(); + } + + private Spannable getLongMessageSpan(@NonNull MessageRecord messageRecord) { + String message; + Runnable action; + + if (messageRecord.isMms()) { + TextSlide slide = ((MmsMessageRecord) messageRecord).getSlideDeck().getTextSlide(); + + if (slide != null && slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + message = getResources().getString(R.string.ConversationItem_read_more); + action = () -> eventListener.onMoreTextClicked(conversationRecipient.getId(), messageRecord.getId(), messageRecord.isMms()); + } else if (slide != null && slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + message = getResources().getString(R.string.ConversationItem_pending); + action = () -> {}; + } else if (slide != null) { + message = getResources().getString(R.string.ConversationItem_download_more); + action = () -> singleDownloadClickListener.onClick(bodyText, slide); + } else { + message = getResources().getString(R.string.ConversationItem_read_more); + action = () -> eventListener.onMoreTextClicked(conversationRecipient.getId(), messageRecord.getId(), messageRecord.isMms()); + } + } else { + message = getResources().getString(R.string.ConversationItem_read_more); + action = () -> eventListener.onMoreTextClicked(conversationRecipient.getId(), messageRecord.getId(), messageRecord.isMms()); + } + + SpannableStringBuilder span = new SpannableStringBuilder(message); + CharacterStyle style = new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + if (eventListener != null && batchSelected.isEmpty()) { + action.run(); + } + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + ds.setTypeface(Typeface.DEFAULT_BOLD); + } + }; + span.setSpan(style, 0, span.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return span; + } + + private class SharedContactEventListener implements SharedContactView.EventListener { + @Override + public void onAddToContactsClicked(@NonNull Contact contact) { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onAddToContactsClicked(contact); + } else { + passthroughClickListener.onClick(sharedContactStub.get()); + } + } + + @Override + public void onInviteClicked(@NonNull List choices) { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onInviteSharedContactClicked(choices); + } else { + passthroughClickListener.onClick(sharedContactStub.get()); + } + } + + @Override + public void onMessageClicked(@NonNull List choices) { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onMessageSharedContactClicked(choices); + } else { + passthroughClickListener.onClick(sharedContactStub.get()); + } + } + } + + private class SharedContactClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) { + eventListener.onSharedContactDetailsClicked(((MmsMessageRecord) messageRecord).getSharedContacts().get(0), sharedContactStub.get().getAvatarView()); + } else { + passthroughClickListener.onClick(view); + } + } + } + + private class LinkPreviewClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) { + eventListener.onLinkPreviewClicked(((MmsMessageRecord) messageRecord).getLinkPreviews().get(0)); + } else { + passthroughClickListener.onClick(view); + } + } + } + + private class ViewOnceMessageClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + ViewOnceMessageView revealView = (ViewOnceMessageView) view; + + if (batchSelected.isEmpty() && messageRecord.isMms() && revealView.requiresTapToDownload((MmsMessageRecord) messageRecord)) { + singleDownloadClickListener.onClick(view, ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlide()); + } else if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms()) { + eventListener.onViewOnceMessageClicked((MmsMessageRecord) messageRecord); + } else { + passthroughClickListener.onClick(view); + } + } + } + + private class LinkPreviewThumbnailClickListener implements SlideClickListener { + public void onClick(final View v, final Slide slide) { + if (eventListener != null && batchSelected.isEmpty() && messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) { + eventListener.onLinkPreviewClicked(((MmsMessageRecord) messageRecord).getLinkPreviews().get(0)); + } else { + performClick(); + } + } + } + + private class AttachmentDownloadClickListener implements SlidesClickedListener { + @Override + public void onClick(View v, final List slides) { + Log.i(TAG, "onClick() for attachment download"); + if (messageRecord.isMmsNotification()) { + Log.i(TAG, "Scheduling MMS attachment download"); + ApplicationDependencies.getJobManager().add(new MmsDownloadJob(messageRecord.getId(), + messageRecord.getThreadId(), + false)); + } else { + Log.i(TAG, "Scheduling push attachment downloads for " + slides.size() + " items"); + + for (Slide slide : slides) { + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(messageRecord.getId(), + ((DatabaseAttachment)slide.asAttachment()).getAttachmentId(), + true)); + } + } + } + } + + private class SlideClickPassthroughListener implements SlideClickListener { + + private final SlidesClickedListener original; + + private SlideClickPassthroughListener(@NonNull SlidesClickedListener original) { + this.original = original; + } + + @Override + public void onClick(View v, Slide slide) { + original.onClick(v, Collections.singletonList(slide)); + } + } + + private class StickerClickListener implements SlideClickListener { + @Override + public void onClick(View v, Slide slide) { + if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { + performClick(); + } else if (eventListener != null && hasSticker(messageRecord)) { + //noinspection ConstantConditions + eventListener.onStickerClicked(((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide().asAttachment().getSticker()); + } + } + } + + private class ThumbnailClickListener implements SlideClickListener { + public void onClick(final View v, final Slide slide) { + if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { + performClick(); + } else if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setDataAndType(slide.getUri(), slide.getContentType()); + intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, messageRecord.getThreadId()); + intent.putExtra(MediaPreviewActivity.DATE_EXTRA, messageRecord.getTimestamp()); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, slide.asAttachment().getSize()); + intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, slide.getCaption().orNull()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, false); + + context.startActivity(intent); + } else if (slide.getUri() != null) { + Log.i(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType()); + Uri publicUri = PartAuthority.getAttachmentPublicUri(slide.getUri()); + Log.i(TAG, "Public URI: " + publicUri); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setDataAndType(PartAuthority.getAttachmentPublicUri(slide.getUri()), Intent.normalizeMimeType(slide.getContentType())); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "No activity existed to view the media."); + Toast.makeText(context, R.string.ConversationItem_unable_to_open_media, Toast.LENGTH_LONG).show(); + } + } + } + } + + private class PassthroughClickListener implements View.OnLongClickListener, View.OnClickListener { + + @Override + public boolean onLongClick(View v) { + if (bodyText.hasSelection()) { + return false; + } + performLongClick(); + return true; + } + + @Override + public void onClick(View v) { + performClick(); + } + } + + private class ClickListener implements View.OnClickListener { + private OnClickListener parent; + + ClickListener(@Nullable OnClickListener parent) { + this.parent = parent; + } + + public void onClick(View v) { + if (!shouldInterceptClicks(messageRecord) && parent != null) { + parent.onClick(v); + } else if (messageRecord.isFailed()) { + if (eventListener != null) { + eventListener.onMessageWithErrorClicked(messageRecord); + } + } else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) { + handleApproveIdentity(); + } else if (messageRecord.isPendingInsecureSmsFallback()) { + handleMessageApproval(); + } + } + } + + private final class UrlClickListener implements UrlClickHandler { + + @Override + public boolean handleOnClick(@NonNull String url) { + return eventListener != null && eventListener.onUrlClicked(url); + } + } + + private class MentionClickableSpan extends ClickableSpan { + private final RecipientId mentionedRecipientId; + + MentionClickableSpan(RecipientId mentionedRecipientId) { + this.mentionedRecipientId = mentionedRecipientId; + } + + @Override + public void onClick(@NonNull View widget) { + if (eventListener != null && batchSelected.isEmpty()) { + VibrateUtil.vibrateTick(context); + eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId()); + } + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { } + } + + private final class AudioViewCallbacks implements AudioView.Callbacks { + + @Override + public void onPlay(@NonNull Uri audioUri, double progress) { + if (eventListener == null) return; + + eventListener.onVoiceNotePlay(audioUri, messageRecord.getId(), progress); + } + + @Override + public void onPause(@NonNull Uri audioUri) { + if (eventListener == null) return; + + eventListener.onVoiceNotePause(audioUri); + } + + @Override + public void onSeekTo(@NonNull Uri audioUri, double progress) { + if (eventListener == null) return; + + eventListener.onVoiceNoteSeekTo(audioUri, progress); + } + + @Override + public void onStopAndReset(@NonNull Uri audioUri) { + throw new UnsupportedOperationException(); + } + + @Override + public void onProgressUpdated(long durationMillis, long playheadMillis) { + footer.setAudioDuration(durationMillis, playheadMillis); + } + } + + private void handleMessageApproval() { + final int title; + final int message; + + if (messageRecord.isMms()) title = R.string.ConversationItem_click_to_approve_unencrypted_mms_dialog_title; + else title = R.string.ConversationItem_click_to_approve_unencrypted_sms_dialog_title; + + message = R.string.ConversationItem_click_to_approve_unencrypted_dialog_message; + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(title); + + if (message > -1) builder.setMessage(message); + + builder.setPositiveButton(R.string.yes, (dialogInterface, i) -> { + MessageDatabase db = messageRecord.isMms() ? DatabaseFactory.getMmsDatabase(context) + : DatabaseFactory.getSmsDatabase(context); + + db.markAsInsecure(messageRecord.getId()); + db.markAsOutbox(messageRecord.getId()); + db.markAsForcedSms(messageRecord.getId()); + + if (messageRecord.isMms()) { + MmsSendJob.enqueue(context, + ApplicationDependencies.getJobManager(), + messageRecord.getId()); + } else { + ApplicationDependencies.getJobManager().add(new SmsSendJob(messageRecord.getId(), + messageRecord.getIndividualRecipient())); + } + }); + + builder.setNegativeButton(R.string.no, (dialogInterface, i) -> { + if (messageRecord.isMms()) { + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageRecord.getId()); + } else { + DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageRecord.getId()); + } + }); + builder.show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java new file mode 100644 index 00000000..af1b4be7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.Outliner; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Collections; +import java.util.List; + +public class ConversationItemBodyBubble extends LinearLayout { + + @Nullable private List outliners = Collections.emptyList(); + @Nullable private OnSizeChangedListener sizeChangedListener; + + public ConversationItemBodyBubble(Context context) { + super(context); + } + + public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setOutliners(@NonNull List outliners) { + this.outliners = outliners; + } + + public void setOnSizeChangedListener(@Nullable OnSizeChangedListener listener) { + this.sizeChangedListener = listener; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (Util.isEmpty(outliners)) return; + + for (Outliner outliner : outliners) { + outliner.draw(canvas, 0, getMeasuredWidth(), getMeasuredHeight(), 0); + } + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + if (sizeChangedListener != null) { + post(() -> { + if (sizeChangedListener != null) { + sizeChangedListener.onSizeChanged(width, height); + } + }); + } + } + + public interface OnSizeChangedListener { + void onSizeChanged(int width, int height); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java new file mode 100644 index 00000000..af4e4389 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java @@ -0,0 +1,200 @@ +package org.thoughtcrime.securesms.conversation; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.os.Vibrator; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.util.AccessibilityUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; + +class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { + + private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX; + private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10; + + private boolean swipeBack; + private boolean shouldTriggerSwipeFeedback; + private boolean canTriggerSwipe; + private float latestDownX; + private float latestDownY; + + private final SwipeAvailabilityProvider swipeAvailabilityProvider; + private final ConversationItemTouchListener itemTouchListener; + private final OnSwipeListener onSwipeListener; + + ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider, + @NonNull OnSwipeListener onSwipeListener) + { + super(0, ItemTouchHelper.END); + this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate); + this.swipeAvailabilityProvider = swipeAvailabilityProvider; + this.onSwipeListener = onSwipeListener; + this.shouldTriggerSwipeFeedback = true; + this.canTriggerSwipe = true; + } + + void attachToRecyclerView(@NonNull RecyclerView recyclerView) { + recyclerView.addOnItemTouchListener(itemTouchListener); + new ItemTouchHelper(this).attachToRecyclerView(recyclerView); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) + { + return false; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + + @Override + public int getSwipeDirs(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) + { + if (cannotSwipeViewHolder(viewHolder)) return 0; + return super.getSwipeDirs(recyclerView, viewHolder); + } + + @Override + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + if (swipeBack) { + swipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + @Override + public void onChildDraw( + @NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx, float dy, int actionState, boolean isCurrentlyActive) + { + if (cannotSwipeViewHolder(viewHolder)) return; + + float sign = getSignFromDirection(viewHolder.itemView); + boolean isCorrectSwipeDir = sameSign(dx, sign); + + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign); + handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx)); + if (canTriggerSwipe) { + setTouchListener(recyclerView, viewHolder, Math.abs(dx)); + } + } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1); + } + + if (dx == 0) { + shouldTriggerSwipeFeedback = true; + canTriggerSwipe = true; + } + } + + private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) { + if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) { + vibrate(item.getContext()); + ConversationSwipeAnimationHelper.trigger(item); + shouldTriggerSwipeFeedback = false; + } + } + + private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) { + if (cannotSwipeViewHolder(viewHolder)) return; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + ConversationMessage messageRecord = item.getConversationMessage(); + + onSwipeListener.onSwipe(messageRecord); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx) + { + recyclerView.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + shouldTriggerSwipeFeedback = true; + break; + case MotionEvent.ACTION_UP: + handleTouchActionUp(recyclerView, viewHolder, dx); + case MotionEvent.ACTION_CANCEL: + swipeBack = true; + shouldTriggerSwipeFeedback = false; + resetProgressIfAnimationsDisabled(viewHolder); + break; + } + return false; + }); + } + + private void handleTouchActionUp(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dx) + { + if (dx > SWIPE_SUCCESS_DX) { + canTriggerSwipe = false; + onSwiped(viewHolder); + if (shouldTriggerSwipeFeedback) { + vibrate(viewHolder.itemView.getContext()); + } + recyclerView.setOnTouchListener(null); + } + recyclerView.cancelPendingInputEvents(); + } + + private static void resetProgressIfAnimationsDisabled(RecyclerView.ViewHolder viewHolder) { + if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, + 0f, + getSignFromDirection(viewHolder.itemView)); + } + } + + private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder.itemView instanceof ConversationItem)) return true; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + return !swipeAvailabilityProvider.isSwipeAvailable(item.getConversationMessage()) || + item.disallowSwipe(latestDownX, latestDownY); + } + + private void updateLatestDownCoordinate(float x, float y) { + latestDownX = x; + latestDownY = y; + } + + private static float getSignFromDirection(@NonNull View view) { + return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? -1f : 1f; + } + + private static boolean sameSign(float dX, float sign) { + return dX * sign > 0; + } + + private static void vibrate(@NonNull Context context) { + Vibrator vibrator = ServiceUtil.getVibrator(context); + if (vibrator != null) vibrator.vibrate(SWIPE_SUCCESS_VIBE_TIME_MS); + } + + interface SwipeAvailabilityProvider { + boolean isSwipeAvailable(ConversationMessage conversationMessage); + } + + interface OnSwipeListener { + void onSwipe(ConversationMessage conversationMessage); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java new file mode 100644 index 00000000..4420ddb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +final class ConversationItemTouchListener extends RecyclerView.SimpleOnItemTouchListener { + + private final Callback callback; + + ConversationItemTouchListener(Callback callback) { + this.callback = callback; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + if (e.getAction() == MotionEvent.ACTION_DOWN) { + callback.onDownEvent(e.getRawX(), e.getRawY()); + } + return false; + } + + interface Callback { + void onDownEvent(float rawX, float rawY); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java new file mode 100644 index 00000000..71c61cc6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -0,0 +1,145 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.text.SpannableString; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.Conversions; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; + +import java.security.MessageDigest; +import java.util.Collections; +import java.util.List; + +/** + * A view level model used to pass arbitrary message related information needed + * for various presentations. + */ +public class ConversationMessage { + @NonNull private final MessageRecord messageRecord; + @NonNull private final List mentions; + @Nullable private final SpannableString body; + + private ConversationMessage(@NonNull MessageRecord messageRecord) { + this(messageRecord, null, null); + } + + private ConversationMessage(@NonNull MessageRecord messageRecord, + @Nullable CharSequence body, + @Nullable List mentions) + { + this.messageRecord = messageRecord; + this.body = body != null ? SpannableString.valueOf(body) : null; + this.mentions = mentions != null ? mentions : Collections.emptyList(); + + if (!this.mentions.isEmpty() && this.body != null) { + MentionAnnotation.setMentionAnnotations(this.body, this.mentions); + } + } + + public @NonNull MessageRecord getMessageRecord() { + return messageRecord; + } + + public @NonNull List getMentions() { + return mentions; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final ConversationMessage that = (ConversationMessage) o; + return messageRecord.equals(that.messageRecord); + } + + @Override + public int hashCode() { + return messageRecord.hashCode(); + } + + public long getUniqueId(@NonNull MessageDigest digest) { + String unique = (messageRecord.isMms() ? "MMS::" : "SMS::") + messageRecord.getId(); + byte[] bytes = digest.digest(unique.getBytes()); + + return Conversions.byteArrayToLong(bytes); + } + + public @NonNull SpannableString getDisplayBody(Context context) { + if (mentions.isEmpty() || body == null) { + return messageRecord.getDisplayBody(context); + } + return body; + } + + /** + * Factory providing multiple ways of creating {@link ConversationMessage}s. + */ + public static class ConversationMessageFactory { + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord. No database or + * heavy work performed as the message is assumed to not have any mentions. + */ + @AnyThread + public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord) { + return new ConversationMessage(messageRecord); + } + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord, potentially annotated body, and + * list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be + * fully updated with display names. + * + * @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names. + * @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body. + */ + @AnyThread + public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions) { + if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { + return new ConversationMessage(messageRecord, body, mentions); + } + return createWithResolvedData(messageRecord); + } + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord and will update and modify the provided + * mentions from placeholder to actual. This method may perform database operations to resolve mentions to display names. + * + * @param mentions List of placeholder mentions to be used to update the body in the provided MessageRecord. + */ + @WorkerThread + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List mentions) { + if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + } + return createWithResolvedData(messageRecord); + } + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord, and will query for potential mentions. If mentions + * are found, the body of the provided message will be updated and modified to match actual mentions. This will perform + * database operations to query for mentions and then to resolve mentions to display names. + */ + @WorkerThread + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord) { + if (messageRecord.isMms()) { + List mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageRecord.getId()); + if (!mentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + } + } + return createWithResolvedData(messageRecord); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java new file mode 100644 index 00000000..d73c5578 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Display; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; + +import androidx.core.app.ActivityOptionsCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; + +import java.util.concurrent.ExecutionException; + +public class ConversationPopupActivity extends ConversationActivity { + + private static final String TAG = ConversationPopupActivity.class.getSimpleName(); + + @Override + protected void onPreCreate() { + super.onPreCreate(); + overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top); + } + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + getWindow().setFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND, + WindowManager.LayoutParams.FLAG_DIM_BEHIND); + + WindowManager.LayoutParams params = getWindow().getAttributes(); + params.alpha = 1.0f; + params.dimAmount = 0.1f; + params.gravity = Gravity.TOP; + getWindow().setAttributes(params); + + Display display = getWindowManager().getDefaultDisplay(); + int width = display.getWidth(); + int height = display.getHeight(); + + if (height > width) getWindow().setLayout((int) (width * .85), (int) (height * .5)); + else getWindow().setLayout((int) (width * .7), (int) (height * .75)); + + super.onCreate(bundle, ready); + + titleView.setOnClickListener(null); + } + + @Override + protected void onResume() { + super.onResume(); + composeText.requestFocus(); + quickAttachmentToggle.disable(); + } + + @Override + protected void onPause() { + super.onPause(); + if (isFinishing()) overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuInflater inflater = this.getMenuInflater(); + menu.clear(); + + inflater.inflate(R.menu.conversation_popup, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_expand: + saveDraft().addListener(new ListenableFuture.Listener() { + @Override + public void onSuccess(Long result) { + ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height); + Intent intent = ConversationIntents.createBuilder(ConversationPopupActivity.this, getRecipient().getId(), result) + .build(); + + startActivity(intent, transition.toBundle()); + + finish(); + } + + @Override + public void onFailure(ExecutionException e) { + Log.w(TAG, e); + } + }); + return true; + } + + return false; + } + + @Override + protected void initializeActionBar() { + super.initializeActionBar(); + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + } + + @Override + protected void sendComplete(long threadId) { + super.sendComplete(threadId); + finish(); + } + + @Override + protected void updateReminders() { + if (reminderView.resolved()) { + reminderView.get().setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java new file mode 100644 index 00000000..01532905 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Activity; +import android.graphics.PointF; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; + +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.views.Stub; + +/** + * Delegate class that mimics the ConversationReactionOverlay public API + * + * This allows us to properly stub out the ConversationReactionOverlay View class while still + * respecting listeners and other positional information that can be set BEFORE we want to actually + * resolve the view. + */ +final class ConversationReactionDelegate { + + private final Stub overlayStub; + private final PointF lastSeenDownPoint = new PointF(); + + private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener; + private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener; + private ConversationReactionOverlay.OnHideListener onHideListener; + private float translationY; + + ConversationReactionDelegate(@NonNull Stub overlayStub) { + this.overlayStub = overlayStub; + } + + boolean isShowing() { + return overlayStub.resolved() && overlayStub.get().isShowing(); + } + + void show(@NonNull Activity activity, + @NonNull View maskTarget, + @NonNull Recipient conversationRecipient, + @NonNull MessageRecord messageRecord, + int maskPaddingBottom) + { + resolveOverlay().show(activity, maskTarget, conversationRecipient, messageRecord, maskPaddingBottom, lastSeenDownPoint); + } + + void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) { + resolveOverlay().showMask(maskTarget, maskPaddingTop, maskPaddingBottom); + } + + void hide() { + overlayStub.get().hide(); + } + + void hideAllButMask() { + overlayStub.get().hideAllButMask(); + } + + void hideMask() { + overlayStub.get().hideMask(); + } + + void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) { + this.onReactionSelectedListener = onReactionSelectedListener; + + if (overlayStub.resolved()) { + overlayStub.get().setOnReactionSelectedListener(onReactionSelectedListener); + } + } + + void setOnToolbarItemClickedListener(@NonNull Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) { + this.onToolbarItemClickedListener = onToolbarItemClickedListener; + + if (overlayStub.resolved()) { + overlayStub.get().setOnToolbarItemClickedListener(onToolbarItemClickedListener); + } + } + + void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) { + this.onHideListener = onHideListener; + + if (overlayStub.resolved()) { + overlayStub.get().setOnHideListener(onHideListener); + } + } + + void setListVerticalTranslation(float translationY) { + this.translationY = translationY; + + if (overlayStub.resolved()) { + overlayStub.get().setListVerticalTranslation(translationY); + } + } + + @NonNull MessageRecord getMessageRecord() { + if (!overlayStub.resolved()) { + throw new IllegalStateException("Cannot call getMessageRecord right now."); + } + + return overlayStub.get().getMessageRecord(); + } + + boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { + if (!overlayStub.resolved() || !overlayStub.get().isShowing()) { + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { + lastSeenDownPoint.set(motionEvent.getX(), motionEvent.getY()); + } + return false; + } else { + return overlayStub.get().applyTouchEvent(motionEvent); + } + } + + private @NonNull ConversationReactionOverlay resolveOverlay() { + ConversationReactionOverlay overlay = overlayStub.get(); + overlay.requestFitSystemWindows(); + + overlay.setListVerticalTranslation(translationY); + overlay.setOnHideListener(onHideListener); + overlay.setOnToolbarItemClickedListener(onToolbarItemClickedListener); + overlay.setOnReactionSelectedListener(onReactionSelectedListener); + + return overlay; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java new file mode 100644 index 00000000..66c6154e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -0,0 +1,670 @@ +package org.thoughtcrime.securesms.conversation; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.app.Activity; +import android.content.Context; +import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.view.HapticFeedbackConstants; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.RelativeLayout; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.core.content.ContextCompat; +import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.components.MaskView; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public final class ConversationReactionOverlay extends RelativeLayout { + + private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); + + private final Rect emojiViewGlobalRect = new Rect(); + private final Rect emojiStripViewBounds = new Rect(); + private float segmentSize; + + private final Boundary horizontalEmojiBoundary = new Boundary(); + private final Boundary verticalScrubBoundary = new Boundary(); + private final PointF deadzoneTouchPoint = new PointF(); + + private Activity activity; + private Recipient conversationRecipient; + private MessageRecord messageRecord; + private OverlayState overlayState = OverlayState.HIDDEN; + + private boolean downIsOurs; + private boolean isToolbarTouch; + private int selected = -1; + private int customEmojiIndex; + private int originalStatusBarColor; + + private View backgroundView; + private ConstraintLayout foregroundView; + private View selectedView; + private EmojiImageView[] emojiViews; + private MaskView maskView; + private Toolbar toolbar; + + private float touchDownDeadZoneSize; + private float distanceFromTouchDownPointToTopOfScrubberDeadZone; + private float distanceFromTouchDownPointToBottomOfScrubberDeadZone; + private int scrubberDistanceFromTouchDown; + private int scrubberHeight; + private int scrubberWidth; + private int actionBarHeight; + private int selectedVerticalTranslation; + private int scrubberHorizontalMargin; + private int animationEmojiStartDelayFactor; + private int statusBarHeight; + + private OnReactionSelectedListener onReactionSelectedListener; + private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener; + private OnHideListener onHideListener; + + private AnimatorSet revealAnimatorSet = new AnimatorSet(); + private AnimatorSet revealMaskAnimatorSet = new AnimatorSet(); + private AnimatorSet hideAnimatorSet = new AnimatorSet(); + private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet(); + private AnimatorSet hideMaskAnimatorSet = new AnimatorSet(); + + public ConversationReactionOverlay(@NonNull Context context) { + super(context); + } + + public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); + selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator); + maskView = findViewById(R.id.conversation_reaction_mask); + toolbar = findViewById(R.id.conversation_reaction_toolbar); + + toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked); + toolbar.setNavigationOnClickListener(view -> hide()); + + emojiViews = Stream.of(ReactionEmoji.values()) + .map(e -> findViewById(e.viewId)) + .toArray(EmojiImageView[]::new); + + customEmojiIndex = ReactionEmoji.values().length - 1; + + distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top); + distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom); + + touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size); + scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance); + scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height); + scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width); + actionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize); + selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); + scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); + + animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor); + + initAnimators(); + } + + public void setListVerticalTranslation(float translationY) { + maskView.setTargetParentTranslationY(translationY); + } + + public void show(@NonNull Activity activity, + @NonNull View maskTarget, + @NonNull Recipient conversationRecipient, + @NonNull MessageRecord messageRecord, + int maskPaddingBottom, + @NonNull PointF lastSeenDownPoint) + { + + if (overlayState != OverlayState.HIDDEN) { + return; + } + + this.messageRecord = messageRecord; + this.conversationRecipient = conversationRecipient; + overlayState = OverlayState.UNINITAILIZED; + selected = -1; + + setupToolbarMenuItems(); + setupSelectedEmoji(); + + if (Build.VERSION.SDK_INT >= 21) { + View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground); + statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight(); + } else { + statusBarHeight = ViewUtil.getStatusBarHeight(this); + } + + final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + actionBarHeight, + lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown - statusBarHeight); + + final float halfWidth = scrubberWidth / 2f + scrubberHorizontalMargin; + final float screenWidth = getResources().getDisplayMetrics().widthPixels; + final float downX = ViewUtil.isLtr(this) ? lastSeenDownPoint.x : screenWidth - lastSeenDownPoint.x; + final float scrubberTranslationX = Util.clamp(downX - halfWidth, + scrubberHorizontalMargin, + screenWidth + scrubberHorizontalMargin - halfWidth * 2) * (ViewUtil.isLtr(this) ? 1 : -1); + + backgroundView.setTranslationX(scrubberTranslationX); + backgroundView.setTranslationY(scrubberTranslationY); + + foregroundView.setTranslationX(scrubberTranslationX); + foregroundView.setTranslationY(scrubberTranslationY); + + verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); + + maskView.setPadding(0, 0, 0, maskPaddingBottom); + maskView.setTarget(maskTarget); + + hideAnimatorSet.end(); + toolbar.setVisibility(VISIBLE); + setVisibility(View.VISIBLE); + revealAnimatorSet.start(); + + if (Build.VERSION.SDK_INT >= 21) { + this.activity = activity; + originalStatusBarColor = activity.getWindow().getStatusBarColor(); + WindowUtil.setStatusBarColor(activity.getWindow(), ContextCompat.getColor(getContext(), R.color.action_mode_status_bar)); + + if (!ThemeUtil.isDarkTheme(getContext())) { + WindowUtil.setLightStatusBar(activity.getWindow()); + } + } + } + + public void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) { + maskView.setPadding(0, maskPaddingTop, 0, maskPaddingBottom); + maskView.setTarget(maskTarget); + + hideAnimatorSet.end(); + toolbar.setVisibility(GONE); + setVisibility(VISIBLE); + revealMaskAnimatorSet.start(); + } + + public void hide() { + maskView.setTarget(null); + hideInternal(hideAnimatorSet, onHideListener); + } + + public void hideAllButMask() { + hideInternal(hideAllButMaskAnimatorSet, null); + } + + public void hideMask() { + hideMaskAnimatorSet.start(); + + if (onHideListener != null) { + onHideListener.onHide(); + } + } + + private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) { + overlayState = OverlayState.HIDDEN; + + revealAnimatorSet.end(); + hideAnimatorSet.start(); + + if (Build.VERSION.SDK_INT >= 21 && activity != null) { + WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor); + WindowUtil.clearLightStatusBar(activity.getWindow()); + activity = null; + } + + if (onHideListener != null) { + onHideListener.onHide(); + } + } + + public boolean isShowing() { + return overlayState != OverlayState.HIDDEN; + } + + public @NonNull MessageRecord getMessageRecord() { + return messageRecord; + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + + backgroundView.getGlobalVisibleRect(emojiStripViewBounds); + emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect); + emojiStripViewBounds.left = getStart(emojiViewGlobalRect); + emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect); + emojiStripViewBounds.right = getEnd(emojiViewGlobalRect); + + segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length; + } + + private int getStart(@NonNull Rect rect) { + if (ViewUtil.isLtr(this)) { + return rect.left; + } else { + return rect.right; + } + } + + private int getEnd(@NonNull Rect rect) { + if (ViewUtil.isLtr(this)) { + return rect.right; + } else { + return rect.left; + } + } + + public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { + if (!isShowing()) { + throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber."); + } + + if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) { + return true; + } + + if (overlayState == OverlayState.UNINITAILIZED) { + downIsOurs = false; + + deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); + + overlayState = OverlayState.DEADZONE; + } + + if (overlayState == OverlayState.DEADZONE) { + float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX()); + float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY()); + + if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { + overlayState = OverlayState.SCRUB; + } else { + if (motionEvent.getAction() == MotionEvent.ACTION_UP) { + overlayState = OverlayState.TAP; + + if (downIsOurs) { + handleUpEvent(); + return true; + } + } + + return MotionEvent.ACTION_MOVE == motionEvent.getAction(); + } + } + + if (isToolbarTouch) { + if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) { + isToolbarTouch = false; + } + return false; + } + + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_DOWN: + selected = getSelectedIndexViaDownEvent(motionEvent); + + if (selected == -1) { + if (motionEvent.getY() < toolbar.getHeight() + statusBarHeight) { + isToolbarTouch = true; + return false; + } + } + + deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); + overlayState = OverlayState.DEADZONE; + downIsOurs = true; + return true; + case MotionEvent.ACTION_MOVE: + selected = getSelectedIndexViaMoveEvent(motionEvent); + return true; + case MotionEvent.ACTION_UP: + handleUpEvent(); + return downIsOurs; + case MotionEvent.ACTION_CANCEL: + hide(); + return downIsOurs; + default: + return false; + } + } + + private void setupSelectedEmoji() { + final String oldEmoji = getOldEmoji(messageRecord); + + if (oldEmoji == null) { + selectedView.setVisibility(View.GONE); + } + + boolean foundSelected = false; + + for (int i = 0; i < emojiViews.length; i++) { + final EmojiImageView view = emojiViews[i]; + + view.setScaleX(1.0f); + view.setScaleY(1.0f); + view.setTranslationY(0); + + boolean isAtCustomIndex = i == customEmojiIndex; + boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && oldEmoji != null && ReactionEmoji.values()[i].emoji.equals(EmojiUtil.getCanonicalRepresentation(oldEmoji)); + boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null; + + if (!foundSelected && + (isNotAtCustomIndexAndOldEmojiMatches || isAtCustomIndexAndOldEmojiExists)) + { + foundSelected = true; + selectedView.setVisibility(View.VISIBLE); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(foregroundView); + constraintSet.clear(selectedView.getId(), ConstraintSet.LEFT); + constraintSet.clear(selectedView.getId(), ConstraintSet.RIGHT); + constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT); + constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT); + constraintSet.applyTo(foregroundView); + + if (isAtCustomIndex) { + view.setImageEmoji(oldEmoji); + view.setTag(oldEmoji); + } else { + view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji)); + } + } else if (isAtCustomIndex) { + view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_any_emoji_32)); + view.setTag(null); + } else { + view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji)); + } + } + } + + private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { + return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); + } + + private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { + return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); + } + + private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { + int selected = -1; + + for (int i = 0; i < emojiViews.length; i++) { + final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; + horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); + + if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) { + selected = i; + } + } + + if (this.selected != -1 && this.selected != selected) { + shrinkView(emojiViews[this.selected]); + } + + if (this.selected != selected && selected != -1) { + growView(emojiViews[selected]); + } + + return selected; + } + + private void growView(@NonNull View view) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + view.animate() + .scaleY(1.5f) + .scaleX(1.5f) + .translationY(-selectedVerticalTranslation) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start(); + } + + private void shrinkView(@NonNull View view) { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationY(0) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start(); + } + + private void handleUpEvent() { + if (selected != -1 && onReactionSelectedListener != null) { + if (selected == customEmojiIndex) { + onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null); + } else { + onReactionSelectedListener.onReactionSelected(messageRecord, SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[selected].emoji)); + } + } else { + hide(); + } + } + + public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { + this.onReactionSelectedListener = onReactionSelectedListener; + } + + public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) { + this.onToolbarItemClickedListener = onToolbarItemClickedListener; + } + + public void setOnHideListener(@Nullable OnHideListener onHideListener) { + this.onHideListener = onHideListener; + } + + private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { + return Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor() + .serialize() + .equals(Recipient.self() + .getId() + .serialize())) + .findFirst() + .map(ReactionRecord::getEmoji) + .orElse(null); + } + + private void setupToolbarMenuItems() { + MenuState menuState = MenuState.getMenuState(conversationRecipient, Collections.singleton(messageRecord), false); + + toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction()); + toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction()); + toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction()); + toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction()); + } + + private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) { + + hide(); + + if (onToolbarItemClickedListener == null) { + return false; + } + + return onToolbarItemClickedListener.onMenuItemClick(menuItem); + } + + private void initAnimators() { + + int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); + + List reveals = Stream.of(emojiViews) + .mapIndexed((idx, v) -> { + Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); + anim.setTarget(v); + anim.setStartDelay(idx * animationEmojiStartDelayFactor); + return anim; + }) + .toList(); + + Animator overlayRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + overlayRevealAnim.setTarget(maskView); + overlayRevealAnim.setDuration(duration); + reveals.add(overlayRevealAnim); + + Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + backgroundRevealAnim.setTarget(backgroundView); + backgroundRevealAnim.setDuration(duration); + reveals.add(backgroundRevealAnim); + + Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + selectedRevealAnim.setTarget(selectedView); + selectedRevealAnim.setDuration(duration); + reveals.add(selectedRevealAnim); + + Animator toolbarRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + toolbarRevealAnim.setTarget(toolbar); + toolbarRevealAnim.setDuration(duration); + reveals.add(toolbarRevealAnim); + + revealAnimatorSet.setInterpolator(INTERPOLATOR); + revealAnimatorSet.playTogether(reveals); + + revealMaskAnimatorSet.setInterpolator(INTERPOLATOR); + revealMaskAnimatorSet.playTogether(overlayRevealAnim); + + List hides = Stream.of(emojiViews) + .mapIndexed((idx, v) -> { + Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); + anim.setTarget(v); + anim.setStartDelay(idx * animationEmojiStartDelayFactor); + return anim; + }) + .toList(); + + Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + overlayHideAnim.setTarget(maskView); + overlayHideAnim.setDuration(duration); + + Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + backgroundHideAnim.setTarget(backgroundView); + backgroundHideAnim.setDuration(duration); + hides.add(backgroundHideAnim); + + Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + selectedHideAnim.setTarget(selectedView); + selectedHideAnim.setDuration(duration); + hides.add(selectedHideAnim); + + Animator toolbarHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + toolbarHideAnim.setTarget(toolbar); + toolbarHideAnim.setDuration(duration); + hides.add(toolbarHideAnim); + + AnimationCompleteListener hideListener = new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + setVisibility(View.GONE); + } + }; + + List hideAllAnimators = new LinkedList<>(hides); + hideAllAnimators.add(overlayHideAnim); + + hideAnimatorSet.addListener(hideListener); + hideAnimatorSet.setInterpolator(INTERPOLATOR); + hideAnimatorSet.playTogether(hideAllAnimators); + + hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR); + hideAllButMaskAnimatorSet.playTogether(hides); + + hideMaskAnimatorSet.addListener(hideListener); + hideMaskAnimatorSet.setInterpolator(INTERPOLATOR); + hideMaskAnimatorSet.playTogether(overlayHideAnim); + } + + public interface OnHideListener { + void onHide(); + } + + public interface OnReactionSelectedListener { + void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); + void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji); + } + + private static class Boundary { + private float min; + private float max; + + Boundary() {} + + Boundary(float min, float max) { + update(min, max); + } + + private void update(float min, float max) { + this.min = min; + this.max = max; + } + + public boolean contains(float value) { + if (min < max) { + return this.min < value && this.max > value; + } else { + return this.min > value && this.max < value; + } + } + } + + private enum ReactionEmoji { + HEART(R.id.reaction_1, "\u2764\ufe0f"), + THUMBS_UP(R.id.reaction_2, "\ud83d\udc4d"), + THUMBS_DOWN(R.id.reaction_3, "\ud83d\udc4e"), + LAUGH(R.id.reaction_4, "\ud83d\ude02"), + SURPRISE(R.id.reaction_5, "\ud83d\ude2e"), + SAD(R.id.reaction_6, "\ud83d\ude22"), + ANGRY(R.id.reaction_7, "\ud83d\ude21"); + + final @IdRes int viewId; + final String emoji; + + ReactionEmoji(int viewId, String emoji) { + this.viewId = viewId; + this.emoji = emoji; + } + } + + private enum OverlayState { + HIDDEN, + UNINITAILIZED, + DEADZONE, + SCRUB, + TAP + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java new file mode 100644 index 00000000..2c887275 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; + +import java.util.concurrent.Executor; + +class ConversationRepository { + + private final Context context; + private final Executor executor; + + ConversationRepository() { + this.context = ApplicationDependencies.getApplication(); + this.executor = SignalExecutors.BOUNDED; + } + + LiveData getConversationData(long threadId, int jumpToPosition) { + MutableLiveData liveData = new MutableLiveData<>(); + + executor.execute(() -> { + liveData.postValue(getConversationDataInternal(threadId, jumpToPosition)); + }); + + return liveData; + } + + @WorkerThread + boolean canShowAsBubble(long threadId) { + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + return recipient != null && BubbleUtil.canBubble(context, recipient.getId(), threadId); + } else { + return false; + } + } + + private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) { + ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); + int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); + + long lastSeen = metadata.getLastSeen(); + boolean hasSent = metadata.hasSent(); + int lastSeenPosition = 0; + long lastScrolled = metadata.getLastScrolled(); + int lastScrolledPosition = 0; + + boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId); + + if (lastSeen > 0) { + lastSeenPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastSeen); + } + + if (lastSeenPosition <= 0) { + lastSeen = 0; + } + + if (lastSeen == 0 && lastScrolled > 0) { + lastScrolledPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastScrolled); + } + + return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, isMessageRequestAccepted, jumpToPosition, threadSize); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java new file mode 100644 index 00000000..a72550c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.database.CursorList; +import org.thoughtcrime.securesms.search.SearchRepository; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; + +public class ConversationSearchViewModel extends AndroidViewModel { + + private final SearchRepository searchRepository; + private final MutableLiveData result; + private final Debouncer debouncer; + + private boolean firstSearch; + private boolean searchOpen; + private String activeQuery; + private long activeThreadId; + + public ConversationSearchViewModel(@NonNull Application application) { + super(application); + result = new MutableLiveData<>(); + debouncer = new Debouncer(500); + searchRepository = new SearchRepository(); + } + + LiveData getSearchResults() { + return result; + } + + void onQueryUpdated(@NonNull String query, long threadId, boolean forced) { + if (firstSearch && query.length() < 2) { + result.postValue(new SearchResult(CursorList.emptyList(), 0)); + return; + } + + if (query.equals(activeQuery) && !forced) { + return; + } + + updateQuery(query, threadId); + } + + void onMissingResult() { + if (activeQuery != null) { + updateQuery(activeQuery, activeThreadId); + } + } + + void onMoveUp() { + if (result.getValue() == null) { + return; + } + + debouncer.clear(); + + List messages = result.getValue().getResults(); + int position = Math.min(result.getValue().getPosition() + 1, messages.size() - 1); + + result.setValue(new SearchResult(messages, position)); + } + + void onMoveDown() { + if (result.getValue() == null) { + return; + } + + debouncer.clear(); + + List messages = result.getValue().getResults(); + int position = Math.max(result.getValue().getPosition() - 1, 0); + + result.setValue(new SearchResult(messages, position)); + } + + + void onSearchOpened() { + searchOpen = true; + firstSearch = true; + } + + void onSearchClosed() { + searchOpen = false; + debouncer.clear(); + } + + private void updateQuery(@NonNull String query, long threadId) { + activeQuery = query; + activeThreadId = threadId; + + debouncer.publish(() -> { + firstSearch = false; + + searchRepository.query(query, threadId, messages -> { + Util.runOnMain(() -> { + if (searchOpen && query.equals(activeQuery)) { + result.setValue(new SearchResult(messages, 0)); + } + }); + }); + }); + } + + static class SearchResult { + + private final List results; + private final int position; + + SearchResult(@NonNull List results, int position) { + this.results = results; + this.position = position; + } + + public List getResults() { + return results; + } + + public int getPosition() { + return position; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerSuggestionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerSuggestionAdapter.java new file mode 100644 index 00000000..0eec9603 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerSuggestionAdapter.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +public class ConversationStickerSuggestionAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List stickers; + + public ConversationStickerSuggestionAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.stickers = new ArrayList<>(); + } + + @Override + public @NonNull StickerSuggestionViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new StickerSuggestionViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.sticker_suggestion_list_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull StickerSuggestionViewHolder viewHolder, int i) { + viewHolder.bind(glideRequests, eventListener, stickers.get(i)); + } + + @Override + public void onViewRecycled(@NonNull StickerSuggestionViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return stickers.size(); + } + + public void setStickers(@NonNull List stickers) { + this.stickers.clear(); + this.stickers.addAll(stickers); + notifyDataSetChanged(); + } + + static class StickerSuggestionViewHolder extends RecyclerView.ViewHolder { + + private final ImageView image; + + StickerSuggestionViewHolder(@NonNull View itemView) { + super(itemView); + this.image = itemView.findViewById(R.id.sticker_suggestion_item_image); + } + + void bind(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, @NonNull StickerRecord sticker) { + glideRequests.load(new DecryptableUri(sticker.getUri())) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(image); + + itemView.setOnClickListener(v -> { + eventListener.onStickerSuggestionClicked(sticker); + }); + } + + void recycle() { + itemView.setOnClickListener(null); + } + } + + public interface EventListener { + void onStickerSuggestionClicked(@NonNull StickerRecord sticker); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java new file mode 100644 index 00000000..4c0f8ec1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Application; +import android.database.ContentObserver; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.database.CursorList; +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.stickers.StickerSearchRepository; +import org.thoughtcrime.securesms.util.Throttler; + +import java.util.List; + +class ConversationStickerViewModel extends ViewModel { + + private final Application application; + private final StickerSearchRepository repository; + private final MutableLiveData> stickers; + private final MutableLiveData stickersAvailable; + private final Throttler availabilityThrottler; + private final ContentObserver packObserver; + + private ConversationStickerViewModel(@NonNull Application application, @NonNull StickerSearchRepository repository) { + this.application = application; + this.repository = repository; + this.stickers = new MutableLiveData<>(); + this.stickersAvailable = new MutableLiveData<>(); + this.availabilityThrottler = new Throttler(500); + this.packObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + availabilityThrottler.publish(() -> repository.getStickerFeatureAvailability(stickersAvailable::postValue)); + } + }; + + application.getContentResolver().registerContentObserver(DatabaseContentProviders.StickerPack.CONTENT_URI, true, packObserver); + } + + @NonNull LiveData> getStickerResults() { + return stickers; + } + + @NonNull LiveData getStickersAvailability() { + repository.getStickerFeatureAvailability(stickersAvailable::postValue); + return stickersAvailable; + } + + void onInputTextUpdated(@NonNull String text) { + if (TextUtils.isEmpty(text) || text.length() > EmojiUtil.MAX_EMOJI_LENGTH) { + stickers.setValue(CursorList.emptyList()); + } else { + repository.searchByEmoji(text, stickers::postValue); + } + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(packObserver); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + private final Application application; + private final StickerSearchRepository repository; + + public Factory(@NonNull Application application, @NonNull StickerSearchRepository repository) { + this.application = application; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ConversationStickerViewModel(application, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java new file mode 100644 index 00000000..dac6af5e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.conversation; + +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.view.View; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.Util; + +final class ConversationSwipeAnimationHelper { + + static final float TRIGGER_DX = dpToPx(64); + static final float MAX_DX = dpToPx(96); + + private static final float REPLY_SCALE_OVERSHOOT = 1.8f; + private static final float REPLY_SCALE_MAX = 1.2f; + private static final float REPLY_SCALE_MIN = 1f; + private static final long REPLY_SCALE_OVERSHOOT_DURATION = 200; + + private static final Interpolator BUBBLE_INTERPOLATOR = new BubblePositionInterpolator(0f, TRIGGER_DX, MAX_DX); + private static final Interpolator REPLY_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(0f, 1f, 1f); + private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10)); + private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8)); + private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX); + + private ConversationSwipeAnimationHelper() { + } + + public static void update(@NonNull ConversationItem conversationItem, float dx, float sign) { + float progress = dx / TRIGGER_DX; + + updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign); + updateReactionsTransition(conversationItem.reactionsView, dx, sign); + updateReplyIconTransition(conversationItem.reply, dx, progress, sign); + updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign); + } + + public static void trigger(@NonNull ConversationItem conversationItem) { + triggerReplyIcon(conversationItem.reply); + } + + private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float dx, float sign) { + bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } + + private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) { + reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } + + private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) { + if (progress > 0.05f) { + replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress)); + } else replyIcon.setAlpha(0f); + + replyIcon.setTranslationX(REPLY_TRANSITION_INTERPOLATOR.getInterpolation(progress) * sign); + + if (dx < TRIGGER_DX) { + float scale = REPLY_SCALE_INTERPOLATOR.getInterpolation(progress); + replyIcon.setScaleX(scale); + replyIcon.setScaleY(scale); + } + } + + private static void updateContactPhotoHolderTransition(@Nullable View contactPhotoHolder, + float progress, + float sign) + { + if (contactPhotoHolder == null) return; + contactPhotoHolder.setTranslationX(AVATAR_INTERPOLATOR.getInterpolation(progress) * sign); + } + + private static void triggerReplyIcon(@NonNull View replyIcon) { + ValueAnimator animator = ValueAnimator.ofFloat(REPLY_SCALE_MAX, REPLY_SCALE_OVERSHOOT, REPLY_SCALE_MAX); + animator.setDuration(REPLY_SCALE_OVERSHOOT_DURATION); + animator.addUpdateListener(animation -> { + replyIcon.setScaleX((float) animation.getAnimatedValue()); + replyIcon.setScaleY((float) animation.getAnimatedValue()); + }); + animator.start(); + } + + private static int dpToPx(int dp) { + return (int) (dp * Resources.getSystem().getDisplayMetrics().density); + } + + private static final class BubblePositionInterpolator implements Interpolator { + + private final float start; + private final float middle; + private final float end; + + private BubblePositionInterpolator(float start, float middle, float end) { + this.start = start; + this.middle = middle; + this.end = end; + } + + @Override + public float getInterpolation(float input) { + if (input < start) { + return start; + } else if (input < middle) { + return input; + } else { + float segmentLength = end - middle; + float segmentTraveled = input - middle; + float segmentCompletion = segmentTraveled / segmentLength; + float scaleDownFactor = middle / (input * 2); + float output = middle + (segmentLength * segmentCompletion * scaleDownFactor); + + return Math.min(output, end); + } + } + } + + private static final class ClampingLinearInterpolator implements Interpolator { + + private final float slope; + private final float yIntercept; + private final float max; + private final float min; + + ClampingLinearInterpolator(float start, float end) { + this(start, end, 1.0f); + } + + ClampingLinearInterpolator(float start, float end, float scale) { + slope = (end - start) * scale; + yIntercept = start; + max = Math.max(start, end); + min = Math.min(start, end); + } + + @Override + public float getInterpolation(float input) { + return Util.clamp(slope * input + yIntercept, min, max); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java new file mode 100644 index 00000000..25ef7cfa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.widget.TextViewCompat; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class ConversationTitleView extends RelativeLayout { + + private AvatarImageView avatar; + private TextView title; + private TextView subtitle; + private ImageView verified; + private View subtitleContainer; + private View verifiedSubtitle; + private View expirationBadgeContainer; + private TextView expirationBadgeTime; + + public ConversationTitleView(Context context) { + this(context, null); + } + + public ConversationTitleView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + + this.title = findViewById(R.id.title); + this.subtitle = findViewById(R.id.subtitle); + this.verified = findViewById(R.id.verified_indicator); + this.subtitleContainer = findViewById(R.id.subtitle_container); + this.verifiedSubtitle = findViewById(R.id.verified_subtitle); + this.avatar = findViewById(R.id.contact_photo_image); + this.expirationBadgeContainer = findViewById(R.id.expiration_badge_container); + this.expirationBadgeTime = findViewById(R.id.expiration_badge); + + ViewUtil.setTextViewGravityStart(this.title, getContext()); + ViewUtil.setTextViewGravityStart(this.subtitle, getContext()); + } + + public void showExpiring(@NonNull LiveRecipient recipient) { + expirationBadgeTime.setText(ExpirationUtil.getExpirationAbbreviatedDisplayValue(getContext(), recipient.get().getExpireMessages())); + expirationBadgeContainer.setVisibility(View.VISIBLE); + updateSubtitleVisibility(); + } + + public void clearExpiring() { + expirationBadgeContainer.setVisibility(View.GONE); + updateSubtitleVisibility(); + } + + public void setTitle(@NonNull GlideRequests glideRequests, @Nullable Recipient recipient) { + this.subtitleContainer.setVisibility(View.VISIBLE); + + if (recipient == null) setComposeTitle(); + else setRecipientTitle(recipient); + + int startDrawable = 0; + int endDrawable = 0; + + if (recipient != null && recipient.isBlocked()) { + startDrawable = R.drawable.ic_block_white_18dp; + } else if (recipient != null && recipient.isMuted()) { + startDrawable = R.drawable.ic_volume_off_white_18dp; + } + + if (recipient != null && recipient.isSystemContact() && !recipient.isSelf()) { + endDrawable = R.drawable.ic_profile_circle_outline_16; + } + + title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, 0, endDrawable, 0); + TextViewCompat.setCompoundDrawableTintList(title, ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_80))); + + if (recipient != null) { + this.avatar.setAvatar(glideRequests, recipient, false); + } + + updateVerifiedSubtitleVisibility(); + } + + public void setVerified(boolean verified) { + this.verified.setVisibility(verified ? View.VISIBLE : View.GONE); + + updateVerifiedSubtitleVisibility(); + } + + private void setComposeTitle() { + this.title.setText(R.string.ConversationActivity_compose_message); + this.subtitle.setText(null); + updateSubtitleVisibility(); + } + + private void setRecipientTitle(@NonNull Recipient recipient) { + if (recipient.isGroup()) setGroupRecipientTitle(recipient); + else if (recipient.isSelf()) setSelfTitle(); + else setIndividualRecipientTitle(recipient); + } + + private void setGroupRecipientTitle(@NonNull Recipient recipient) { + this.title.setText(recipient.getDisplayName(getContext())); + this.subtitle.setText(Stream.of(recipient.getParticipants()) + .sorted((a, b) -> Boolean.compare(a.isSelf(), b.isSelf())) + .map(r -> r.isSelf() ? getResources().getString(R.string.ConversationTitleView_you) + : r.getDisplayName(getContext())) + .collect(Collectors.joining(", "))); + + updateSubtitleVisibility(); + } + + private void setSelfTitle() { + this.title.setText(R.string.note_to_self); + this.subtitleContainer.setVisibility(View.GONE); + } + + private void setIndividualRecipientTitle(@NonNull Recipient recipient) { + final String displayName = recipient.getDisplayNameOrUsername(getContext()); + this.title.setText(displayName); + this.subtitle.setText(null); + updateSubtitleVisibility(); + updateVerifiedSubtitleVisibility(); + } + + private void updateVerifiedSubtitleVisibility() { + verifiedSubtitle.setVisibility(subtitle.getVisibility() != VISIBLE && verified.getVisibility() == VISIBLE ? VISIBLE : GONE); + } + + private void updateSubtitleVisibility() { + subtitle.setVisibility(expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE); + updateVerifiedSubtitleVisibility(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java new file mode 100644 index 00000000..183bdc86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -0,0 +1,443 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.text.Spannable; +import android.text.SpannableString; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; + +import com.google.android.material.button.MaterialButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BindableConversationItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; +import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.UpdateDescription; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collection; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +public final class ConversationUpdateItem extends FrameLayout + implements BindableConversationItem +{ + private static final String TAG = ConversationUpdateItem.class.getSimpleName(); + + private Set batchSelected; + + private TextView body; + private MaterialButton actionButton; + private View background; + private ConversationMessage conversationMessage; + private Recipient conversationRecipient; + private Optional nextMessageRecord; + private MessageRecord messageRecord; + private LiveData displayBody; + private EventListener eventListener; + + private final UpdateObserver updateObserver = new UpdateObserver(); + + private final PresentOnChange presentOnChange = new PresentOnChange(); + private final RecipientObserverManager senderObserver = new RecipientObserverManager(presentOnChange); + private final RecipientObserverManager groupObserver = new RecipientObserverManager(presentOnChange); + + public ConversationUpdateItem(Context context) { + super(context); + } + + public ConversationUpdateItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.body = findViewById(R.id.conversation_update_body); + this.actionButton = findViewById(R.id.conversation_update_action); + this.background = findViewById(R.id.conversation_update_background); + + this.setOnClickListener(new InternalClickListener(null)); + } + + @Override + public void bind(@NonNull LifecycleOwner lifecycleOwner, + @NonNull ConversationMessage conversationMessage, + @NonNull Optional previousMessageRecord, + @NonNull Optional nextMessageRecord, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set batchSelected, + @NonNull Recipient conversationRecipient, + @Nullable String searchQuery, + boolean pulseMention, + boolean hasWallpaper, + boolean isMessageRequestAccepted) + { + this.batchSelected = batchSelected; + + bind(lifecycleOwner, conversationMessage, previousMessageRecord, nextMessageRecord, conversationRecipient, hasWallpaper); + } + + @Override + public void setEventListener(@Nullable EventListener listener) { + this.eventListener = listener; + } + + @Override + public ConversationMessage getConversationMessage() { + return conversationMessage; + } + + private void bind(@NonNull LifecycleOwner lifecycleOwner, + @NonNull ConversationMessage conversationMessage, + @NonNull Optional previousMessageRecord, + @NonNull Optional nextMessageRecord, + @NonNull Recipient conversationRecipient, + boolean hasWallpaper) + { + this.conversationMessage = conversationMessage; + this.messageRecord = conversationMessage.getMessageRecord(); + this.nextMessageRecord = nextMessageRecord; + this.conversationRecipient = conversationRecipient; + + senderObserver.observe(lifecycleOwner, messageRecord.getIndividualRecipient()); + + if (conversationRecipient.isActiveGroup() && conversationMessage.getMessageRecord().isGroupCall()) { + groupObserver.observe(lifecycleOwner, conversationRecipient); + } else { + groupObserver.observe(lifecycleOwner, null); + } + + int textColor = ContextCompat.getColor(getContext(), R.color.conversation_item_update_text_color); + if (ThemeUtil.isDarkTheme(getContext()) && hasWallpaper) { + textColor = ContextCompat.getColor(getContext(), R.color.core_grey_15); + } + + if (!ThemeUtil.isDarkTheme(getContext())) { + if (hasWallpaper) { + actionButton.setStrokeColor(ColorStateList.valueOf(getResources().getColor(R.color.core_grey_45))); + } else { + actionButton.setStrokeColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_button_secondary_stroke))); + } + } + + UpdateDescription updateDescription = Objects.requireNonNull(messageRecord.getUpdateDisplayBody(getContext())); + LiveData liveUpdateMessage = LiveUpdateMessage.fromMessageDescription(getContext(), updateDescription, textColor); + LiveData spannableMessage = loading(liveUpdateMessage); + + observeDisplayBody(lifecycleOwner, spannableMessage); + + present(conversationMessage, nextMessageRecord, conversationRecipient); + + presentBackground(shouldCollapse(messageRecord, previousMessageRecord), + shouldCollapse(messageRecord, nextMessageRecord), + hasWallpaper); + } + + private static boolean shouldCollapse(@NonNull MessageRecord current, @NonNull Optional candidate) + { + return candidate.isPresent() && + candidate.get().isUpdate() && + DateUtils.isSameDay(current.getTimestamp(), candidate.get().getTimestamp()) && + isSameType(current, candidate.get()); + } + + /** After a short delay, if the main data hasn't shown yet, then a loading message is displayed. */ + private @NonNull LiveData loading(@NonNull LiveData string) { + return LiveDataUtil.until(string, LiveDataUtil.delay(250, new SpannableString(getContext().getString(R.string.ConversationUpdateItem_loading)))); + } + + @Override + public void unbind() { + } + + static final class RecipientObserverManager { + + private final Observer recipientObserver; + + private LiveRecipient recipient; + + RecipientObserverManager(@NonNull Observer observer){ + this.recipientObserver = observer; + } + + public void observe(@NonNull LifecycleOwner lifecycleOwner, @Nullable Recipient recipient) { + if (this.recipient != null) { + this.recipient.getLiveData().removeObserver(recipientObserver); + } + + if (recipient != null) { + this.recipient = recipient.live(); + this.recipient.getLiveData().observe(lifecycleOwner, recipientObserver); + } else { + this.recipient = null; + } + } + + @NonNull Recipient getObservedRecipient() { + return recipient.get(); + } + } + + private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData displayBody) { + if (this.displayBody != displayBody) { + if (this.displayBody != null) { + this.displayBody.removeObserver(updateObserver); + } + + this.displayBody = displayBody; + + if (this.displayBody != null) { + this.displayBody.observe(lifecycleOwner, updateObserver); + } + } + } + + private void setBodyText(@Nullable CharSequence text) { + if (text == null) { + body.setVisibility(INVISIBLE); + } else { + body.setText(text); + body.setVisibility(VISIBLE); + } + } + + private void present(@NonNull ConversationMessage conversationMessage, + @NonNull Optional nextMessageRecord, + @NonNull Recipient conversationRecipient) + { + if (batchSelected.contains(conversationMessage)) setSelected(true); + else setSelected(false); + + if (conversationMessage.getMessageRecord().isGroupV1MigrationEvent() && + (!nextMessageRecord.isPresent() || !nextMessageRecord.get().isGroupV1MigrationEvent())) + { + actionButton.setText(R.string.ConversationUpdateItem_learn_more); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onGroupMigrationLearnMoreClicked(conversationMessage.getMessageRecord().getGroupV1MigrationMembershipChanges()); + } + }); + } else if (conversationMessage.getMessageRecord().isFailedDecryptionType() && + (!nextMessageRecord.isPresent() || !nextMessageRecord.get().isFailedDecryptionType())) + { + actionButton.setText(R.string.ConversationUpdateItem_learn_more); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onDecryptionFailedLearnMoreClicked(); + } + }); + } else if (conversationMessage.getMessageRecord().isIdentityUpdate()) { + actionButton.setText(R.string.ConversationUpdateItem_learn_more); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onSafetyNumberLearnMoreClicked(conversationMessage.getMessageRecord().getIndividualRecipient()); + } + }); + } else if (conversationMessage.getMessageRecord().isGroupCall()) { + UpdateDescription updateDescription = MessageRecord.getGroupCallUpdateDescription(getContext(), conversationMessage.getMessageRecord().getBody(), true); + Collection uuids = updateDescription.getMentioned(); + + int text = 0; + if (Util.hasItems(uuids)) { + if (uuids.contains(TextSecurePreferences.getLocalUuid(getContext()))) { + text = R.string.ConversationUpdateItem_return_to_call; + } else if (GroupCallUpdateDetailsUtil.parse(conversationMessage.getMessageRecord().getBody()).getIsCallFull()) { + text = R.string.ConversationUpdateItem_call_is_full; + } else { + text = R.string.ConversationUpdateItem_join_call; + } + } + + if (text != 0 && conversationRecipient.isGroup() && conversationRecipient.isActiveGroup()) { + actionButton.setText(text); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onJoinGroupCallClicked(); + } + }); + } else { + actionButton.setVisibility(GONE); + actionButton.setOnClickListener(null); + } + } else if (conversationMessage.getMessageRecord().isSelfCreatedGroup()) { + actionButton.setText(R.string.ConversationUpdateItem_invite_friends); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onInviteFriendsToGroupClicked(conversationRecipient.requireGroupId().requireV2()); + } + }); + } else { + actionButton.setVisibility(GONE); + actionButton.setOnClickListener(null); + } + } + + private void presentBackground(boolean collapseAbove, boolean collapseBelow, boolean hasWallpaper) { + int marginDefault = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_update_vertical_margin); + int marginCollapsed = 0; + int paddingDefault = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_update_vertical_padding); + int paddingCollapsed = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_update_vertical_padding_collapsed); + + if (collapseAbove && collapseBelow) { + ViewUtil.setTopMargin(background, marginCollapsed); + ViewUtil.setBottomMargin(background, marginCollapsed); + + ViewUtil.setPaddingTop(background, paddingCollapsed); + ViewUtil.setPaddingBottom(background, paddingCollapsed); + + ViewUtil.updateLayoutParams(background, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + if (hasWallpaper) { + background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_middle); + } else { + background.setBackground(null); + } + } else if (collapseAbove) { + ViewUtil.setTopMargin(background, marginCollapsed); + ViewUtil.setBottomMargin(background, marginDefault); + + ViewUtil.setPaddingTop(background, paddingCollapsed); + ViewUtil.setPaddingBottom(background, paddingDefault); + + ViewUtil.updateLayoutParams(background, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + if (hasWallpaper) { + background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_bottom); + } else { + background.setBackground(null); + } + } else if (collapseBelow) { + ViewUtil.setTopMargin(background, marginDefault); + ViewUtil.setBottomMargin(background, marginCollapsed); + + ViewUtil.setPaddingTop(background, paddingDefault); + ViewUtil.setPaddingBottom(background, paddingCollapsed); + + ViewUtil.updateLayoutParams(background, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + if (hasWallpaper) { + background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_top); + } else { + background.setBackground(null); + } + } else { + ViewUtil.setTopMargin(background, marginDefault); + ViewUtil.setBottomMargin(background, marginDefault); + + ViewUtil.setPaddingTop(background, paddingDefault); + ViewUtil.setPaddingBottom(background, paddingDefault); + + ViewUtil.updateLayoutParams(background, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + if (hasWallpaper) { + background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_singular); + } else { + background.setBackground(null); + } + } + } + + private static boolean isSameType(@NonNull MessageRecord current, @NonNull MessageRecord candidate) { + return (current.isGroupUpdate() && candidate.isGroupUpdate()) || + (current.isProfileChange() && candidate.isProfileChange()) || + (current.isGroupCall() && candidate.isGroupCall()) || + (current.isExpirationTimerUpdate() && candidate.isExpirationTimerUpdate()); + } + + @Override + public void setOnClickListener(View.OnClickListener l) { + super.setOnClickListener(new InternalClickListener(l)); + } + + private final class PresentOnChange implements Observer { + + @Override + public void onChanged(Recipient recipient) { + if (recipient.getId() == conversationRecipient.getId() && (conversationRecipient == null || !conversationRecipient.hasSameContent(recipient))) { + conversationRecipient = recipient; + present(conversationMessage, nextMessageRecord, conversationRecipient); + } + } + } + + private final class UpdateObserver implements Observer { + + @Override + public void onChanged(Spannable update) { + setBodyText(update); + } + } + + private class InternalClickListener implements View.OnClickListener { + + @Nullable private final View.OnClickListener parent; + + InternalClickListener(@Nullable View.OnClickListener parent) { + this.parent = parent; + } + + @Override + public void onClick(View v) { + if ((!messageRecord.isIdentityUpdate() && + !messageRecord.isIdentityDefault() && + !messageRecord.isIdentityVerified()) || + !batchSelected.isEmpty()) + { + if (parent != null) parent.onClick(v); + return; + } + + final Recipient sender = ConversationUpdateItem.this.senderObserver.getObservedRecipient(); + + IdentityUtil.getRemoteIdentityKey(getContext(), sender).addListener(new ListenableFuture.Listener>() { + @Override + public void onSuccess(Optional result) { + if (result.isPresent()) { + getContext().startActivity(VerifyIdentityActivity.newIntent(getContext(), result.get())); + } + } + + @Override + public void onFailure(ExecutionException e) { + Log.w(TAG, e); + } + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java new file mode 100644 index 00000000..37b4bd69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Application; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.logging.Log; +import org.signal.paging.PagedData; +import org.signal.paging.PagingConfig; +import org.signal.paging.PagingController; +import org.signal.paging.ProxyPagingController; +import org.thoughtcrime.securesms.database.DatabaseObserver; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaRepository; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.whispersystems.libsignal.util.Pair; + +import java.util.List; +import java.util.Objects; + +class ConversationViewModel extends ViewModel { + + private static final String TAG = Log.tag(ConversationViewModel.class); + + private final Application context; + private final MediaRepository mediaRepository; + private final ConversationRepository conversationRepository; + private final MutableLiveData> recentMedia; + private final MutableLiveData threadId; + private final LiveData> messages; + private final LiveData conversationMetadata; + private final MutableLiveData showScrollButtons; + private final MutableLiveData hasUnreadMentions; + private final LiveData canShowAsBubble; + private final ProxyPagingController pagingController; + private final DatabaseObserver.Observer messageObserver; + private final MutableLiveData recipientId; + private final LiveData wallpaper; + + private ConversationIntents.Args args; + private int jumpToPosition; + + private ConversationViewModel() { + this.context = ApplicationDependencies.getApplication(); + this.mediaRepository = new MediaRepository(); + this.conversationRepository = new ConversationRepository(); + this.recentMedia = new MutableLiveData<>(); + this.threadId = new MutableLiveData<>(); + this.showScrollButtons = new MutableLiveData<>(false); + this.hasUnreadMentions = new MutableLiveData<>(false); + this.recipientId = new MutableLiveData<>(); + this.pagingController = new ProxyPagingController(); + this.messageObserver = pagingController::onDataInvalidated; + + LiveData metadata = Transformations.switchMap(threadId, thread -> { + LiveData conversationData = conversationRepository.getConversationData(thread, jumpToPosition); + + jumpToPosition = -1; + + return conversationData; + }); + + LiveData>> pagedDataForThreadId = Transformations.map(metadata, data -> { + final int startPosition; + if (data.shouldJumpToMessage()) { + startPosition = data.getJumpToPosition(); + } else if (data.isMessageRequestAccepted() && data.shouldScrollToLastSeen()) { + startPosition = data.getLastSeenPosition(); + } else if (data.isMessageRequestAccepted()) { + startPosition = data.getLastScrolledPosition(); + } else { + startPosition = data.getThreadSize(); + } + + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver); + ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), messageObserver); + + ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId()); + PagingConfig config = new PagingConfig.Builder() + .setPageSize(25) + .setBufferPages(3) + .setStartIndex(Math.max(startPosition, 0)) + .build(); + + Log.d(TAG, "Starting at position: " + startPosition + " || jumpToPosition: " + data.getJumpToPosition() + ", lastSeenPosition: " + data.getLastSeenPosition() + ", lastScrolledPosition: " + data.getLastScrolledPosition()); + return new Pair<>(data.getThreadId(), PagedData.create(dataSource, config)); + }); + + this.messages = Transformations.switchMap(pagedDataForThreadId, pair -> { + pagingController.set(pair.second().getController()); + return pair.second().getData(); + }); + + conversationMetadata = Transformations.switchMap(messages, m -> metadata); + canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble); + wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId, + id -> Recipient.live(id).getLiveData()), + Recipient::getWallpaper)); + } + + void onAttachmentKeyboardOpen() { + mediaRepository.getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, recentMedia::postValue); + } + + @MainThread + void onConversationDataAvailable(@NonNull RecipientId recipientId, long threadId, int startingPosition) { + Log.d(TAG, "[onConversationDataAvailable] threadId: " + threadId + ", startingPosition: " + startingPosition); + this.jumpToPosition = startingPosition; + + this.threadId.setValue(threadId); + this.recipientId.setValue(recipientId); + } + + void clearThreadId() { + this.jumpToPosition = -1; + this.threadId.postValue(-1L); + } + + @NonNull LiveData canShowAsBubble() { + return canShowAsBubble; + } + + @NonNull LiveData getShowScrollToBottom() { + return Transformations.distinctUntilChanged(showScrollButtons); + } + + @NonNull LiveData getShowMentionsButton() { + return Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(showScrollButtons, hasUnreadMentions, (a, b) -> a && b)); + } + + @NonNull LiveData getWallpaper() { + return wallpaper; + } + + void setHasUnreadMentions(boolean hasUnreadMentions) { + this.hasUnreadMentions.setValue(hasUnreadMentions); + } + + void setShowScrollButtons(boolean showScrollButtons) { + this.showScrollButtons.setValue(showScrollButtons); + } + + @NonNull LiveData> getRecentMedia() { + return recentMedia; + } + + @NonNull LiveData getConversationMetadata() { + return conversationMetadata; + } + + @NonNull LiveData> getMessages() { + return messages; + } + + @NonNull PagingController getPagingController() { + return pagingController; + } + + long getLastSeen() { + return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0; + } + + int getLastSeenPosition() { + return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeenPosition() : 0; + } + + void setArgs(@NonNull ConversationIntents.Args args) { + this.args = args; + } + + @NonNull ConversationIntents.Args getArgs() { + return Objects.requireNonNull(args); + } + + @Override + protected void onCleared() { + super.onCleared(); + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ConversationViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/LastSeenHeader.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/LastSeenHeader.java new file mode 100644 index 00000000..e224305d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/LastSeenHeader.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; + +class LastSeenHeader extends StickyHeaderDecoration { + + private final ConversationAdapter adapter; + private final long lastSeenTimestamp; + + LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) { + super(adapter, false, false, ConversationAdapter.HEADER_TYPE_LAST_SEEN); + this.adapter = adapter; + this.lastSeenTimestamp = lastSeenTimestamp; + } + + @Override + protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) { + if (lastSeenTimestamp <= 0) { + return false; + } + + long currentRecordTimestamp = adapter.getReceivedTimestamp(position); + long previousRecordTimestamp = adapter.getReceivedTimestamp(position + 1); + + return currentRecordTimestamp > lastSeenTimestamp && previousRecordTimestamp < lastSeenTimestamp; + } + + @Override + protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) { + return parent.getLayoutManager().getDecoratedTop(child); + } + + @Override + protected @NonNull RecyclerView.ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) { + StickyHeaderViewHolder viewHolder = new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_last_seen, parent, false)); + adapter.onBindLastSeenViewHolder(viewHolder, position); + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), viewHolder.itemView.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), viewHolder.itemView.getLayoutParams().height); + + viewHolder.itemView.measure(childWidth, childHeight); + viewHolder.itemView.layout(0, 0, viewHolder.itemView.getMeasuredWidth(), viewHolder.itemView.getMeasuredHeight()); + + return viewHolder; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java new file mode 100644 index 00000000..a4ad5096 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.MarkReadReceiver; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; + +import java.util.List; +import java.util.concurrent.Executor; + +class MarkReadHelper { + private static final String TAG = Log.tag(MarkReadHelper.class); + + private static final long DEBOUNCE_TIMEOUT = 100; + private static final Executor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED); + + private final long threadId; + private final Context context; + private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT); + private long latestTimestamp; + + MarkReadHelper(long threadId, @NonNull Context context) { + this.threadId = threadId; + this.context = context.getApplicationContext(); + } + + public void onViewsRevealed(long timestamp) { + if (timestamp <= latestTimestamp) { + return; + } + + latestTimestamp = timestamp; + + debouncer.publish(() -> { + EXECUTOR.execute(() -> { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + List infos = threadDatabase.setReadSince(threadId, false, timestamp); + + Log.d(TAG, "Marking " + infos.size() + " messages as read."); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, infos); + }); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java new file mode 100644 index 00000000..5609bc43 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -0,0 +1,183 @@ +package org.thoughtcrime.securesms.conversation; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Set; + +final class MenuState { + + private final boolean forward; + private final boolean reply; + private final boolean details; + private final boolean saveAttachment; + private final boolean resend; + private final boolean copy; + + private MenuState(@NonNull Builder builder) { + forward = builder.forward; + reply = builder.reply; + details = builder.details; + saveAttachment = builder.saveAttachment; + resend = builder.resend; + copy = builder.copy; + } + + boolean shouldShowForwardAction() { + return forward; + } + + boolean shouldShowReplyAction() { + return reply; + } + + boolean shouldShowDetailsAction() { + return details; + } + + boolean shouldShowSaveAttachmentAction() { + return saveAttachment; + } + + boolean shouldShowResendAction() { + return resend; + } + + boolean shouldShowCopyAction() { + return copy; + } + + static MenuState getMenuState(@NonNull Recipient conversationRecipient, + @NonNull Set messageRecords, + boolean shouldShowMessageRequest) + { + + Builder builder = new Builder(); + boolean actionMessage = false; + boolean hasText = false; + boolean sharedContact = false; + boolean viewOnce = false; + boolean remoteDelete = false; + + for (MessageRecord messageRecord : messageRecords) { + if (isActionMessage(messageRecord)) + { + actionMessage = true; + } + + if (messageRecord.getBody().length() > 0) { + hasText = true; + } + + if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) { + sharedContact = true; + } + + if (messageRecord.isViewOnce()) { + viewOnce = true; + } + + if (messageRecord.isRemoteDelete()) { + remoteDelete = true; + } + } + + if (messageRecords.size() > 1) { + builder.shouldShowForwardAction(false) + .shouldShowReplyAction(false) + .shouldShowDetailsAction(false) + .shouldShowSaveAttachmentAction(false) + .shouldShowResendAction(false); + } else { + MessageRecord messageRecord = messageRecords.iterator().next(); + + builder.shouldShowResendAction(messageRecord.isFailed()) + .shouldShowSaveAttachmentAction(!actionMessage && + !viewOnce && + messageRecord.isMms() && + !messageRecord.isMediaPending() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null) + .shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce && !remoteDelete && !messageRecord.isMediaPending()) + .shouldShowDetailsAction(!actionMessage) + .shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest)); + } + + return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText) + .build(); + } + + static boolean canReplyToMessage(@NonNull Recipient conversationRecipient, boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) { + return !actionMessage && + !messageRecord.isRemoteDelete() && + !messageRecord.isPending() && + !messageRecord.isFailed() && + !isDisplayingMessageRequest && + messageRecord.isSecure() && + (!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) && + !messageRecord.getRecipient().isBlocked(); + } + + static boolean isActionMessage(@NonNull MessageRecord messageRecord) { + return messageRecord.isGroupAction() || + messageRecord.isCallLog() || + messageRecord.isJoined() || + messageRecord.isExpirationTimerUpdate() || + messageRecord.isEndSession() || + messageRecord.isIdentityUpdate() || + messageRecord.isIdentityVerified() || + messageRecord.isIdentityDefault() || + messageRecord.isProfileChange() || + messageRecord.isFailedDecryptionType(); + } + + private final static class Builder { + + private boolean forward; + private boolean reply; + private boolean details; + private boolean saveAttachment; + private boolean resend; + private boolean copy; + + @NonNull Builder shouldShowForwardAction(boolean forward) { + this.forward = forward; + return this; + } + + @NonNull Builder shouldShowReplyAction(boolean reply) { + this.reply = reply; + return this; + } + + @NonNull Builder shouldShowDetailsAction(boolean details) { + this.details = details; + return this; + } + + @NonNull Builder shouldShowSaveAttachmentAction(boolean saveAttachment) { + this.saveAttachment = saveAttachment; + return this; + } + + @NonNull Builder shouldShowResendAction(boolean resend) { + this.resend = resend; + return this; + } + + @NonNull Builder shouldShowCopyAction(boolean copy) { + this.copy = copy; + return this; + } + + @NonNull + MenuState build() { + return new MenuState(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageCountsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageCountsViewModel.java new file mode 100644 index 00000000..11381dd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageCountsViewModel.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.conversation; + +import android.app.Application; +import android.content.Context; +import android.database.ContentObserver; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; +import org.whispersystems.libsignal.util.Pair; + +import java.util.concurrent.Executor; + +public class MessageCountsViewModel extends ViewModel { + + private static final Executor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED); + + private final Application context; + private final MutableLiveData threadId = new MutableLiveData<>(-1L); + private final LiveData> unreadCounts; + + private ContentObserver observer; + + public MessageCountsViewModel() { + this.context = ApplicationDependencies.getApplication(); + this.unreadCounts = Transformations.switchMap(Transformations.distinctUntilChanged(threadId), id -> { + + MutableLiveData> counts = new MutableLiveData<>(new Pair<>(0, 0)); + + if (id == -1L) { + return counts; + } + + observer = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + EXECUTOR.execute(() -> { + counts.postValue(getCounts(context, id)); + }); + } + }; + + observer.onChange(false); + + context.getContentResolver().registerContentObserver(DatabaseContentProviders.Conversation.getUriForThread(id), true, observer); + + return counts; + }); + + } + + void setThreadId(long threadId) { + this.threadId.setValue(threadId); + } + + void clearThreadId() { + this.threadId.postValue(-1L); + } + + @NonNull LiveData getUnreadMessagesCount() { + return Transformations.map(unreadCounts, Pair::first); + } + + @NonNull LiveData getUnreadMentionsCount() { + return Transformations.map(unreadCounts, Pair::second); + } + + private Pair getCounts(@NonNull Context context, long threadId) { + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + int unreadCount = mmsSmsDatabase.getUnreadCount(threadId); + int unreadMentionCount = mmsDatabase.getUnreadMentionCount(threadId); + + return new Pair<>(unreadCount, unreadMentionCount); + } + + @Override + protected void onCleared() { + if (observer != null) { + context.getContentResolver().unregisterContentObserver(observer); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java new file mode 100644 index 00000000..0b474c52 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Wrapper class for helping show a list of recipients that had recent safety number changes. + * + * Also provides helper methods for behavior used in multiple spots. + */ +final class ChangedRecipient { + private final Recipient recipient; + private final IdentityRecord record; + + ChangedRecipient(@NonNull Recipient recipient, @NonNull IdentityRecord record) { + this.recipient = recipient; + this.record = record; + } + + @NonNull Recipient getRecipient() { + return recipient; + } + + @NonNull IdentityRecord getIdentityRecord() { + return record; + } + + boolean isUnverified() { + return record.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.UNVERIFIED; + } + + boolean isVerified() { + return record.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java new file mode 100644 index 00000000..33afb9b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; + +final class SafetyNumberChangeAdapter extends ListAdapter { + + private final Callbacks callbacks; + + SafetyNumberChangeAdapter(@NonNull Callbacks callbacks) { + super(new AlwaysChangedDiffUtil<>()); + this.callbacks = callbacks; + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.safety_number_change_recipient, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + final ChangedRecipient changedRecipient = getItem(position); + holder.bind(changedRecipient); + } + + class ViewHolder extends RecyclerView.ViewHolder { + + final AvatarImageView avatar; + final FromTextView name; + final TextView subtitle; + final View viewButton; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + avatar = itemView.findViewById(R.id.safety_number_change_recipient_avatar); + name = itemView.findViewById(R.id.safety_number_change_recipient_name); + subtitle = itemView.findViewById(R.id.safety_number_change_recipient_subtitle); + viewButton = itemView.findViewById(R.id.safety_number_change_recipient_view); + } + + void bind(@NonNull ChangedRecipient changedRecipient) { + avatar.setRecipient(changedRecipient.getRecipient()); + name.setText(changedRecipient.getRecipient()); + + if (changedRecipient.isUnverified() || changedRecipient.isVerified()) { + subtitle.setText(R.string.safety_number_change_dialog__previous_verified); + + Drawable check = ContextCompat.getDrawable(itemView.getContext(), R.drawable.check); + if (check != null) { + check.setBounds(0, 0, ViewUtil.dpToPx(12), ViewUtil.dpToPx(12)); + subtitle.setCompoundDrawables(check, null, null, null); + } + } else if (changedRecipient.getRecipient().hasAUserSetDisplayName(itemView.getContext())) { + subtitle.setText(changedRecipient.getRecipient().getE164().or("")); + subtitle.setCompoundDrawables(null, null, null, null); + } else { + subtitle.setText(""); + } + subtitle.setVisibility(TextUtils.isEmpty(subtitle.getText()) ? View.GONE : View.VISIBLE); + + viewButton.setOnClickListener(view -> callbacks.onViewIdentityRecord(changedRecipient.getIdentityRecord())); + } + } + + interface Callbacks { + void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java new file mode 100644 index 00000000..d4397880 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java @@ -0,0 +1,233 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collection; +import java.util.List; + +public final class SafetyNumberChangeDialog extends DialogFragment implements SafetyNumberChangeAdapter.Callbacks { + + public static final String SAFETY_NUMBER_DIALOG = "SAFETY_NUMBER"; + + private static final String RECIPIENT_IDS_EXTRA = "recipient_ids"; + private static final String MESSAGE_ID_EXTRA = "message_id"; + private static final String MESSAGE_TYPE_EXTRA = "message_type"; + private static final String CONTINUE_TEXT_RESOURCE_EXTRA = "continue_text_resource"; + private static final String CANCEL_TEXT_RESOURCE_EXTRA = "cancel_text_resource"; + + private SafetyNumberChangeViewModel viewModel; + private SafetyNumberChangeAdapter adapter; + private View dialogView; + + public static void show(@NonNull FragmentManager fragmentManager, @NonNull List identityRecords) { + List ids = Stream.of(identityRecords) + .filterNot(IdentityDatabase.IdentityRecord::isFirstUse) + .map(record -> record.getRecipientId().serialize()) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__send_anyway); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); + } + + public static void show(@NonNull FragmentActivity fragmentActivity, @NonNull MessageRecord messageRecord) { + List ids = Stream.of(messageRecord.getIdentityKeyMismatches()) + .map(mismatch -> mismatch.getRecipientId(fragmentActivity).serialize()) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + arguments.putLong(MESSAGE_ID_EXTRA, messageRecord.getId()); + arguments.putString(MESSAGE_TYPE_EXTRA, messageRecord.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT); + arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__send_anyway); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + fragment.show(fragmentActivity.getSupportFragmentManager(), SAFETY_NUMBER_DIALOG); + } + + public static void showForCall(@NonNull FragmentManager fragmentManager, @NonNull RecipientId recipientId) { + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, new String[] { recipientId.serialize() }); + arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__call_anyway); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); + } + + public static void showForGroupCall(@NonNull FragmentManager fragmentManager, @NonNull List identityRecords) { + List ids = Stream.of(identityRecords) + .filterNot(IdentityDatabase.IdentityRecord::isFirstUse) + .map(record -> record.getRecipientId().serialize()) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__join_call); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); + } + + public static void showForDuringGroupCall(@NonNull FragmentManager fragmentManager, @NonNull Collection recipientIds) { + Fragment previous = fragmentManager.findFragmentByTag(SAFETY_NUMBER_DIALOG); + if (previous != null) { + ((SafetyNumberChangeDialog) previous).updateRecipients(recipientIds); + return; + } + + List ids = Stream.of(recipientIds) + .map(RecipientId::serialize) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__continue_call); + arguments.putInt(CANCEL_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__leave_call); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); + } + + private SafetyNumberChangeDialog() { } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return dialogView; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + //noinspection ConstantConditions + List recipientIds = Stream.of(getArguments().getStringArray(RECIPIENT_IDS_EXTRA)).map(RecipientId::from).toList(); + long messageId = getArguments().getLong(MESSAGE_ID_EXTRA, -1); + String messageType = getArguments().getString(MESSAGE_TYPE_EXTRA, null); + + viewModel = ViewModelProviders.of(this, new SafetyNumberChangeViewModel.Factory(recipientIds, (messageId != -1) ? messageId : null, messageType)).get(SafetyNumberChangeViewModel.class); + viewModel.getChangedRecipients().observe(getViewLifecycleOwner(), adapter::submitList); + } + + @Override + public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + int continueText = requireArguments().getInt(CONTINUE_TEXT_RESOURCE_EXTRA, android.R.string.ok); + int cancelText = requireArguments().getInt(CANCEL_TEXT_RESOURCE_EXTRA, android.R.string.cancel); + + dialogView = LayoutInflater.from(requireActivity()).inflate(R.layout.safety_number_change_dialog, null); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity(), getTheme()); + + configureView(dialogView); + + builder.setTitle(R.string.safety_number_change_dialog__safety_number_changes) + .setView(dialogView) + .setCancelable(false) + .setPositiveButton(continueText, this::handleSendAnyway) + .setNegativeButton(cancelText, this::handleCancel); + + setCancelable(false); + + return builder.create(); + } + + @Override + public void onDestroyView() { + dialogView = null; + super.onDestroyView(); + } + + private void configureView(View view) { + RecyclerView list = view.findViewById(R.id.safety_number_change_dialog_list); + adapter = new SafetyNumberChangeAdapter(this); + list.setAdapter(adapter); + list.setItemAnimator(null); + list.setLayoutManager(new LinearLayoutManager(requireContext())); + } + + private void updateRecipients(Collection recipientIds) { + viewModel.updateRecipients(recipientIds); + } + + private void handleSendAnyway(DialogInterface dialogInterface, int which) { + Activity activity = getActivity(); + Callback callback; + if (activity instanceof Callback) { + callback = (Callback) activity; + } else { + callback = null; + } + + LiveData trustOrVerifyResultLiveData = viewModel.trustOrVerifyChangedRecipients(); + + Observer observer = new Observer() { + @Override + public void onChanged(TrustAndVerifyResult result) { + if (callback != null) { + switch (result.getResult()) { + case TRUST_AND_VERIFY: + callback.onSendAnywayAfterSafetyNumberChange(result.getChangedRecipients()); + break; + case TRUST_VERIFY_AND_RESEND: + callback.onMessageResentAfterSafetyNumberChange(); + break; + } + } + trustOrVerifyResultLiveData.removeObserver(this); + } + }; + + trustOrVerifyResultLiveData.observeForever(observer); + } + + private void handleCancel(@NonNull DialogInterface dialogInterface, int which) { + if (getActivity() instanceof Callback) { + ((Callback) getActivity()).onCanceled(); + } + } + + @Override + public void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord) { + startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord)); + } + + public interface Callback { + void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients); + void onMessageResentAfterSafetyNumberChange(); + void onCanceled(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java new file mode 100644 index 00000000..8669b3d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java @@ -0,0 +1,171 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.Collection; +import java.util.List; + +final class SafetyNumberChangeRepository { + + private static final String TAG = SafetyNumberChangeRepository.class.getSimpleName(); + + private final Context context; + + SafetyNumberChangeRepository(Context context) { + this.context = context.getApplicationContext(); + } + + @NonNull LiveData trustOrVerifyChangedRecipients(@NonNull List changedRecipients) { + MutableLiveData liveData = new MutableLiveData<>(); + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(trustOrVerifyChangedRecipientsInternal(changedRecipients))); + return liveData; + } + + @NonNull LiveData trustOrVerifyChangedRecipientsAndResend(@NonNull List changedRecipients, @NonNull MessageRecord messageRecord) { + MutableLiveData liveData = new MutableLiveData<>(); + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(trustOrVerifyChangedRecipientsAndResendInternal(changedRecipients, messageRecord))); + return liveData; + } + + @WorkerThread + public @NonNull SafetyNumberChangeState getSafetyNumberChangeState(@NonNull Collection recipientIds, @Nullable Long messageId, @Nullable String messageType) { + MessageRecord messageRecord = null; + if (messageId != null && messageType != null) { + messageRecord = getMessageRecord(messageId, messageType); + } + + List recipients = Stream.of(recipientIds).map(Recipient::resolved).toList(); + + List changedRecipients = Stream.of(DatabaseFactory.getIdentityDatabase(context).getIdentities(recipients).getIdentityRecords()) + .map(record -> new ChangedRecipient(Recipient.resolved(record.getRecipientId()), record)) + .toList(); + + return new SafetyNumberChangeState(changedRecipients, messageRecord); + } + + @WorkerThread + private @Nullable MessageRecord getMessageRecord(Long messageId, String messageType) { + try { + switch (messageType) { + case MmsSmsDatabase.SMS_TRANSPORT: + return DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId); + case MmsSmsDatabase.MMS_TRANSPORT: + return DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + default: + throw new AssertionError("no valid message type specified"); + } + } catch (NoSuchMessageException e) { + Log.i(TAG, e); + } + return null; + } + + @WorkerThread + private TrustAndVerifyResult trustOrVerifyChangedRecipientsInternal(@NonNull List changedRecipients) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + for (ChangedRecipient changedRecipient : changedRecipients) { + IdentityRecord identityRecord = changedRecipient.getIdentityRecord(); + + if (changedRecipient.isUnverified()) { + identityDatabase.setVerified(identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + IdentityDatabase.VerifiedStatus.DEFAULT); + } else { + identityDatabase.setApproval(identityRecord.getRecipientId(), true); + } + } + } + + return TrustAndVerifyResult.trustAndVerify(changedRecipients); + } + + @WorkerThread + private TrustAndVerifyResult trustOrVerifyChangedRecipientsAndResendInternal(@NonNull List changedRecipients, + @NonNull MessageRecord messageRecord) { + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + for (ChangedRecipient changedRecipient : changedRecipients) { + SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), 1); + TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context); + identityKeyStore.saveIdentity(mismatchAddress, changedRecipient.getIdentityRecord().getIdentityKey(), true); + } + } + + if (messageRecord.isOutgoing()) { + processOutgoingMessageRecord(changedRecipients, messageRecord); + } + + return TrustAndVerifyResult.trustVerifyAndResend(changedRecipients, messageRecord); + } + + @WorkerThread + private void processOutgoingMessageRecord(@NonNull List changedRecipients, @NonNull MessageRecord messageRecord) { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + + for (ChangedRecipient changedRecipient : changedRecipients) { + RecipientId id = changedRecipient.getRecipient().getId(); + IdentityKey identityKey = changedRecipient.getIdentityRecord().getIdentityKey(); + + if (messageRecord.isMms()) { + mmsDatabase.removeMismatchedIdentity(messageRecord.getId(), id, identityKey); + + if (messageRecord.getRecipient().isPushGroup()) { + MessageSender.resendGroupMessage(context, messageRecord, id); + } else { + MessageSender.resend(context, messageRecord); + } + } else { + smsDatabase.removeMismatchedIdentity(messageRecord.getId(), id, identityKey); + + MessageSender.resend(context, messageRecord); + } + } + } + + static final class SafetyNumberChangeState { + + private final List changedRecipients; + private final MessageRecord messageRecord; + + SafetyNumberChangeState(List changedRecipients, @Nullable MessageRecord messageRecord) { + this.changedRecipients = changedRecipients; + this.messageRecord = messageRecord; + } + + @NonNull List getChangedRecipients() { + return changedRecipients; + } + + @Nullable MessageRecord getMessageRecord() { + return messageRecord; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java new file mode 100644 index 00000000..5151acea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository.SafetyNumberChangeState; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public final class SafetyNumberChangeViewModel extends ViewModel { + + private final SafetyNumberChangeRepository safetyNumberChangeRepository; + private final MutableLiveData> recipientIds; + private final LiveData safetyNumberChangeState; + + private SafetyNumberChangeViewModel(@NonNull List recipientIds, + @Nullable Long messageId, + @Nullable String messageType, + @NonNull SafetyNumberChangeRepository safetyNumberChangeRepository) + { + this.safetyNumberChangeRepository = safetyNumberChangeRepository; + this.recipientIds = new MutableLiveData<>(recipientIds); + this.safetyNumberChangeState = LiveDataUtil.mapAsync(this.recipientIds, ids -> this.safetyNumberChangeRepository.getSafetyNumberChangeState(ids, messageId, messageType)); + } + + @NonNull LiveData> getChangedRecipients() { + return Transformations.map(safetyNumberChangeState, SafetyNumberChangeState::getChangedRecipients); + } + + @NonNull LiveData trustOrVerifyChangedRecipients() { + SafetyNumberChangeState state = Objects.requireNonNull(safetyNumberChangeState.getValue()); + if (state.getMessageRecord() != null) { + return safetyNumberChangeRepository.trustOrVerifyChangedRecipientsAndResend(state.getChangedRecipients(), state.getMessageRecord()); + } else { + return safetyNumberChangeRepository.trustOrVerifyChangedRecipients(state.getChangedRecipients()); + } + } + + void updateRecipients(Collection recipientIds) { + this.recipientIds.setValue(recipientIds); + } + + public static final class Factory implements ViewModelProvider.Factory { + private final List recipientIds; + private final Long messageId; + private final String messageType; + + public Factory(@NonNull List recipientIds, @Nullable Long messageId, @Nullable String messageType) { + this.recipientIds = recipientIds; + this.messageId = messageId; + this.messageType = messageType; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(ApplicationDependencies.getApplication()); + return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, messageType, repo))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java new file mode 100644 index 00000000..cbbd3b37 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.List; + +/** + * Result of trust/verify after safety number change. + */ +public class TrustAndVerifyResult { + + private final List changedRecipients; + private final MessageRecord messageRecord; + private final Result result; + + static TrustAndVerifyResult trustAndVerify(@NonNull List changedRecipients) { + return new TrustAndVerifyResult(changedRecipients, null, Result.TRUST_AND_VERIFY); + } + + static TrustAndVerifyResult trustVerifyAndResend(@NonNull List changedRecipients, @NonNull MessageRecord messageRecord) { + return new TrustAndVerifyResult(changedRecipients, messageRecord, Result.TRUST_VERIFY_AND_RESEND); + } + + TrustAndVerifyResult(@NonNull List changedRecipients, @Nullable MessageRecord messageRecord, @NonNull Result result) { + this.changedRecipients = Stream.of(changedRecipients).map(changedRecipient -> changedRecipient.getRecipient().getId()).toList(); + this.messageRecord = messageRecord; + this.result = result; + } + + public @NonNull List getChangedRecipients() { + return changedRecipients; + } + + public @Nullable MessageRecord getMessageRecord() { + return messageRecord; + } + + public @NonNull Result getResult() { + return result; + } + + public enum Result { + TRUST_AND_VERIFY, + TRUST_VERIFY_AND_RESEND, + UNKNOWN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/groupcall/GroupCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/groupcall/GroupCallViewModel.java new file mode 100644 index 00000000..e795a253 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/groupcall/GroupCallViewModel.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.conversation.ui.groupcall; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.events.GroupCallPeekEvent; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Objects; + +public class GroupCallViewModel extends ViewModel { + + private static final String TAG = Log.tag(GroupCallViewModel.class); + + private final MutableLiveData activeGroup; + private final MutableLiveData ongoingGroupCall; + private final LiveData activeGroupCall; + private final MutableLiveData groupCallHasCapacity; + + private @Nullable Recipient currentRecipient; + + GroupCallViewModel() { + this.activeGroup = new MutableLiveData<>(false); + this.ongoingGroupCall = new MutableLiveData<>(false); + this.groupCallHasCapacity = new MutableLiveData<>(false); + this.activeGroupCall = LiveDataUtil.combineLatest(activeGroup, ongoingGroupCall, (active, ongoing) -> active && ongoing); + } + + public @NonNull LiveData hasActiveGroupCall() { + return activeGroupCall; + } + + public @NonNull LiveData groupCallHasCapacity() { + return groupCallHasCapacity; + } + + public void onRecipientChange(@NonNull Context context, @Nullable Recipient recipient) { + activeGroup.postValue(recipient != null && recipient.isActiveGroup()); + + if (Objects.equals(currentRecipient, recipient)) { + return; + } + + ongoingGroupCall.postValue(false); + groupCallHasCapacity.postValue(false); + + currentRecipient = recipient; + + peekGroupCall(context); + } + + public void peekGroupCall(@NonNull Context context) { + if (isGroupCallCapable(currentRecipient)) { + Log.i(TAG, "peek call for " + currentRecipient.getId()); + Intent intent = new Intent(context, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_GROUP_CALL_PEEK) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(currentRecipient.getId())); + + context.startService(intent); + } + } + + public void onGroupCallPeekEvent(@NonNull GroupCallPeekEvent groupCallPeekEvent) { + if (isGroupCallCapable(currentRecipient) && groupCallPeekEvent.getGroupRecipientId().equals(currentRecipient.getId())) { + Log.i(TAG, "update UI with call event: ongoing call: " + groupCallPeekEvent.isOngoing() + " hasCapacity: " + groupCallPeekEvent.callHasCapacity()); + + ongoingGroupCall.postValue(groupCallPeekEvent.isOngoing()); + groupCallHasCapacity.postValue(groupCallPeekEvent.callHasCapacity()); + } else { + Log.i(TAG, "Ignore call event for different recipient."); + } + } + + private static boolean isGroupCallCapable(@Nullable Recipient recipient) { + return recipient != null && recipient.isActiveGroup() && recipient.isPushV2Group() && FeatureFlags.groupCalling(); + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new GroupCallViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java new file mode 100644 index 00000000..72f9a754 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +public final class MentionViewState extends RecipientMappingModel { + + private final Recipient recipient; + + public MentionViewState(@NonNull Recipient recipient) { + this.recipient = recipient; + } + + @Override + public @NonNull Recipient getRecipient() { + return recipient; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java new file mode 100644 index 00000000..06682cad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder.EventListener; + +import java.util.List; + +public class MentionsPickerAdapter extends MappingAdapter { + private final Runnable currentListChangedListener; + + public MentionsPickerAdapter(@Nullable EventListener listener, @NonNull Runnable currentListChangedListener) { + this.currentListChangedListener = currentListChangedListener; + registerFactory(MentionViewState.class, RecipientViewHolder.createFactory(R.layout.mentions_picker_recipient_list_item, listener)); + } + + @Override + public void onCurrentListChanged(@NonNull List> previousList, @NonNull List> currentList) { + super.onCurrentListChanged(previousList, currentList); + currentListChangedListener.run(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java new file mode 100644 index 00000000..b8846e90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.VibrateUtil; + +import java.util.Collections; +import java.util.List; + +public class MentionsPickerFragment extends LoggingFragment { + + private MentionsPickerAdapter adapter; + private RecyclerView list; + private View topDivider; + private View bottomDivider; + private BottomSheetBehavior behavior; + private MentionsPickerViewModel viewModel; + private final Runnable lockSheetAfterListUpdate = () -> behavior.setHideable(false); + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false); + + list = view.findViewById(R.id.mentions_picker_list); + topDivider = view.findViewById(R.id.mentions_picker_top_divider); + bottomDivider = view.findViewById(R.id.mentions_picker_bottom_divider); + behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)); + + initializeBehavior(); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + viewModel = ViewModelProviders.of(requireActivity()).get(MentionsPickerViewModel.class); + + initializeList(); + + viewModel.getMentionList().observe(getViewLifecycleOwner(), this::updateList); + + viewModel.isShowing().observe(getViewLifecycleOwner(), isShowing -> { + if (isShowing) { + VibrateUtil.vibrateTick(requireContext()); + } + }); + } + + private void initializeBehavior() { + behavior.setHideable(true); + behavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + adapter.submitList(Collections.emptyList()); + showDividers(false); + } else { + showDividers(true); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + showDividers(Float.isNaN(slideOffset) || slideOffset > -0.8f); + } + }); + } + + private void initializeList() { + adapter = new MentionsPickerAdapter(this::handleMentionClicked, () -> updateBottomSheetBehavior(adapter.getItemCount())); + + list.setLayoutManager(new LinearLayoutManager(requireContext())); + list.setAdapter(adapter); + list.setItemAnimator(null); + } + + private void handleMentionClicked(@NonNull Recipient recipient) { + viewModel.onSelectionChange(recipient); + } + + private void updateList(@NonNull List> mappingModels) { + if (adapter.getItemCount() > 0 && mappingModels.isEmpty()) { + updateBottomSheetBehavior(0); + } else { + adapter.submitList(mappingModels); + } + } + + private void updateBottomSheetBehavior(int count) { + boolean isShowing = count > 0; + + viewModel.setIsShowing(isShowing); + + if (isShowing) { + list.scrollToPosition(0); + behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + handler.post(lockSheetAfterListUpdate); + showDividers(true); + } else { + handler.removeCallbacks(lockSheetAfterListUpdate); + behavior.setHideable(true); + behavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } + } + + private void showDividers(boolean showDividers) { + topDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE); + bottomDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java new file mode 100644 index 00000000..4d8adad1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collections; +import java.util.List; + +final class MentionsPickerRepository { + + private final RecipientDatabase recipientDatabase; + private final GroupDatabase groupDatabase; + + MentionsPickerRepository(@NonNull Context context) { + recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + groupDatabase = DatabaseFactory.getGroupDatabase(context); + } + + @WorkerThread + @NonNull List getMembers(@Nullable Recipient recipient) { + if (recipient == null || !recipient.isPushV2Group()) { + return Collections.emptyList(); + } + + return Stream.of(groupDatabase.getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF)) + .map(Recipient::getId) + .toList(); + } + + @WorkerThread + @NonNull List search(@NonNull MentionQuery mentionQuery) { + if (mentionQuery.query == null || mentionQuery.members.isEmpty()) { + return Collections.emptyList(); + } + + return recipientDatabase.queryRecipientsForMentions(mentionQuery.query, mentionQuery.members); + } + + static class MentionQuery { + @Nullable private final String query; + @NonNull private final List members; + + MentionQuery(@Nullable String query, @NonNull List members) { + this.query = query; + this.members = members; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java new file mode 100644 index 00000000..8a50006d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.List; +import java.util.Objects; + +public class MentionsPickerViewModel extends ViewModel { + + private final SingleLiveEvent selectedRecipient; + private final LiveData>> mentionList; + private final MutableLiveData liveRecipient; + private final MutableLiveData liveQuery; + private final MutableLiveData isShowing; + + MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) { + this.liveRecipient = new MutableLiveData<>(); + this.liveQuery = new MutableLiveData<>(); + this.selectedRecipient = new SingleLiveEvent<>(); + this.isShowing = new MutableLiveData<>(false); + + LiveData recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); + LiveData> fullMembers = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(recipient, mentionsPickerRepository::getMembers)); + + LiveData mentionQuery = LiveDataUtil.combineLatest(liveQuery, fullMembers, (q, m) -> new MentionQuery(q.query, m)); + + this.mentionList = LiveDataUtil.mapAsync(mentionQuery, q -> Stream.of(mentionsPickerRepository.search(q)).>map(MentionViewState::new).toList()); + } + + @NonNull LiveData>> getMentionList() { + return mentionList; + } + + void onSelectionChange(@NonNull Recipient recipient) { + selectedRecipient.setValue(recipient); + } + + void setIsShowing(boolean isShowing) { + if (Objects.equals(this.isShowing.getValue(), isShowing)) { + return; + } + this.isShowing.setValue(isShowing); + } + + public @NonNull LiveData getSelectedRecipient() { + return selectedRecipient; + } + + public @NonNull LiveData isShowing() { + return isShowing; + } + + public void onQueryChange(@Nullable String query) { + liveQuery.setValue(query == null ? Query.NONE : new Query(query)); + } + + public void onRecipientChange(@NonNull Recipient recipient) { + this.liveRecipient.setValue(recipient.live()); + } + + /** + * Wraps a nullable query string so it can be properly propagated through + * {@link LiveDataUtil#combineLatest(LiveData, LiveData, LiveDataUtil.Combine)}. + */ + private static class Query { + static final Query NONE = new Query(null); + + @Nullable private final String query; + + Query(@Nullable String query) { + this.query = query; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + + if (object == null || getClass() != object.getClass()) { + return false; + } + + Query other = (Query) object; + return Objects.equals(query, other.query); + } + + @Override + public int hashCode() { + return Objects.hash(query); + } + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java new file mode 100644 index 00000000..2644fa76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java @@ -0,0 +1,309 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.paging.PagingController; +import org.thoughtcrime.securesms.BindableConversationListItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +class ConversationListAdapter extends ListAdapter { + + private static final int TYPE_THREAD = 1; + private static final int TYPE_ACTION = 2; + private static final int TYPE_PLACEHOLDER = 3; + private static final int TYPE_HEADER = 4; + + private enum Payload { + TYPING_INDICATOR, + SELECTION + } + + private final GlideRequests glideRequests; + private final OnConversationClickListener onConversationClickListener; + private final Map batchSet = Collections.synchronizedMap(new LinkedHashMap<>()); + private boolean batchMode = false; + private final Set typingSet = new HashSet<>(); + + private PagingController pagingController; + + protected ConversationListAdapter(@NonNull GlideRequests glideRequests, + @NonNull OnConversationClickListener onConversationClickListener) + { + super(new ConversationDiffCallback()); + + this.glideRequests = glideRequests; + this.onConversationClickListener = onConversationClickListener; + + this.setHasStableIds(true); + } + + @Override + public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == TYPE_ACTION) { + ConversationViewHolder holder = new ConversationViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.conversation_list_item_action, parent, false)); + + holder.itemView.setOnClickListener(v -> { + if (holder.getAdapterPosition() != RecyclerView.NO_POSITION) { + onConversationClickListener.onShowArchiveClick(); + } + }); + + return holder; + } else if (viewType == TYPE_THREAD) { + ConversationViewHolder holder = new ConversationViewHolder(CachedInflater.from(parent.getContext()) + .inflate(R.layout.conversation_list_item_view, parent, false)); + + holder.itemView.setOnClickListener(v -> { + int position = holder.getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + onConversationClickListener.onConversationClick(getItem(position)); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + int position = holder.getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + return onConversationClickListener.onConversationLongClick(getItem(position)); + } + + return false; + }); + return holder; + } else if (viewType == TYPE_PLACEHOLDER) { + View v = new FrameLayout(parent.getContext()); + v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100))); + return new PlaceholderViewHolder(v); + } else if (viewType == TYPE_HEADER) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_header, parent, false); + return new HeaderViewHolder(v); + } else { + throw new IllegalStateException("Unknown type! " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List payloads) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position); + } else if (holder instanceof ConversationViewHolder) { + for (Object payloadObject : payloads) { + if (payloadObject instanceof Payload) { + Payload payload = (Payload) payloadObject; + + if (payload == Payload.SELECTION) { + ((ConversationViewHolder) holder).getConversationListItem().setBatchMode(batchMode); + } else { + ((ConversationViewHolder) holder).getConversationListItem().updateTypingIndicator(typingSet); + } + } + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder.getItemViewType() == TYPE_ACTION || holder.getItemViewType() == TYPE_THREAD) { + ConversationViewHolder casted = (ConversationViewHolder) holder; + Conversation conversation = Objects.requireNonNull(getItem(position)); + + casted.getConversationListItem().bind(conversation.getThreadRecord(), + glideRequests, + Locale.getDefault(), + typingSet, + getBatchSelectionIds(), + batchMode); + } else if (holder.getItemViewType() == TYPE_HEADER) { + HeaderViewHolder casted = (HeaderViewHolder) holder; + Conversation conversation = Objects.requireNonNull(getItem(position)); + switch (conversation.getType()) { + case PINNED_HEADER: + casted.headerText.setText(R.string.conversation_list__pinned); + break; + case UNPINNED_HEADER: + casted.headerText.setText(R.string.conversation_list__chats); + break; + default: + throw new IllegalArgumentException(); + } + } + } + + @Override + public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof ConversationViewHolder) { + ((ConversationViewHolder) holder).getConversationListItem().unbind(); + } + } + + @Override + protected Conversation getItem(int position) { + if (pagingController != null) { + pagingController.onDataNeededAroundIndex(position); + } + + return super.getItem(position); + } + + @Override + public long getItemId(int position) { + Conversation item = getItem(position); + + if (item == null) { + return 0; + } + + switch (item.getType()) { + case THREAD: return item.getThreadRecord().getThreadId(); + case PINNED_HEADER: return -1; + case UNPINNED_HEADER: return -2; + case ARCHIVED_FOOTER: return -3; + default: throw new AssertionError(); + } + } + + public void setPagingController(@Nullable PagingController pagingController) { + this.pagingController = pagingController; + } + + void setTypingThreads(@NonNull Set typingThreadSet) { + this.typingSet.clear(); + this.typingSet.addAll(typingThreadSet); + + notifyItemRangeChanged(0, getItemCount(), Payload.TYPING_INDICATOR); + } + + void toggleConversationInBatchSet(@NonNull Conversation conversation) { + if (batchSet.containsKey(conversation.getThreadRecord().getThreadId())) { + batchSet.remove(conversation.getThreadRecord().getThreadId()); + } else if (conversation.getThreadRecord().getThreadId() != -1) { + batchSet.put(conversation.getThreadRecord().getThreadId(), conversation); + } + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + Collection getBatchSelection() { + return batchSet.values(); + } + + @Override + public int getItemViewType(int position) { + Conversation conversation = getItem(position); + if (conversation == null) { + return TYPE_PLACEHOLDER; + } + switch (conversation.getType()) { + case PINNED_HEADER: + case UNPINNED_HEADER: + return TYPE_HEADER; + case ARCHIVED_FOOTER: + return TYPE_ACTION; + case THREAD: + return TYPE_THREAD; + default: + throw new IllegalArgumentException(); + } + } + + @NonNull Set getBatchSelectionIds() { + return batchSet.keySet(); + } + + void selectAllThreads() { + for (int i = 0; i < super.getItemCount(); i++) { + Conversation conversation = getItem(i); + if (conversation != null && conversation.getThreadRecord().getThreadId() >= 0) { + batchSet.put(conversation.getThreadRecord().getThreadId(), conversation); + } + } + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + void initializeBatchMode(boolean toggle) { + this.batchMode = toggle; + unselectAllThreads(); + } + + private void unselectAllThreads() { + batchSet.clear(); + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + static final class ConversationViewHolder extends RecyclerView.ViewHolder { + + private final BindableConversationListItem conversationListItem; + + ConversationViewHolder(@NonNull View itemView) { + super(itemView); + + conversationListItem = (BindableConversationListItem) itemView; + } + + public BindableConversationListItem getConversationListItem() { + return conversationListItem; + } + } + + private static final class ConversationDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull Conversation oldItem, @NonNull Conversation newItem) { + return oldItem.getThreadRecord().getThreadId() == newItem.getThreadRecord().getThreadId(); + } + + @Override + public boolean areContentsTheSame(@NonNull Conversation oldItem, @NonNull Conversation newItem) { + return oldItem.equals(newItem); + } + } + + private static class PlaceholderViewHolder extends RecyclerView.ViewHolder { + PlaceholderViewHolder(@NonNull View itemView) { + super(itemView); + } + } + + static class HeaderViewHolder extends RecyclerView.ViewHolder { + private TextView headerText; + + public HeaderViewHolder(@NonNull View itemView) { + super(itemView); + headerText = (TextView) itemView; + } + } + + interface OnConversationClickListener { + void onConversationClick(Conversation conversation); + boolean onConversationLongClick(Conversation conversation); + void onShowArchiveClick(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java new file mode 100644 index 00000000..63b5b408 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversationlist; + +import android.annotation.SuppressLint; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.DrawableRes; +import androidx.annotation.MenuRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.snackbar.Snackbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; +import org.thoughtcrime.securesms.util.views.Stub; + +import java.util.Set; + + +public class ConversationListArchiveFragment extends ConversationListFragment implements ActionMode.Callback +{ + private RecyclerView list; + private Stub emptyState; + private PulsingFloatingActionButton fab; + private PulsingFloatingActionButton cameraFab; + private Stub toolbar; + + public static ConversationListArchiveFragment newInstance() { + return new ConversationListArchiveFragment(); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + setHasOptionsMenu(false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + toolbar = new Stub<>(view.findViewById(R.id.toolbar_basic)); + + super.onViewCreated(view, savedInstanceState); + + list = view.findViewById(R.id.list); + fab = view.findViewById(R.id.fab); + cameraFab = view.findViewById(R.id.camera_fab); + emptyState = new Stub<>(view.findViewById(R.id.empty_state)); + + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + toolbar.get().setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations); + + fab.hide(); + cameraFab.hide(); + } + + @Override + protected void onPostSubmitList(int conversationCount) { + list.setVisibility(View.VISIBLE); + + if (emptyState.resolved()) { + emptyState.get().setVisibility(View.GONE); + } + } + + @Override + protected boolean isArchived() { + return true; + } + + @Override + protected @NonNull Toolbar getToolbar(@NonNull View rootView) { + return toolbar.get(); + } + + @Override + protected @StringRes int getArchivedSnackbarTitleRes() { + return R.plurals.ConversationListFragment_moved_conversations_to_inbox; + } + + @Override + protected @MenuRes int getActionModeMenuRes() { + return R.menu.conversation_list_batch_unarchive; + } + + @Override + protected @DrawableRes int getArchiveIconRes() { + return R.drawable.ic_unarchive_white_36dp; + } + + @Override + @WorkerThread + protected void archiveThreads(Set threadIds) { + DatabaseFactory.getThreadDatabase(getActivity()).setArchived(threadIds, false); + } + + @Override + @WorkerThread + protected void reverseArchiveThreads(Set threadIds) { + DatabaseFactory.getThreadDatabase(getActivity()).setArchived(threadIds, true); + } + + @SuppressLint("StaticFieldLeak") + @Override + protected void onItemSwiped(long threadId, int unreadCount) { + new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), + requireView(), + getResources().getQuantityString(R.plurals.ConversationListFragment_moved_conversations_to_inbox, 1, 1), + getString(R.string.ConversationListFragment_undo), + getResources().getColor(R.color.amber_500), + Snackbar.LENGTH_LONG, + false) + { + @Override + protected void executeAction(@Nullable Long parameter) { + DatabaseFactory.getThreadDatabase(getActivity()).unarchiveConversation(threadId); + } + + @Override + protected void reverseAction(@Nullable Long parameter) { + DatabaseFactory.getThreadDatabase(getActivity()).archiveConversation(threadId); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId); + } + + @Override + void updateEmptyState(boolean isConversationEmpty) { + // Do nothing + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java new file mode 100644 index 00000000..cdcb981a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java @@ -0,0 +1,188 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import org.signal.core.util.logging.Log; +import org.signal.paging.PagedDataSource; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.conversationlist.model.ConversationReader; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Stopwatch; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +abstract class ConversationListDataSource implements PagedDataSource { + + private static final String TAG = Log.tag(ConversationListDataSource.class); + + protected final ThreadDatabase threadDatabase; + + protected ConversationListDataSource(@NonNull Context context) { + this.threadDatabase = DatabaseFactory.getThreadDatabase(context); + } + + public static ConversationListDataSource create(@NonNull Context context, boolean isArchived) { + if (!isArchived) return new UnarchivedConversationListDataSource(context); + else return new ArchivedConversationListDataSource(context); + } + + @Override + public int size() { + long startTime = System.currentTimeMillis(); + int count = getTotalCount(); + + Log.d(TAG, "[size(), " + getClass().getSimpleName() + "] " + (System.currentTimeMillis() - startTime) + " ms"); + return count; + } + + @Override + public @NonNull List load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName()); + + List conversations = new ArrayList<>(length); + List recipients = new LinkedList<>(); + + try (ConversationReader reader = new ConversationReader(getCursor(start, length))) { + ThreadRecord record; + while ((record = reader.getNext()) != null && !cancellationSignal.isCanceled()) { + conversations.add(new Conversation(record)); + recipients.add(record.getRecipient()); + } + } + + stopwatch.split("cursor"); + + ApplicationDependencies.getRecipientCache().addToCache(recipients); + + stopwatch.split("cache-recipients"); + + stopwatch.stop(TAG); + + return conversations; + } + + protected abstract int getTotalCount(); + protected abstract Cursor getCursor(long offset, long limit); + + private static class ArchivedConversationListDataSource extends ConversationListDataSource { + + ArchivedConversationListDataSource(@NonNull Context context) { + super(context); + } + + @Override + protected int getTotalCount() { + return threadDatabase.getArchivedConversationListCount(); + } + + @Override + protected Cursor getCursor(long offset, long limit) { + return threadDatabase.getArchivedConversationList(offset, limit); + } + } + + @VisibleForTesting + static class UnarchivedConversationListDataSource extends ConversationListDataSource { + + private int totalCount; + private int pinnedCount; + private int archivedCount; + private int unpinnedCount; + + UnarchivedConversationListDataSource(@NonNull Context context) { + super(context); + } + + @Override + protected int getTotalCount() { + int unarchivedCount = threadDatabase.getUnarchivedConversationListCount(); + + pinnedCount = threadDatabase.getPinnedConversationListCount(); + archivedCount = threadDatabase.getArchivedConversationListCount(); + unpinnedCount = unarchivedCount - pinnedCount; + totalCount = unarchivedCount; + + if (archivedCount != 0) { + totalCount++; + } + + if (pinnedCount != 0) { + if (unpinnedCount != 0) { + totalCount += 2; + } else { + totalCount += 1; + } + } + + return totalCount; + } + + @Override + protected Cursor getCursor(long offset, long limit) { + List cursors = new ArrayList<>(5); + long originalLimit = limit; + + if (offset == 0 && hasPinnedHeader()) { + MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN); + pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER); + cursors.add(pinnedHeaderCursor); + limit--; + } + + Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit); + cursors.add(pinnedCursor); + limit -= pinnedCursor.getCount(); + + if (offset == 0 && hasUnpinnedHeader()) { + MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN); + unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER); + cursors.add(unpinnedHeaderCursor); + limit--; + } + + long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset()); + Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit); + cursors.add(unpinnedCursor); + + if (offset + originalLimit >= totalCount && hasArchivedFooter()) { + MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS); + archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount)); + cursors.add(archivedFooterCursor); + } + + return new MergeCursor(cursors.toArray(new Cursor[]{})); + } + + @VisibleForTesting + int getHeaderOffset() { + return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0); + } + + @VisibleForTesting + boolean hasPinnedHeader() { + return pinnedCount != 0; + } + + @VisibleForTesting + boolean hasUnpinnedHeader() { + return hasPinnedHeader() && unpinnedCount != 0; + } + + @VisibleForTesting + boolean hasArchivedFooter() { + return archivedCount != 0; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java new file mode 100644 index 00000000..74d55777 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -0,0 +1,1195 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversationlist; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.IdRes; +import androidx.annotation.MenuRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.PluralsRes; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.content.res.ResourcesCompat; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; +import com.google.android.material.snackbar.Snackbar; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.MainFragment; +import org.thoughtcrime.securesms.MainNavigator; +import org.thoughtcrime.securesms.NewConversationActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.RatingManager; +import org.thoughtcrime.securesms.components.SearchToolbar; +import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator; +import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; +import org.thoughtcrime.securesms.components.reminder.DozeReminder; +import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; +import org.thoughtcrime.securesms.components.reminder.OutdatedBuildReminder; +import org.thoughtcrime.securesms.components.reminder.PushRegistrationReminder; +import org.thoughtcrime.securesms.components.reminder.Reminder; +import org.thoughtcrime.securesms.components.reminder.ReminderView; +import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; +import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; +import org.thoughtcrime.securesms.conversation.ConversationFragment; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.conversationlist.model.SearchResult; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.ReminderUpdateEvent; +import org.thoughtcrime.securesms.insights.InsightsLauncher; +import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController; +import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.notifications.MarkReadReceiver; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.AppForegroundObserver; +import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SignalProxyUtil; +import org.thoughtcrime.securesms.util.SnapToTopDataObserver; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.WindowUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; +import org.thoughtcrime.securesms.util.views.Stub; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +import static android.app.Activity.RESULT_OK; + + +public class ConversationListFragment extends MainFragment implements ActionMode.Callback, + ConversationListAdapter.OnConversationClickListener, + ConversationListSearchAdapter.EventListener, + MainNavigator.BackHandler, + MegaphoneActionController +{ + public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; + public static final short SMS_ROLE_REQUEST_CODE = 32563; + + private static final String TAG = Log.tag(ConversationListFragment.class); + + private static final int MAXIMUM_PINNED_CONVERSATIONS = 4; + + private ActionMode actionMode; + private RecyclerView list; + private Stub reminderView; + private Stub emptyState; + private TextView searchEmptyState; + private PulsingFloatingActionButton fab; + private PulsingFloatingActionButton cameraFab; + private Stub searchToolbar; + private ImageView proxyStatus; + private ImageView searchAction; + private View toolbarShadow; + private ConversationListViewModel viewModel; + private RecyclerView.Adapter activeAdapter; + private ConversationListAdapter defaultAdapter; + private ConversationListSearchAdapter searchAdapter; + private StickyHeaderDecoration searchAdapterDecoration; + private Stub megaphoneContainer; + private SnapToTopDataObserver snapToTopDataObserver; + private Drawable archiveDrawable; + private AppForegroundObserver.Listener appForegroundObserver; + + private Stopwatch startupStopwatch; + + public static ConversationListFragment newInstance() { + return new ConversationListFragment(); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + setHasOptionsMenu(true); + startupStopwatch = new Stopwatch("startup"); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { + return inflater.inflate(R.layout.conversation_list_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + list = view.findViewById(R.id.list); + fab = view.findViewById(R.id.fab); + cameraFab = view.findViewById(R.id.camera_fab); + searchEmptyState = view.findViewById(R.id.search_no_results); + searchAction = view.findViewById(R.id.search_action); + toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow); + proxyStatus = view.findViewById(R.id.conversation_list_proxy_status); + reminderView = new Stub<>(view.findViewById(R.id.reminder)); + emptyState = new Stub<>(view.findViewById(R.id.empty_state)); + searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar)); + megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container)); + + Toolbar toolbar = getToolbar(view); + toolbar.setVisibility(View.VISIBLE); + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + + proxyStatus.setOnClickListener(v -> onProxyStatusClicked()); + + fab.show(); + cameraFab.show(); + + list.setLayoutManager(new LinearLayoutManager(requireActivity())); + list.setItemAnimator(new DeleteItemAnimator()); + list.addOnScrollListener(new ScrollListener()); + + snapToTopDataObserver = new SnapToTopDataObserver(list); + + new ItemTouchHelper(new ArchiveListenerCallback()).attachToRecyclerView(list); + + fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class))); + cameraFab.setOnClickListener(v -> { + Permissions.with(requireActivity()) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) + .onAllGranted(() -> startActivity(MediaSendActivity.buildCameraFirstIntent(requireActivity()))) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show()) + .execute(); + }); + + initializeViewModel(); + initializeListAdapters(); + initializeTypingObserver(); + initializeSearchListener(); + + RatingManager.showRatingDialogIfNecessary(requireContext()); + + TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages)); + } + + @Override + public void onResume() { + super.onResume(); + + updateReminders(); + EventBus.getDefault().register(this); + + if (TextSecurePreferences.isSmsEnabled(requireContext())) { + InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager()); + } + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), Recipient::self, this::initializeProfileIcon); + + if ((!searchToolbar.resolved() || !searchToolbar.get().isVisible()) && list.getAdapter() != defaultAdapter) { + list.removeItemDecoration(searchAdapterDecoration); + setAdapter(defaultAdapter); + } + + if (activeAdapter != null) { + activeAdapter.notifyDataSetChanged(); + } + + SignalProxyUtil.startListeningToWebsocket(); + } + + @Override + public void onStart() { + super.onStart(); + ConversationFragment.prepare(requireContext()); + ApplicationDependencies.getAppForegroundObserver().addListener(appForegroundObserver); + } + + @Override + public void onPause() { + super.onPause(); + + fab.stopPulse(); + cameraFab.stopPulse(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onStop() { + super.onStop(); + ApplicationDependencies.getAppForegroundObserver().removeListener(appForegroundObserver); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + menu.clear(); + inflater.inflate(R.menu.text_secure_normal, menu); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.menu_insights).setVisible(TextSecurePreferences.isSmsEnabled(requireContext())); + menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(requireContext())); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case R.id.menu_new_group: handleCreateGroup(); return true; + case R.id.menu_settings: handleDisplaySettings(); return true; + case R.id.menu_clear_passphrase: handleClearPassphrase(); return true; + case R.id.menu_mark_all_read: handleMarkAllRead(); return true; + case R.id.menu_invite: handleInvite(); return true; + case R.id.menu_insights: handleInsights(); return true; + } + + return false; + } + + @Override + public boolean onBackPressed() { + return closeSearchIfOpen(); + } + + private boolean closeSearchIfOpen() { + if ((searchToolbar.resolved() && searchToolbar.get().isVisible()) || activeAdapter == searchAdapter) { + list.removeItemDecoration(searchAdapterDecoration); + setAdapter(defaultAdapter); + searchToolbar.get().collapse(); + return true; + } + + return false; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (resultCode != RESULT_OK) { + return; + } + + if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) { + Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).setTextColor(Color.WHITE).show(); + viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL); + } + } + + @Override + public void onConversationClicked(@NonNull ThreadRecord threadRecord) { + hideKeyboard(); + getNavigator().goToConversation(threadRecord.getRecipient().getId(), + threadRecord.getThreadId(), + threadRecord.getDistributionType(), + -1); + } + + @Override + public void onShowArchiveClick() { + getNavigator().goToArchiveList(); + } + + @Override + public void onContactClicked(@NonNull Recipient contact) { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + return DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact.getId()); + }, threadId -> { + hideKeyboard(); + getNavigator().goToConversation(contact.getId(), + threadId, + ThreadDatabase.DistributionTypes.DEFAULT, + -1); + }); + } + + @Override + public void onMessageClicked(@NonNull MessageResult message) { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs); + return Math.max(0, startingPosition); + }, startingPosition -> { + hideKeyboard(); + getNavigator().goToConversation(message.conversationRecipient.getId(), + message.threadId, + ThreadDatabase.DistributionTypes.DEFAULT, + startingPosition); + }); + } + + @Override + public void onMegaphoneNavigationRequested(@NonNull Intent intent) { + startActivity(intent); + } + + @Override + public void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode) { + startActivityForResult(intent, requestCode); + } + + @Override + public void onMegaphoneToastRequested(@NonNull String string) { + Snackbar.make(fab, string, Snackbar.LENGTH_LONG) + .setTextColor(Color.WHITE) + .show(); + } + + @Override + public @NonNull Activity getMegaphoneActivity() { + return requireActivity(); + } + + @Override + public void onMegaphoneSnooze(@NonNull Megaphones.Event event) { + viewModel.onMegaphoneSnoozed(event); + } + + @Override + public void onMegaphoneCompleted(@NonNull Megaphones.Event event) { + viewModel.onMegaphoneCompleted(event); + } + + @Override + public void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment) { + dialogFragment.show(getChildFragmentManager(), "megaphone_dialog"); + } + + private void initializeReminderView() { + reminderView.get().setOnDismissListener(this::updateReminders); + reminderView.get().setOnActionClickListener(this::onReminderAction); + } + + private void onReminderAction(@IdRes int reminderActionId) { + if (reminderActionId == R.id.reminder_action_update_now) { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()); + } + } + + private void hideKeyboard() { + InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext()); + imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0); + } + + private void initializeProfileIcon(@NonNull Recipient recipient) { + ImageView icon = requireView().findViewById(R.id.toolbar_icon); + + AvatarUtil.loadIconIntoImageView(recipient, icon); + icon.setOnClickListener(v -> getNavigator().goToAppSettings()); + } + + private void initializeSearchListener() { + searchAction.setOnClickListener(v -> { + searchToolbar.get().display(searchAction.getX() + (searchAction.getWidth() / 2.0f), + searchAction.getY() + (searchAction.getHeight() / 2.0f)); + + searchToolbar.get().setListener(new SearchToolbar.SearchListener() { + @Override + public void onSearchTextChange(String text) { + String trimmed = text.trim(); + + viewModel.updateQuery(trimmed); + + if (trimmed.length() > 0) { + if (activeAdapter != searchAdapter) { + setAdapter(searchAdapter); + list.removeItemDecoration(searchAdapterDecoration); + list.addItemDecoration(searchAdapterDecoration); + } + } else { + if (activeAdapter != defaultAdapter) { + list.removeItemDecoration(searchAdapterDecoration); + setAdapter(defaultAdapter); + } + } + } + + @Override + public void onSearchClosed() { + list.removeItemDecoration(searchAdapterDecoration); + setAdapter(defaultAdapter); + } + }); + }); + } + + private void initializeListAdapters() { + defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this); + searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault()); + searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0); + + setAdapter(defaultAdapter); + + defaultAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + startupStopwatch.split("data-set"); + defaultAdapter.unregisterAdapterDataObserver(this); + list.post(() -> { + AppStartup.getInstance().onCriticalRenderEventEnd(); + startupStopwatch.split("first-render"); + startupStopwatch.stop(TAG); + }); + } + }); + } + + @SuppressWarnings("rawtypes") + private void setAdapter(@NonNull RecyclerView.Adapter adapter) { + RecyclerView.Adapter oldAdapter = activeAdapter; + + activeAdapter = adapter; + + if (oldAdapter == activeAdapter) { + return; + } + + if (adapter instanceof ConversationListAdapter) { + ((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController()); + } + + list.setAdapter(adapter); + + if (adapter == defaultAdapter) { + defaultAdapter.registerAdapterDataObserver(snapToTopDataObserver); + } else { + defaultAdapter.unregisterAdapterDataObserver(snapToTopDataObserver); + } + } + + private void initializeTypingObserver() { + ApplicationDependencies.getTypingStatusRepository().getTypingThreads().observe(getViewLifecycleOwner(), threadIds -> { + if (threadIds == null) { + threadIds = Collections.emptySet(); + } + + defaultAdapter.setTypingThreads(threadIds); + }); + } + + protected boolean isArchived() { + return false; + } + + private void initializeViewModel() { + viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class); + + viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged); + viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged); + viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitList); + viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState); + viewModel.getPipeState().observe(getViewLifecycleOwner(), this::updateProxyStatus); + + appForegroundObserver = new AppForegroundObserver.Listener() { + @Override + public void onForeground() { + viewModel.onVisible(); + } + + @Override + public void onBackground() { } + }; + } + + private void onSearchResultChanged(@Nullable SearchResult result) { + result = result != null ? result : SearchResult.EMPTY; + searchAdapter.updateResults(result); + + if (result.isEmpty() && activeAdapter == searchAdapter) { + searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery())); + searchEmptyState.setVisibility(View.VISIBLE); + } else { + searchEmptyState.setVisibility(View.GONE); + } + } + + private void onMegaphoneChanged(@Nullable Megaphone megaphone) { + if (megaphone == null) { + if (megaphoneContainer.resolved()) { + megaphoneContainer.get().setVisibility(View.GONE); + megaphoneContainer.get().removeAllViews(); + } + return; + } + + View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this); + + megaphoneContainer.get().removeAllViews(); + + if (view != null) { + megaphoneContainer.get().addView(view); + megaphoneContainer.get().setVisibility(View.VISIBLE); + } else { + megaphoneContainer.get().setVisibility(View.GONE); + + if (megaphone.getOnVisibleListener() != null) { + megaphone.getOnVisibleListener().onEvent(megaphone, this); + } + } + + viewModel.onMegaphoneVisible(megaphone); + } + + private void updateReminders() { + Context context = requireContext(); + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + if (UnauthorizedReminder.isEligible(context)) { + return Optional.of(new UnauthorizedReminder(context)); + } else if (ExpiredBuildReminder.isEligible()) { + return Optional.of(new ExpiredBuildReminder(context)); + } else if (ServiceOutageReminder.isEligible(context)) { + ApplicationDependencies.getJobManager().add(new ServiceOutageDetectionJob()); + return Optional.of(new ServiceOutageReminder(context)); + } else if (OutdatedBuildReminder.isEligible()) { + return Optional.of(new OutdatedBuildReminder(context)); + } else if (PushRegistrationReminder.isEligible(context)) { + return Optional.of((new PushRegistrationReminder(context))); + } else if (DozeReminder.isEligible(context)) { + return Optional.of(new DozeReminder(context)); + } else { + return Optional.absent(); + } + }, reminder -> { + if (reminder.isPresent() && getActivity() != null && !isRemoving()) { + if (!reminderView.resolved()) { + initializeReminderView(); + } + reminderView.get().showReminder(reminder.get()); + } else if (reminderView.resolved() && !reminder.isPresent()) { + reminderView.get().hide(); + } + }); + } + + private void handleCreateGroup() { + getNavigator().goToGroupCreation(); + } + + private void handleDisplaySettings() { + getNavigator().goToAppSettings(); + } + + private void handleClearPassphrase() { + Intent intent = new Intent(requireActivity(), KeyCachingService.class); + intent.setAction(KeyCachingService.CLEAR_KEY_ACTION); + requireActivity().startService(intent); + } + + private void handleMarkAllRead() { + Context context = requireContext(); + + SignalExecutors.BOUNDED.execute(() -> { + List messageIds = DatabaseFactory.getThreadDatabase(context).setAllThreadsRead(); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + }); + } + + private void handleMarkSelectedAsRead() { + Context context = requireContext(); + Set selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds()); + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + List messageIds = DatabaseFactory.getThreadDatabase(context).setRead(selectedConversations, false); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + + return null; + }, none -> { + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + }); + } + + private void handleMarkSelectedAsUnread() { + Context context = requireContext(); + Set selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds()); + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + DatabaseFactory.getThreadDatabase(context).setForcedUnread(selectedConversations); + StorageSyncHelper.scheduleSyncForDataChange(); + return null; + }, none -> { + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + }); + } + + private void handleInvite() { + getNavigator().goToInvite(); + } + + private void handleInsights() { + getNavigator().goToInsights(); + } + + @SuppressLint("StaticFieldLeak") + private void handleArchiveAllSelected() { + Set selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds()); + int count = selectedConversations.size(); + String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count); + + new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), + requireView(), + snackBarTitle, + getString(R.string.ConversationListFragment_undo), + getResources().getColor(R.color.amber_500), + Snackbar.LENGTH_LONG, true) + { + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + + @Override + protected void executeAction(@Nullable Void parameter) { + archiveThreads(selectedConversations); + } + + @Override + protected void reverseAction(@Nullable Void parameter) { + reverseArchiveThreads(selectedConversations); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @SuppressLint("StaticFieldLeak") + private void handleDeleteAllSelected() { + int conversationsCount = defaultAdapter.getBatchSelectionIds().size(); + AlertDialog.Builder alert = new AlertDialog.Builder(getActivity()); + alert.setIcon(R.drawable.ic_warning); + alert.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations, + conversationsCount, conversationsCount)); + alert.setMessage(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations, + conversationsCount, conversationsCount)); + alert.setCancelable(true); + + alert.setPositiveButton(R.string.delete, (dialog, which) -> { + final Set selectedConversations = defaultAdapter.getBatchSelectionIds(); + + if (!selectedConversations.isEmpty()) { + new AsyncTask() { + private ProgressDialog dialog; + + @Override + protected void onPreExecute() { + dialog = ProgressDialog.show(getActivity(), + getActivity().getString(R.string.ConversationListFragment_deleting), + getActivity().getString(R.string.ConversationListFragment_deleting_selected_conversations), + true, false); + } + + @Override + protected Void doInBackground(Void... params) { + DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations); + ApplicationDependencies.getMessageNotifier().updateNotification(getActivity()); + return null; + } + + @Override + protected void onPostExecute(Void result) { + dialog.dismiss(); + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + + alert.setNegativeButton(android.R.string.cancel, null); + alert.show(); + } + + private void handlePinAllSelected() { + final Set toPin = new LinkedHashSet<>(Stream.of(defaultAdapter.getBatchSelection()) + .filterNot(conversation -> conversation.getThreadRecord().isPinned()) + .map(conversation -> conversation.getThreadRecord().getThreadId()) + .toList()); + + if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) { + Snackbar.make(fab, + getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), + Snackbar.LENGTH_LONG) + .setTextColor(Color.WHITE) + .show(); + actionMode.finish(); + return; + } + + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()); + + db.pinConversations(toPin); + + return null; + }, unused -> { + if (actionMode != null) { + actionMode.finish(); + } + }); + } + + private void handleUnpinAllSelected() { + final Set toPin = new HashSet<>(defaultAdapter.getBatchSelectionIds()); + + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()); + + db.unpinConversations(toPin); + + return null; + }, unused -> { + if (actionMode != null) { + actionMode.finish(); + } + }); + } + + private void handleSelectAllThreads() { + defaultAdapter.selectAllThreads(); + actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size())); + } + + private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) { + getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1); + } + + private void onSubmitList(@NonNull List conversationList) { + defaultAdapter.submitList(conversationList); + onPostSubmitList(conversationList.size()); + } + + void updateEmptyState(boolean isConversationEmpty) { + if (isConversationEmpty) { + Log.i(TAG, "Received an empty data set."); + list.setVisibility(View.INVISIBLE); + emptyState.get().setVisibility(View.VISIBLE); + fab.startPulse(3 * 1000); + cameraFab.startPulse(3 * 1000); + + SignalStore.onboarding().setShowNewGroup(true); + SignalStore.onboarding().setShowInviteFriends(true); + } else { + list.setVisibility(View.VISIBLE); + fab.stopPulse(); + cameraFab.stopPulse(); + + if (emptyState.resolved()) { + emptyState.get().setVisibility(View.GONE); + } + } + } + + private void updateProxyStatus(@NonNull PipeConnectivityListener.State state) { + if (SignalStore.proxy().isProxyEnabled()) { + proxyStatus.setVisibility(View.VISIBLE); + + switch (state) { + case CONNECTING: + case DISCONNECTED: + proxyStatus.setImageResource(R.drawable.ic_proxy_connecting_24); + break; + case CONNECTED: + proxyStatus.setImageResource(R.drawable.ic_proxy_connected_24); + break; + case FAILURE: + proxyStatus.setImageResource(R.drawable.ic_proxy_failed_24); + break; + } + } else { + proxyStatus.setVisibility(View.GONE); + } + } + + private void onProxyStatusClicked() { + Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class); + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_PROXY_FRAGMENT, true); + + startActivity(intent); + } + + protected void onPostSubmitList(int conversationCount) { + if (conversationCount >= 6 && (SignalStore.onboarding().shouldShowInviteFriends() || SignalStore.onboarding().shouldShowNewGroup())) { + SignalStore.onboarding().clearAll(); + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.ONBOARDING); + } + } + + @Override + public void onConversationClick(Conversation conversation) { + if (actionMode == null) { + handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType()); + } else { + defaultAdapter.toggleConversationInBatchSet(conversation); + + if (defaultAdapter.getBatchSelectionIds().size() == 0) { + actionMode.finish(); + } else { + actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size())); + setCorrectMenuVisibility(actionMode.getMenu()); + } + } + } + + @Override + public boolean onConversationLongClick(Conversation conversation) { + if (actionMode != null) { + onConversationClick(conversation); + return true; + } + + defaultAdapter.initializeBatchMode(true); + defaultAdapter.toggleConversationInBatchSet(conversation); + + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); + + return true; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = getActivity().getMenuInflater(); + + inflater.inflate(R.menu.conversation_list_batch_pin, menu); + inflater.inflate(getActionModeMenuRes(), menu); + inflater.inflate(R.menu.conversation_list_batch, menu); + + mode.setTitle("1"); + + WindowUtil.setStatusBarColor(requireActivity().getWindow(), getResources().getColor(R.color.action_mode_status_bar)); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + setCorrectMenuVisibility(menu); + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_select_all: handleSelectAllThreads(); return true; + case R.id.menu_delete_selected: handleDeleteAllSelected(); return true; + case R.id.menu_pin_selected: handlePinAllSelected(); return true; + case R.id.menu_unpin_selected: handleUnpinAllSelected(); return true; + case R.id.menu_archive_selected: handleArchiveAllSelected(); return true; + case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true; + case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true; + } + + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + defaultAdapter.initializeBatchMode(false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + TypedArray color = getActivity().getTheme().obtainStyledAttributes(new int[] {android.R.attr.statusBarColor}); + WindowUtil.setStatusBarColor(getActivity().getWindow(), color.getColor(0, Color.BLACK)); + color.recycle(); + } + + if (Build.VERSION.SDK_INT >= 23) { + TypedArray lightStatusBarAttr = getActivity().getTheme().obtainStyledAttributes(new int[] {android.R.attr.windowLightStatusBar}); + int current = getActivity().getWindow().getDecorView().getSystemUiVisibility(); + int statusBarMode = lightStatusBarAttr.getBoolean(0, false) ? current | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + : current & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + + getActivity().getWindow().getDecorView().setSystemUiVisibility(statusBarMode); + + lightStatusBarAttr.recycle(); + } + + actionMode = null; + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(ReminderUpdateEvent event) { + updateReminders(); + } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + public void onEvent(MessageSender.MessageSentEvent event) { + EventBus.getDefault().removeStickyEvent(event); + closeSearchIfOpen(); + } + + private void setCorrectMenuVisibility(@NonNull Menu menu) { + boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead()); + boolean hasUnpinned = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned()); + boolean canPin = viewModel.getPinnedCount() < MAXIMUM_PINNED_CONVERSATIONS; + + if (hasUnread) { + menu.findItem(R.id.menu_mark_as_unread).setVisible(false); + menu.findItem(R.id.menu_mark_as_read).setVisible(true); + } else { + menu.findItem(R.id.menu_mark_as_unread).setVisible(true); + menu.findItem(R.id.menu_mark_as_read).setVisible(false); + } + + if (!isArchived() && hasUnpinned && canPin) { + menu.findItem(R.id.menu_pin_selected).setVisible(true); + menu.findItem(R.id.menu_unpin_selected).setVisible(false); + } else if (!isArchived() && !hasUnpinned) { + menu.findItem(R.id.menu_pin_selected).setVisible(false); + menu.findItem(R.id.menu_unpin_selected).setVisible(true); + } else { + menu.findItem(R.id.menu_pin_selected).setVisible(false); + menu.findItem(R.id.menu_unpin_selected).setVisible(false); + } + } + + protected Toolbar getToolbar(@NonNull View rootView) { + return rootView.findViewById(R.id.toolbar); + } + + protected @PluralsRes int getArchivedSnackbarTitleRes() { + return R.plurals.ConversationListFragment_conversations_archived; + } + + protected @MenuRes int getActionModeMenuRes() { + return R.menu.conversation_list_batch_archive; + } + + protected @DrawableRes int getArchiveIconRes() { + return R.drawable.ic_archive_white_36dp; + } + + @WorkerThread + protected void archiveThreads(Set threadIds) { + DatabaseFactory.getThreadDatabase(getActivity()).setArchived(threadIds, true); + } + + @WorkerThread + protected void reverseArchiveThreads(Set threadIds) { + DatabaseFactory.getThreadDatabase(getActivity()).setArchived(threadIds, false); + } + + @SuppressLint("StaticFieldLeak") + protected void onItemSwiped(long threadId, int unreadCount) { + new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), + requireView(), + getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1), + getString(R.string.ConversationListFragment_undo), + getResources().getColor(R.color.amber_500), + Snackbar.LENGTH_LONG, + false) + { + private final ThreadDatabase threadDatabase= DatabaseFactory.getThreadDatabase(getActivity()); + + private List pinnedThreadIds; + + @Override + protected void executeAction(@Nullable Long parameter) { + Context context = requireActivity(); + + pinnedThreadIds = threadDatabase.getPinnedThreadIds(); + threadDatabase.archiveConversation(threadId); + + if (unreadCount > 0) { + List messageIds = threadDatabase.setRead(threadId, false); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + } + } + + @Override + protected void reverseAction(@Nullable Long parameter) { + Context context = requireActivity(); + + threadDatabase.unarchiveConversation(threadId); + threadDatabase.restorePins(pinnedThreadIds); + + if (unreadCount > 0) { + threadDatabase.incrementUnread(threadId, unreadCount); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId); + } + + private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback { + + ArchiveListenerCallback() { + super(0, ItemTouchHelper.RIGHT); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) + { + return false; + } + + @Override + public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (viewHolder.itemView instanceof ConversationListItemAction || + viewHolder instanceof ConversationListAdapter.HeaderViewHolder || + actionMode != null || + activeAdapter == searchAdapter) + { + return 0; + } + + return super.getSwipeDirs(recyclerView, viewHolder); + } + + @SuppressLint("StaticFieldLeak") + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + if (viewHolder.itemView instanceof ConversationListItemInboxZero) return; + final long threadId = ((ConversationListItem)viewHolder.itemView).getThreadId(); + final int unreadCount = ((ConversationListItem)viewHolder.itemView).getUnreadCount(); + + onItemSwiped(threadId, unreadCount); + } + + @Override + public void onChildDraw(@NonNull Canvas canvas, @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dX, float dY, int actionState, + boolean isCurrentlyActive) + { + if (viewHolder.itemView instanceof ConversationListItemInboxZero) return; + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + View itemView = viewHolder.itemView; + float alpha = 1.0f - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); + + if (dX > 0) { + Resources resources = getResources(); + + if (archiveDrawable == null) { + archiveDrawable = ResourcesCompat.getDrawable(resources, getArchiveIconRes(), requireActivity().getTheme()); + Objects.requireNonNull(archiveDrawable).setBounds(0, 0, archiveDrawable.getIntrinsicWidth(), archiveDrawable.getIntrinsicHeight()); + } + + canvas.save(); + canvas.clipRect(itemView.getLeft(), itemView.getTop(), dX, itemView.getBottom()); + + canvas.drawColor(alpha > 0 ? resources.getColor(R.color.green_500) : Color.WHITE); + + canvas.translate(itemView.getLeft() + resources.getDimension(R.dimen.conversation_list_fragment_archive_padding), + itemView.getTop() + (itemView.getBottom() - itemView.getTop() - archiveDrawable.getIntrinsicHeight()) / 2f); + + archiveDrawable.draw(canvas); + canvas.restore(); + } + + viewHolder.itemView.setAlpha(alpha); + viewHolder.itemView.setTranslationX(dX); + } else { + super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } + } + } + + private class ScrollListener extends RecyclerView.OnScrollListener { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (recyclerView.canScrollVertically(-1)) { + if (toolbarShadow.getVisibility() != View.VISIBLE) { + ViewUtil.fadeIn(toolbarShadow, 250); + } + } else { + if (toolbarShadow.getVisibility() != View.GONE) { + ViewUtil.fadeOut(toolbarShadow, 250); + } + } + } + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java new file mode 100644 index 00000000..4cd2aa6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -0,0 +1,585 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversationlist; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.Transformations; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BindableConversationListItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.Unbindable; +import org.thoughtcrime.securesms.components.AlertView; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.DeliveryStatusView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.TypingIndicatorView; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.UpdateDescription; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.SearchUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collections; +import java.util.Locale; +import java.util.Set; + +import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync; + +public final class ConversationListItem extends ConstraintLayout + implements RecipientForeverObserver, + BindableConversationListItem, + Unbindable, + Observer +{ + @SuppressWarnings("unused") + private final static String TAG = Log.tag(ConversationListItem.class); + + private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); + private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); + + private Set selectedThreads; + private Set typingThreads; + private LiveRecipient recipient; + private long threadId; + private GlideRequests glideRequests; + private TextView subjectView; + private TypingIndicatorView typingView; + private FromTextView fromView; + private TextView dateView; + private TextView archivedView; + private DeliveryStatusView deliveryStatusIndicator; + private AlertView alertView; + private TextView unreadIndicator; + private long lastSeen; + private ThreadRecord thread; + private boolean batchMode; + + private int unreadCount; + private AvatarImageView contactPhotoImage; + private ThumbnailView thumbnailView; + + private final Debouncer subjectViewClearDebouncer = new Debouncer(150); + + private LiveData displayBody; + + public ConversationListItem(Context context) { + this(context, null); + } + + public ConversationListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + this.subjectView = findViewById(R.id.conversation_list_item_summary); + this.typingView = findViewById(R.id.conversation_list_item_typing_indicator); + this.fromView = findViewById(R.id.conversation_list_item_name); + this.dateView = findViewById(R.id.conversation_list_item_date); + this.deliveryStatusIndicator = findViewById(R.id.conversation_list_item_status); + this.alertView = findViewById(R.id.conversation_list_item_alert); + this.contactPhotoImage = findViewById(R.id.conversation_list_item_avatar); + this.thumbnailView = findViewById(R.id.conversation_list_item_thumbnail); + this.archivedView = findViewById(R.id.conversation_list_item_archived); + this.unreadIndicator = findViewById(R.id.conversation_list_item_unread_indicator); + thumbnailView.setClickable(false); + } + + @Override + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode) + { + bind(thread, glideRequests, locale, typingThreads, selectedThreads, batchMode, null); + } + + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode, + @Nullable String highlightSubstring) + { + observeRecipient(thread.getRecipient().live()); + observeDisplayBody(null); + setSubjectViewText(null); + + this.selectedThreads = selectedThreads; + this.threadId = thread.getThreadId(); + this.glideRequests = glideRequests; + this.unreadCount = thread.getUnreadCount(); + this.lastSeen = thread.getLastSeen(); + this.thread = thread; + + if (highlightSubstring != null) { + String name = recipient.get().isSelf() ? getContext().getString(R.string.note_to_self) : recipient.get().getDisplayName(getContext()); + + this.fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), name, highlightSubstring)); + } else { + this.fromView.setText(recipient.get(), thread.isRead()); + } + + this.typingThreads = typingThreads; + updateTypingIndicator(typingThreads); + + observeDisplayBody(getThreadDisplayBody(getContext(), thread)); + + this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); + this.subjectView.setTextColor(thread.isRead() ? ContextCompat.getColor(getContext(), R.color.signal_text_secondary) + : ContextCompat.getColor(getContext(), R.color.signal_text_primary)); + + if (thread.getDate() > 0) { + CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate()); + dateView.setText(date); + dateView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); + dateView.setTextColor(thread.isRead() ? ContextCompat.getColor(getContext(), R.color.signal_text_secondary) + : ContextCompat.getColor(getContext(), R.color.signal_text_primary)); + } + + if (thread.isArchived()) { + this.archivedView.setVisibility(View.VISIBLE); + } else { + this.archivedView.setVisibility(View.GONE); + } + + setStatusIcons(thread); + setThumbnailSnippet(thread); + setBatchMode(batchMode); + setRippleColor(recipient.get()); + setUnreadIndicator(thread); + this.contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode); + } + + public void bind(@NonNull Recipient contact, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @Nullable String highlightSubstring) + { + observeRecipient(contact.live()); + observeDisplayBody(null); + setSubjectViewText(null); + + this.selectedThreads = Collections.emptySet(); + this.glideRequests = glideRequests; + + + fromView.setText(contact); + fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), new SpannableString(fromView.getText()), highlightSubstring)); + setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getE164().or(""), highlightSubstring)); + dateView.setText(""); + archivedView.setVisibility(GONE); + unreadIndicator.setVisibility(GONE); + deliveryStatusIndicator.setNone(); + alertView.setNone(); + thumbnailView.setVisibility(GONE); + + setBatchMode(false); + setRippleColor(contact); + contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode); + } + + public void bind(@NonNull MessageResult messageResult, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @Nullable String highlightSubstring) + { + observeRecipient(messageResult.conversationRecipient.live()); + observeDisplayBody(null); + setSubjectViewText(null); + + this.selectedThreads = Collections.emptySet(); + this.glideRequests = glideRequests; + + fromView.setText(recipient.get(), true); + setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring)); + dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs)); + archivedView.setVisibility(GONE); + unreadIndicator.setVisibility(GONE); + deliveryStatusIndicator.setNone(); + alertView.setNone(); + thumbnailView.setVisibility(GONE); + + setBatchMode(false); + setRippleColor(recipient.get()); + contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode); + } + + @Override + public void unbind() { + if (this.recipient != null) { + observeRecipient(null); + setBatchMode(false); + contactPhotoImage.setAvatar(glideRequests, null, !batchMode); + } + + observeDisplayBody(null); + } + + @Override + public void setBatchMode(boolean batchMode) { + this.batchMode = batchMode; + setSelected(batchMode && selectedThreads.contains(thread.getThreadId())); + } + + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + if (typingThreads.contains(threadId)) { + this.subjectView.setVisibility(INVISIBLE); + + this.typingView.setVisibility(VISIBLE); + this.typingView.startAnimation(); + } else { + this.typingView.setVisibility(GONE); + this.typingView.stopAnimation(); + + this.subjectView.setVisibility(VISIBLE); + } + } + + public Recipient getRecipient() { + return recipient.get(); + } + + public long getThreadId() { + return threadId; + } + + public @NonNull ThreadRecord getThread() { + return thread; + } + + public int getUnreadCount() { + return unreadCount; + } + + public long getLastSeen() { + return lastSeen; + } + + private void observeRecipient(@Nullable LiveRecipient newRecipient) { + if (this.recipient != null) { + this.recipient.removeForeverObserver(this); + } + + this.recipient = newRecipient; + + if (this.recipient != null) { + this.recipient.observeForever(this); + } + } + + private void observeDisplayBody(@Nullable LiveData displayBody) { + if (this.displayBody != null) { + this.displayBody.removeObserver(this); + } + + this.displayBody = displayBody; + + if (this.displayBody != null) { + this.displayBody.observeForever(this); + } + } + + private void setSubjectViewText(@Nullable CharSequence text) { + if (text == null) { + subjectViewClearDebouncer.publish(() -> subjectView.setText(null)); + } else { + subjectViewClearDebouncer.clear(); + subjectView.setText(text); + subjectView.setVisibility(VISIBLE); + } + } + + private void setThumbnailSnippet(ThreadRecord thread) { + if (thread.getSnippetUri() != null) { + this.thumbnailView.setVisibility(View.VISIBLE); + this.thumbnailView.setImageResource(glideRequests, thread.getSnippetUri()); + } else { + this.thumbnailView.setVisibility(View.GONE); + } + } + + private void setStatusIcons(ThreadRecord thread) { + if (!thread.isOutgoing() || + thread.isOutgoingAudioCall() || + thread.isOutgoingVideoCall() || + thread.isVerificationStatusChange()) + { + deliveryStatusIndicator.setNone(); + alertView.setNone(); + } else if (thread.isFailed()) { + deliveryStatusIndicator.setNone(); + alertView.setFailed(); + } else if (thread.isPendingInsecureSmsFallback()) { + deliveryStatusIndicator.setNone(); + alertView.setPendingApproval(); + } else { + alertView.setNone(); + + if (thread.getExtra() != null && thread.getExtra().isRemoteDelete()) { + if (thread.isPending()) { + deliveryStatusIndicator.setPending(); + } else { + deliveryStatusIndicator.setNone(); + } + } else { + if (thread.isPending()) { + deliveryStatusIndicator.setPending(); + } else if (thread.isRemoteRead()) { + deliveryStatusIndicator.setRead(); + } else if (thread.isDelivered()) { + deliveryStatusIndicator.setDelivered(); + } else { + deliveryStatusIndicator.setSent(); + } + } + } + } + + private void setRippleColor(Recipient recipient) { + if (Build.VERSION.SDK_INT >= 21) { + ((RippleDrawable)(getBackground()).mutate()) + .setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext()))); + } + } + + private void setUnreadIndicator(ThreadRecord thread) { + if ((thread.isOutgoing() && !thread.isForcedUnread()) || thread.isRead()) { + unreadIndicator.setVisibility(View.GONE); + return; + } + + unreadIndicator.setText(unreadCount > 0 ? String.valueOf(unreadCount) : " "); + unreadIndicator.setVisibility(View.VISIBLE); + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + if (this.recipient == null || !this.recipient.getId().equals(recipient.getId())) { + Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId()); + return; + } + + fromView.setText(recipient, unreadCount == 0); + contactPhotoImage.setAvatar(glideRequests, recipient, !batchMode); + setRippleColor(recipient); + } + + private static @NonNull LiveData getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) { + int defaultTint = thread.isRead() ? ContextCompat.getColor(context, R.color.signal_text_secondary) + : ContextCompat.getColor(context, R.color.signal_text_primary); + + if (!thread.isMessageRequestAccepted()) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_message_request), defaultTint); + } else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) { + if (thread.getRecipient().isPushV2Group()) { + return emphasisAdded(context, MessageRecord.getGv2ChangeDescription(context, thread.getBody()), defaultTint); + } else { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_group_updated), defaultTint); + } + } else if (SmsDatabase.Types.isGroupQuit(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_left_the_group), defaultTint); + } else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ConversationListItem_key_exchange_message), defaultTint); + } else if (SmsDatabase.Types.isFailedDecryptType(thread.getType())) { + UpdateDescription description = UpdateDescription.staticDescription(context.getString(R.string.ThreadRecord_chat_session_refreshed), R.drawable.ic_refresh_16); + return emphasisAdded(context, description, defaultTint); + } else if (SmsDatabase.Types.isNoRemoteSessionType(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session), defaultTint); + } else if (SmsDatabase.Types.isEndSessionType(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_secure_session_reset), defaultTint); + } else if (MmsSmsColumns.Types.isLegacyType(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported), defaultTint); + } else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) { + String draftText = context.getString(R.string.ThreadRecord_draft); + return emphasisAdded(context, draftText + " " + thread.getBody(), defaultTint); + } else if (SmsDatabase.Types.isOutgoingAudioCall(thread.getType()) || SmsDatabase.Types.isOutgoingVideoCall(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_called), defaultTint); + } else if (SmsDatabase.Types.isIncomingAudioCall(thread.getType()) || SmsDatabase.Types.isIncomingVideoCall(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_called_you), defaultTint); + } else if (SmsDatabase.Types.isMissedAudioCall(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_missed_audio_call), defaultTint); + } else if (SmsDatabase.Types.isMissedVideoCall(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_missed_video_call), defaultTint); + } else if (MmsSmsColumns.Types.isGroupCall(thread.getType())) { + return emphasisAdded(context, MessageRecord.getGroupCallUpdateDescription(context, thread.getBody(), false), defaultTint); + } else if (SmsDatabase.Types.isJoinedType(thread.getType())) { + return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> new SpannableString(context.getString(R.string.ThreadRecord_s_is_on_signal, r.getDisplayName(context))))); + } else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) { + int seconds = (int)(thread.getExpiresIn() / 1000); + if (seconds <= 0) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_disappearing_messages_disabled), defaultTint); + } + String time = ExpirationUtil.getExpirationDisplayValue(context, seconds); + return emphasisAdded(context, context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time), defaultTint); + } else if (SmsDatabase.Types.isIdentityUpdate(thread.getType())) { + return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> { + if (r.isGroup()) { + return new SpannableString(context.getString(R.string.ThreadRecord_safety_number_changed)); + } else { + return new SpannableString(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context))); + } + })); + } else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_you_marked_verified), defaultTint); + } else if (SmsDatabase.Types.isIdentityDefault(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_you_marked_unverified), defaultTint); + } else if (SmsDatabase.Types.isUnsupportedMessageType(thread.getType())) { + return emphasisAdded(context, context.getString(R.string.ThreadRecord_message_could_not_be_processed), defaultTint); + } else if (SmsDatabase.Types.isProfileChange(thread.getType())) { + return emphasisAdded(context, "", defaultTint); + } else { + ThreadDatabase.Extra extra = thread.getExtra(); + if (extra != null && extra.isViewOnce()) { + return emphasisAdded(context, getViewOnceDescription(context, thread.getContentType()), defaultTint); + } else if (extra != null && extra.isRemoteDelete()) { + return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint); + } else { + String body = removeNewlines(thread.getBody()); + if (thread.getRecipient().isGroup()) { + RecipientId groupMessageSender = thread.getGroupMessageSender(); + if (!groupMessageSender.isUnknown()) { + return describeGroupMessage(context, body, groupMessageSender, thread.isRead()); + } + } + return LiveDataUtil.just(new SpannableString(body)); + } + } + } + + private static LiveData describeGroupMessage(@NonNull Context context, + @NonNull String body, + @NonNull RecipientId groupMessageSender, + boolean read) + { + return whileLoadingShow(body, recipientToStringAsync(groupMessageSender, + r -> createGroupMessageUpdateString(context, body, r, read))); + } + + private static SpannableString createGroupMessageUpdateString(@NonNull Context context, + @NonNull String body, + @NonNull Recipient recipient, + boolean read) + { + String sender = (recipient.isSelf() ? context.getString(R.string.MessageRecord_you) + : recipient.getShortDisplayName(context)) + ": "; + + SpannableString spannable = new SpannableString(sender + body); + spannable.setSpan(new TextAppearanceSpan(context, read ? R.style.Signal_Text_Preview_Medium_Secondary + : R.style.Signal_Text_Preview_Medium_Primary), + 0, + sender.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spannable; + } + + /** After a short delay, if the main data hasn't shown yet, then a loading message is displayed. */ + private static @NonNull LiveData whileLoadingShow(@NonNull String loading, @NonNull LiveData string) { + return LiveDataUtil.until(string, LiveDataUtil.delay(250, new SpannableString(loading))); + } + + private static @NonNull String removeNewlines(@Nullable String text) { + if (text == null) { + return ""; + } + + if (text.indexOf('\n') >= 0) { + return text.replaceAll("\n", " "); + } else { + return text; + } + } + + private static @NonNull LiveData emphasisAdded(@NonNull Context context, @NonNull String string, @ColorInt int defaultTint) { + return emphasisAdded(context, UpdateDescription.staticDescription(string, 0), defaultTint); + } + + private static @NonNull LiveData emphasisAdded(@NonNull Context context, @NonNull UpdateDescription description, @ColorInt int defaultTint) { + return emphasisAdded(LiveUpdateMessage.fromMessageDescription(context, description, defaultTint)); + } + + private static @NonNull LiveData emphasisAdded(@NonNull LiveData description) { + return Transformations.map(description, sequence -> { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), + 0, + sequence.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + }); + } + + private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) { + if (MediaUtil.isViewOnceType(contentType)) { + return context.getString(R.string.ThreadRecord_view_once_media); + } else if (MediaUtil.isVideoType(contentType)) { + return context.getString(R.string.ThreadRecord_view_once_video); + } else { + return context.getString(R.string.ThreadRecord_view_once_photo); + } + } + + @Override + public void onChanged(SpannableString spannableString) { + setSubjectViewText(spannableString); + + if (typingThreads != null) { + updateTypingIndicator(typingThreads); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java new file mode 100644 index 00000000..0935258d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.BindableConversationListItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.Locale; +import java.util.Set; + +public class ConversationListItemAction extends FrameLayout implements BindableConversationListItem { + + private TextView description; + + public ConversationListItemAction(Context context) { + super(context); + } + + public ConversationListItemAction(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public ConversationListItemAction(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.description = findViewById(R.id.description); + } + + @Override + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode) + { + this.description.setText(getContext().getString(R.string.ConversationListItemAction_archived_conversations_d, thread.getCount())); + } + + @Override + public void unbind() { + + } + + @Override + public void setBatchMode(boolean batchMode) { + + } + + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java new file mode 100644 index 00000000..b5ce4696 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.conversationlist; + + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.BindableConversationListItem; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.Locale; +import java.util.Set; + +public class ConversationListItemInboxZero extends LinearLayout implements BindableConversationListItem { + public ConversationListItemInboxZero(Context context) { + super(context); + } + + public ConversationListItemInboxZero(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ConversationListItemInboxZero(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public ConversationListItemInboxZero(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public void unbind() { + + } + + @Override + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode) + { + + } + + @Override + public void setBatchMode(boolean batchMode) { + + } + + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java new file mode 100644 index 00000000..bccbeb19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java @@ -0,0 +1,218 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.conversationlist.model.SearchResult; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; + +import java.util.Collections; +import java.util.Locale; + +class ConversationListSearchAdapter extends RecyclerView.Adapter + implements StickyHeaderDecoration.StickyHeaderAdapter +{ + private static final int TYPE_CONVERSATIONS = 1; + private static final int TYPE_CONTACTS = 2; + private static final int TYPE_MESSAGES = 3; + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final Locale locale; + + @NonNull + private SearchResult searchResult = SearchResult.EMPTY; + + ConversationListSearchAdapter(@NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull Locale locale) + { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.locale = locale; + } + + @Override + public @NonNull SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new SearchResultViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.conversation_list_item_view, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) { + ThreadRecord conversationResult = getConversationResult(position); + + if (conversationResult != null) { + holder.bind(conversationResult, glideRequests, eventListener, locale, searchResult.getQuery()); + return; + } + + Recipient contactResult = getContactResult(position); + + if (contactResult != null) { + holder.bind(contactResult, glideRequests, eventListener, locale, searchResult.getQuery()); + return; + } + + MessageResult messageResult = getMessageResult(position); + + if (messageResult != null) { + holder.bind(messageResult, glideRequests, eventListener, locale, searchResult.getQuery()); + } + } + + @Override + public void onViewRecycled(@NonNull SearchResultViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return searchResult.size(); + } + + @Override + public long getHeaderId(int position) { + if (getConversationResult(position) != null) { + return TYPE_CONVERSATIONS; + } else if (getContactResult(position) != null) { + return TYPE_CONTACTS; + } else { + return TYPE_MESSAGES; + } + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) { + return new HeaderViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.search_result_list_divider, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) { + viewHolder.bind((int) getHeaderId(position)); + } + + void updateResults(@NonNull SearchResult result) { + this.searchResult = result; + notifyDataSetChanged(); + } + + @Nullable + private ThreadRecord getConversationResult(int position) { + if (position < searchResult.getConversations().size()) { + return searchResult.getConversations().get(position); + } + return null; + } + + @Nullable + private Recipient getContactResult(int position) { + if (position >= getFirstContactIndex() && position < getFirstMessageIndex()) { + return searchResult.getContacts().get(position - getFirstContactIndex()); + } + return null; + } + + @Nullable + private MessageResult getMessageResult(int position) { + if (position >= getFirstMessageIndex() && position < searchResult.size()) { + return searchResult.getMessages().get(position - getFirstMessageIndex()); + } + return null; + } + + private int getFirstContactIndex() { + return searchResult.getConversations().size(); + } + + private int getFirstMessageIndex() { + return getFirstContactIndex() + searchResult.getContacts().size(); + } + + public interface EventListener { + void onConversationClicked(@NonNull ThreadRecord threadRecord); + void onContactClicked(@NonNull Recipient contact); + void onMessageClicked(@NonNull MessageResult message); + } + + static class SearchResultViewHolder extends RecyclerView.ViewHolder { + + private final ConversationListItem root; + + SearchResultViewHolder(View itemView) { + super(itemView); + root = (ConversationListItem) itemView; + } + + void bind(@NonNull ThreadRecord conversationResult, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull Locale locale, + @Nullable String query) + { + root.bind(conversationResult, glideRequests, locale, Collections.emptySet(), Collections.emptySet(), false, query); + root.setOnClickListener(view -> eventListener.onConversationClicked(conversationResult)); + } + + void bind(@NonNull Recipient contactResult, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull Locale locale, + @Nullable String query) + { + root.bind(contactResult, glideRequests, locale, query); + root.setOnClickListener(view -> eventListener.onContactClicked(contactResult)); + } + + void bind(@NonNull MessageResult messageResult, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull Locale locale, + @Nullable String query) + { + root.bind(messageResult, glideRequests, locale, query); + root.setOnClickListener(view -> eventListener.onMessageClicked(messageResult)); + } + + void recycle() { + root.unbind(); + root.setOnClickListener(null); + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + + private TextView titleView; + + public HeaderViewHolder(View itemView) { + super(itemView); + titleView = itemView.findViewById(R.id.label); + } + + public void bind(int headerType) { + switch (headerType) { + case TYPE_CONVERSATIONS: + titleView.setText(R.string.SearchFragment_header_conversations); + break; + case TYPE_CONTACTS: + titleView.setText(R.string.SearchFragment_header_contacts); + break; + case TYPE_MESSAGES: + titleView.setText(R.string.SearchFragment_header_messages); + break; + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java new file mode 100644 index 00000000..71f7628a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -0,0 +1,178 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.app.Application; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.logging.Log; +import org.signal.paging.PagedData; +import org.signal.paging.PagingConfig; +import org.signal.paging.PagingController; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.conversationlist.model.SearchResult; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.DatabaseObserver; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.search.SearchRepository; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.ThrottledDebouncer; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.paging.Invalidator; + +import java.util.List; + +class ConversationListViewModel extends ViewModel { + + private static final String TAG = Log.tag(ConversationListViewModel.class); + + private static boolean coldStart = true; + + private final MutableLiveData megaphone; + private final MutableLiveData searchResult; + private final PagedData pagedData; + private final LiveData hasNoConversations; + private final SearchRepository searchRepository; + private final MegaphoneRepository megaphoneRepository; + private final Debouncer searchDebouncer; + private final ThrottledDebouncer updateDebouncer; + private final DatabaseObserver.Observer observer; + private final Invalidator invalidator; + + private String lastQuery; + private int pinnedCount; + + private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) { + this.megaphone = new MutableLiveData<>(); + this.searchResult = new MutableLiveData<>(); + this.searchRepository = searchRepository; + this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository(); + this.searchDebouncer = new Debouncer(300); + this.updateDebouncer = new ThrottledDebouncer(500); + this.invalidator = new Invalidator(); + this.pagedData = PagedData.create(ConversationListDataSource.create(application, isArchived), + new PagingConfig.Builder() + .setPageSize(15) + .setBufferPages(2) + .build()); + this.observer = () -> { + updateDebouncer.publish(() -> { + if (!TextUtils.isEmpty(getLastQuery())) { + searchRepository.query(getLastQuery(), searchResult::postValue); + } + pagedData.getController().onDataInvalidated(); + }); + }; + + this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> { + pinnedCount = DatabaseFactory.getThreadDatabase(application).getPinnedConversationListCount(); + + if (conversations.size() > 0) { + return false; + } else { + return DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount() == 0; + } + }); + + ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer); + } + + public LiveData hasNoConversations() { + return hasNoConversations; + } + + @NonNull LiveData getSearchResult() { + return searchResult; + } + + @NonNull LiveData getMegaphone() { + return megaphone; + } + + @NonNull LiveData> getConversationList() { + return pagedData.getData(); + } + + @NonNull PagingController getPagingController() { + return pagedData.getController(); + } + + @NonNull LiveData getPipeState() { + return ApplicationDependencies.getPipeListener().getState(); + } + + public int getPinnedCount() { + return pinnedCount; + } + + void onVisible() { + megaphoneRepository.getNextMegaphone(megaphone::postValue); + + if (!coldStart) { + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); + } + + coldStart = false; + } + + void onMegaphoneCompleted(@NonNull Megaphones.Event event) { + megaphone.postValue(null); + megaphoneRepository.markFinished(event); + } + + void onMegaphoneSnoozed(@NonNull Megaphones.Event event) { + megaphoneRepository.markSeen(event); + megaphone.postValue(null); + } + + void onMegaphoneVisible(@NonNull Megaphone visible) { + megaphoneRepository.markVisible(visible.getEvent()); + } + + void updateQuery(String query) { + lastQuery = query; + searchDebouncer.publish(() -> searchRepository.query(query, result -> { + Util.runOnMain(() -> { + if (query.equals(lastQuery)) { + searchResult.setValue(result); + } + }); + })); + } + + private @NonNull String getLastQuery() { + return lastQuery == null ? "" : lastQuery; + } + + @Override + protected void onCleared() { + invalidator.invalidate(); + searchDebouncer.clear(); + updateDebouncer.clear(); + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer); + } + + public static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final boolean isArchived; + + public Factory(boolean isArchived) { + this.isArchived = isArchived; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java new file mode 100644 index 00000000..0183b66d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.conversationlist.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; + +public class Conversation { + private final ThreadRecord threadRecord; + private final Type type; + + public Conversation(@NonNull ThreadRecord threadRecord) { + this.threadRecord = threadRecord; + if (this.threadRecord.getThreadId() < 0) { + type = Type.valueOf(this.threadRecord.getBody()); + } else { + type = Type.THREAD; + } + } + + public @NonNull ThreadRecord getThreadRecord() { + return threadRecord; + } + + public @NonNull Type getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Conversation that = (Conversation) o; + return threadRecord.equals(that.threadRecord); + } + + @Override + public int hashCode() { + return threadRecord.hashCode(); + } + + public enum Type { + THREAD, + PINNED_HEADER, + UNPINNED_HEADER, + ARCHIVED_FOOTER + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java new file mode 100644 index 00000000..d1eebac4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.conversationlist.model; + +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.CursorUtil; + +public class ConversationReader extends ThreadDatabase.StaticReader { + + public static final String[] HEADER_COLUMN = {"header"}; + public static final String[] ARCHIVED_COLUMNS = {"header", "count"}; + public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()}; + public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()}; + + private final Cursor cursor; + + public ConversationReader(@NonNull Cursor cursor) { + super(cursor, ApplicationDependencies.getApplication()); + this.cursor = cursor; + } + + public static String[] createArchivedFooterRow(int archivedCount) { + return new String[]{Conversation.Type.ARCHIVED_FOOTER.toString(), String.valueOf(archivedCount)}; + } + + @Override + public ThreadRecord getCurrent() { + if (cursor.getColumnIndex(HEADER_COLUMN[0]) == -1) { + return super.getCurrent(); + } else { + return buildThreadRecordForHeader(); + } + } + + private ThreadRecord buildThreadRecordForHeader() { + Conversation.Type type = Conversation.Type.valueOf(CursorUtil.requireString(cursor, HEADER_COLUMN[0])); + int count = 0; + if (type == Conversation.Type.ARCHIVED_FOOTER) { + count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]); + } + return new ThreadRecord.Builder(-(100 + type.ordinal())) + .setBody(type.toString()) + .setDate(100) + .setRecipient(Recipient.UNKNOWN) + .setCount(count) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java new file mode 100644 index 00000000..17fd7b55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.conversationlist.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Represents a search result for a message. + */ +public class MessageResult { + + public final Recipient conversationRecipient; + public final Recipient messageRecipient; + public final String body; + public final String bodySnippet; + public final long threadId; + public final long messageId; + public final long receivedTimestampMs; + public final boolean isMms; + + public MessageResult(@NonNull Recipient conversationRecipient, + @NonNull Recipient messageRecipient, + @NonNull String body, + @NonNull String bodySnippet, + long threadId, + long messageId, + long receivedTimestampMs, + boolean isMms) + { + this.conversationRecipient = conversationRecipient; + this.messageRecipient = messageRecipient; + this.body = body; + this.bodySnippet = bodySnippet; + this.threadId = threadId; + this.messageId = messageId; + this.receivedTimestampMs = receivedTimestampMs; + this.isMms = isMms; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java new file mode 100644 index 00000000..fcc8c0f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.conversationlist.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.List; + +/** + * Represents an all-encompassing search result that can contain various result for different + * subcategories. + */ +public class SearchResult { + + public static final SearchResult EMPTY = new SearchResult("", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + + private final String query; + private final List contacts; + private final List conversations; + private final List messages; + + public SearchResult(@NonNull String query, + @NonNull List contacts, + @NonNull List conversations, + @NonNull List messages) + { + this.query = query; + this.contacts = contacts; + this.conversations = conversations; + this.messages = messages; + } + + public List getContacts() { + return contacts; + } + + public List getConversations() { + return conversations; + } + + public List getMessages() { + return messages; + } + + public String getQuery() { + return query; + } + + public int size() { + return contacts.size() + conversations.size() + messages.size(); + } + + public boolean isEmpty() { + return size() == 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterCipher.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterCipher.java new file mode 100644 index 00000000..5a6b1dfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterCipher.java @@ -0,0 +1,138 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import org.signal.core.util.Conversions; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * This class is used to asymmetrically encrypt local data. This is used in the case + * where TextSecure receives an SMS, but the user's local encryption passphrase is + * not cached (either because of a timeout, or because it hasn't yet been entered). + * + * In this case, we have access to the public key of a local keypair. We encrypt + * the message with this, and put it into the DB. When the user enters their passphrase, + * we can get access to the private key of the local keypair, decrypt the message, and + * replace it into the DB with symmetric encryption. + * + * The encryption protocol is as follows: + * + * 1) Generate an ephemeral keypair. + * 2) Do ECDH with the public key of the local durable keypair. + * 3) Do KMF with the ECDH result to obtain a master secret. + * 4) Encrypt the message with that master secret. + * + * @author Moxie Marlinspike + * + */ +public class AsymmetricMasterCipher { + + private final AsymmetricMasterSecret asymmetricMasterSecret; + + public AsymmetricMasterCipher(AsymmetricMasterSecret asymmetricMasterSecret) { + this.asymmetricMasterSecret = asymmetricMasterSecret; + } + + public byte[] encryptBytes(byte[] body) { + try { + ECPublicKey theirPublic = asymmetricMasterSecret.getDjbPublicKey(); + ECKeyPair ourKeyPair = Curve.generateKeyPair(); + byte[] secret = Curve.calculateAgreement(theirPublic, ourKeyPair.getPrivateKey()); + MasterCipher masterCipher = getMasterCipherForSecret(secret); + byte[] encryptedBodyBytes = masterCipher.encryptBytes(body); + + PublicKey ourPublicKey = new PublicKey(31337, ourKeyPair.getPublicKey()); + byte[] publicKeyBytes = ourPublicKey.serialize(); + + return Util.combine(publicKeyBytes, encryptedBodyBytes); + } catch (InvalidKeyException e) { + throw new AssertionError(e); + } + } + + public byte[] decryptBytes(byte[] combined) throws IOException, InvalidMessageException { + try { + byte[][] parts = Util.split(combined, PublicKey.KEY_SIZE, combined.length - PublicKey.KEY_SIZE); + PublicKey theirPublicKey = new PublicKey(parts[0], 0); + + ECPrivateKey ourPrivateKey = asymmetricMasterSecret.getPrivateKey(); + byte[] secret = Curve.calculateAgreement(theirPublicKey.getKey(), ourPrivateKey); + MasterCipher masterCipher = getMasterCipherForSecret(secret); + + return masterCipher.decryptBytes(parts[1]); + } catch (InvalidKeyException e) { + throw new InvalidMessageException(e); + } + } + + public String decryptBody(String body) throws IOException, InvalidMessageException { + byte[] combined = Base64.decode(body); + return new String(decryptBytes(combined)); + } + + public String encryptBody(String body) { + return Base64.encodeBytes(encryptBytes(body.getBytes())); + } + + private MasterCipher getMasterCipherForSecret(byte[] secretBytes) { + SecretKeySpec cipherKey = deriveCipherKey(secretBytes); + SecretKeySpec macKey = deriveMacKey(secretBytes); + MasterSecret masterSecret = new MasterSecret(cipherKey, macKey); + + return new MasterCipher(masterSecret); + } + + private SecretKeySpec deriveMacKey(byte[] secretBytes) { + byte[] digestedBytes = getDigestedBytes(secretBytes, 1); + byte[] macKeyBytes = new byte[20]; + + System.arraycopy(digestedBytes, 0, macKeyBytes, 0, macKeyBytes.length); + return new SecretKeySpec(macKeyBytes, "HmacSHA1"); + } + + private SecretKeySpec deriveCipherKey(byte[] secretBytes) { + byte[] digestedBytes = getDigestedBytes(secretBytes, 0); + byte[] cipherKeyBytes = new byte[16]; + + System.arraycopy(digestedBytes, 0, cipherKeyBytes, 0, cipherKeyBytes.length); + return new SecretKeySpec(cipherKeyBytes, "AES"); + } + + private byte[] getDigestedBytes(byte[] secretBytes, int iteration) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretBytes, "HmacSHA256")); + return mac.doFinal(Conversions.intToByteArray(iteration)); + } catch (NoSuchAlgorithmException | java.security.InvalidKeyException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterSecret.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterSecret.java new file mode 100644 index 00000000..36dfe4b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/AsymmetricMasterSecret.java @@ -0,0 +1,61 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; + +/** + * When a user first initializes TextSecure, a few secrets + * are generated. These are: + * + * 1) A 128bit symmetric encryption key. + * 2) A 160bit symmetric MAC key. + * 3) An ECC keypair. + * + * The first two, along with the ECC keypair's private key, are + * then encrypted on disk using PBE. + * + * This class represents the ECC keypair. + * + * @author Moxie Marlinspike + * + */ + +public class AsymmetricMasterSecret { + + private final ECPublicKey djbPublicKey; + private final ECPrivateKey djbPrivateKey; + + + public AsymmetricMasterSecret(ECPublicKey djbPublicKey, ECPrivateKey djbPrivateKey) + { + this.djbPublicKey = djbPublicKey; + this.djbPrivateKey = djbPrivateKey; + } + + public ECPublicKey getDjbPublicKey() { + return djbPublicKey; + } + + + public ECPrivateKey getPrivateKey() { + return djbPrivateKey; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecret.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecret.java new file mode 100644 index 00000000..7d16ec6f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecret.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.util.Base64; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; + +/** + * Encapsulates the key material used to encrypt attachments on disk. + * + * There are two logical pieces of material, a deprecated set of keys used to encrypt + * legacy attachments, and a key that is used to encrypt attachments going forward. + */ +public class AttachmentSecret { + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] classicCipherKey; + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] classicMacKey; + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] modernKey; + + public AttachmentSecret(byte[] classicCipherKey, byte[] classicMacKey, byte[] modernKey) + { + this.classicCipherKey = classicCipherKey; + this.classicMacKey = classicMacKey; + this.modernKey = modernKey; + } + + @SuppressWarnings("unused") + public AttachmentSecret() { + + } + + @JsonIgnore + byte[] getClassicCipherKey() { + return classicCipherKey; + } + + @JsonIgnore + byte[] getClassicMacKey() { + return classicMacKey; + } + + @JsonIgnore + public byte[] getModernKey() { + return modernKey; + } + + @JsonIgnore + void setClassicCipherKey(byte[] classicCipherKey) { + this.classicCipherKey = classicCipherKey; + } + + @JsonIgnore + void setClassicMacKey(byte[] classicMacKey) { + this.classicMacKey = classicMacKey; + } + + public String serialize() { + try { + return JsonUtils.toJson(this); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + static AttachmentSecret fromString(@NonNull String value) { + try { + return JsonUtils.fromJson(value, AttachmentSecret.class); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING)); + } + } + + private static class ByteArrayDeserializer extends JsonDeserializer { + + @Override + public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING); + } + } + + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java new file mode 100644 index 00000000..2937b424 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.security.SecureRandom; + +/** + * A provider that is responsible for creating or retrieving the AttachmentSecret model. + * + * On modern Android, the serialized secrets are themselves encrypted using a key that lives + * in the system KeyStore, for whatever that is worth. + */ +public class AttachmentSecretProvider { + + private static AttachmentSecretProvider provider; + + public static synchronized AttachmentSecretProvider getInstance(@NonNull Context context) { + if (provider == null) provider = new AttachmentSecretProvider(context.getApplicationContext()); + return provider; + } + + private final Context context; + + private AttachmentSecret attachmentSecret; + + private AttachmentSecretProvider(@NonNull Context context) { + this.context = context.getApplicationContext(); + } + + public synchronized AttachmentSecret getOrCreateAttachmentSecret() { + if (attachmentSecret != null) return attachmentSecret; + + String unencryptedSecret = TextSecurePreferences.getAttachmentUnencryptedSecret(context); + String encryptedSecret = TextSecurePreferences.getAttachmentEncryptedSecret(context); + + if (unencryptedSecret != null) attachmentSecret = getUnencryptedAttachmentSecret(context, unencryptedSecret); + else if (encryptedSecret != null) attachmentSecret = getEncryptedAttachmentSecret(encryptedSecret); + else attachmentSecret = createAndStoreAttachmentSecret(context); + + return attachmentSecret; + } + + public synchronized AttachmentSecret setClassicKey(@NonNull Context context, @NonNull byte[] classicCipherKey, @NonNull byte[] classicMacKey) { + AttachmentSecret currentSecret = getOrCreateAttachmentSecret(); + currentSecret.setClassicCipherKey(classicCipherKey); + currentSecret.setClassicMacKey(classicMacKey); + + storeAttachmentSecret(context, attachmentSecret); + + return attachmentSecret; + } + + private AttachmentSecret getUnencryptedAttachmentSecret(@NonNull Context context, @NonNull String unencryptedSecret) + { + AttachmentSecret attachmentSecret = AttachmentSecret.fromString(unencryptedSecret); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return attachmentSecret; + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(attachmentSecret.serialize().getBytes()); + + TextSecurePreferences.setAttachmentEncryptedSecret(context, encryptedSecret.serialize()); + TextSecurePreferences.setAttachmentUnencryptedSecret(context, null); + + return attachmentSecret; + } + } + + private AttachmentSecret getEncryptedAttachmentSecret(@NonNull String serializedEncryptedSecret) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new AssertionError("OS downgrade not supported. KeyStore sealed data exists on platform < M!"); + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.SealedData.fromString(serializedEncryptedSecret); + return AttachmentSecret.fromString(new String(KeyStoreHelper.unseal(encryptedSecret))); + } + } + + private AttachmentSecret createAndStoreAttachmentSecret(@NonNull Context context) { + SecureRandom random = new SecureRandom(); + byte[] secret = new byte[32]; + random.nextBytes(secret); + + AttachmentSecret attachmentSecret = new AttachmentSecret(null, null, secret); + storeAttachmentSecret(context, attachmentSecret); + + return attachmentSecret; + } + + private void storeAttachmentSecret(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(attachmentSecret.serialize().getBytes()); + TextSecurePreferences.setAttachmentEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setAttachmentUnencryptedSecret(context, attachmentSecret.serialize()); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/ClassicDecryptingPartInputStream.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/ClassicDecryptingPartInputStream.java new file mode 100644 index 00000000..9595955c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/ClassicDecryptingPartInputStream.java @@ -0,0 +1,170 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.LimitedInputStream; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ClassicDecryptingPartInputStream { + + private static final String TAG = ClassicDecryptingPartInputStream.class.getSimpleName(); + + private static final int IV_LENGTH = 16; + private static final int MAC_LENGTH = 20; + + public static InputStream createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull File file) + throws IOException + { + try { + if (file.length() <= IV_LENGTH + MAC_LENGTH) { + throw new IOException("File too short"); + } + + verifyMac(attachmentSecret, file); + + FileInputStream fileStream = new FileInputStream(file); + byte[] ivBytes = new byte[IV_LENGTH]; + readFully(fileStream, ivBytes); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(attachmentSecret.getClassicCipherKey(), "AES"), iv); + + return new CipherInputStreamWrapper(new LimitedInputStream(fileStream, file.length() - MAC_LENGTH - IV_LENGTH), cipher); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + private static void verifyMac(AttachmentSecret attachmentSecret, File file) throws IOException { + Mac mac = initializeMac(new SecretKeySpec(attachmentSecret.getClassicMacKey(), "HmacSHA1")); + FileInputStream macStream = new FileInputStream(file); + InputStream dataStream = new LimitedInputStream(new FileInputStream(file), file.length() - MAC_LENGTH); + byte[] theirMac = new byte[MAC_LENGTH]; + + if (macStream.skip(file.length() - MAC_LENGTH) != file.length() - MAC_LENGTH) { + throw new IOException("Unable to seek"); + } + + readFully(macStream, theirMac); + + byte[] buffer = new byte[4096]; + int read; + + while ((read = dataStream.read(buffer)) != -1) { + mac.update(buffer, 0, read); + } + + byte[] ourMac = mac.doFinal(); + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new IOException("Bad MAC"); + } + + macStream.close(); + dataStream.close(); + } + + private static Mac initializeMac(SecretKeySpec key) { + try { + Mac hmac = Mac.getInstance("HmacSHA1"); + hmac.init(key); + + return hmac; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + private static void readFully(InputStream in, byte[] buffer) throws IOException { + int offset = 0; + + for (;;) { + int read = in.read(buffer, offset, buffer.length-offset); + + if (read + offset < buffer.length) offset += read; + else return; + } + } + + // Note (4/3/17) -- Older versions of Android have a busted OpenSSL provider that + // throws a RuntimeException on a BadPaddingException, so we have to catch + // that here in case someone calls close() before reaching the end of the + // stream (since close() implicitly calls doFinal()) + // + // See Signal-Android Issue #6477 + // Android: https://android-review.googlesource.com/#/c/65321/ + private static class CipherInputStreamWrapper extends CipherInputStream { + + CipherInputStreamWrapper(InputStream is, Cipher c) { + super(is, c); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } catch (Throwable t) { + Log.w(TAG, t); + } + } + + @Override + public long skip(long skipAmount) + throws IOException + { + long remaining = skipAmount; + + if (skipAmount <= 0) { + return 0; + } + + byte[] skipBuffer = new byte[4092]; + + while (remaining > 0) { + int read = super.read(skipBuffer, 0, Util.toIntExact(Math.min(skipBuffer.length, remaining))); + + if (read < 0) { + break; + } + + remaining -= read; + } + + return skipAmount - remaining; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java new file mode 100644 index 00000000..bdd425e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecret.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.crypto; + + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.Hex; + +import java.io.IOException; + +public class DatabaseSecret { + + private final byte[] key; + private final String encoded; + + public DatabaseSecret(@NonNull byte[] key) { + this.key = key; + this.encoded = Hex.toStringCondensed(key); + } + + public DatabaseSecret(@NonNull String encoded) throws IOException { + this.key = Hex.fromStringCondensed(encoded); + this.encoded = encoded; + } + + public String asString() { + return encoded; + } + + public byte[] asBytes() { + return key; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java new file mode 100644 index 00000000..79807649 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; +import java.security.SecureRandom; + +/** + * It can be rather expensive to read from the keystore, so this class caches the key in memory + * after it is created. + */ +public final class DatabaseSecretProvider { + + private static volatile DatabaseSecret instance; + + public static DatabaseSecret getOrCreateDatabaseSecret(@NonNull Context context) { + if (instance == null) { + synchronized (DatabaseSecretProvider.class) { + if (instance == null) { + instance = getOrCreate(context); + } + } + } + + return instance; + } + + private DatabaseSecretProvider() { + } + + private static @NonNull DatabaseSecret getOrCreate(@NonNull Context context) { + String unencryptedSecret = TextSecurePreferences.getDatabaseUnencryptedSecret(context); + String encryptedSecret = TextSecurePreferences.getDatabaseEncryptedSecret(context); + + if (unencryptedSecret != null) return getUnencryptedDatabaseSecret(context, unencryptedSecret); + else if (encryptedSecret != null) return getEncryptedDatabaseSecret(encryptedSecret); + else return createAndStoreDatabaseSecret(context); + } + + private static @NonNull DatabaseSecret getUnencryptedDatabaseSecret(@NonNull Context context, @NonNull String unencryptedSecret) + { + try { + DatabaseSecret databaseSecret = new DatabaseSecret(unencryptedSecret); + + if (Build.VERSION.SDK_INT < 23) { + return databaseSecret; + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes()); + + TextSecurePreferences.setDatabaseEncryptedSecret(context, encryptedSecret.serialize()); + TextSecurePreferences.setDatabaseUnencryptedSecret(context, null); + + return databaseSecret; + } + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static @NonNull DatabaseSecret getEncryptedDatabaseSecret(@NonNull String serializedEncryptedSecret) { + if (Build.VERSION.SDK_INT < 23) { + throw new AssertionError("OS downgrade not supported. KeyStore sealed data exists on platform < M!"); + } else { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.SealedData.fromString(serializedEncryptedSecret); + return new DatabaseSecret(KeyStoreHelper.unseal(encryptedSecret)); + } + } + + private static @NonNull DatabaseSecret createAndStoreDatabaseSecret(@NonNull Context context) { + SecureRandom random = new SecureRandom(); + byte[] secret = new byte[32]; + random.nextBytes(secret); + + DatabaseSecret databaseSecret = new DatabaseSecret(secret); + + if (Build.VERSION.SDK_INT >= 23) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes()); + TextSecurePreferences.setDatabaseEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setDatabaseUnencryptedSecret(context, databaseSecret.asString()); + } + + return databaseSecret; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSessionLock.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSessionLock.java new file mode 100644 index 00000000..3d9d70b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSessionLock.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.crypto; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.concurrent.locks.ReentrantLock; + +/** + * An implementation of {@link SignalSessionLock} that effectively re-uses our database lock. + */ +public enum DatabaseSessionLock implements SignalSessionLock { + + INSTANCE; + + public static final long NO_OWNER = -1; + + private static final ReentrantLock LEGACY_LOCK = new ReentrantLock(); + + private volatile long ownerThreadId = NO_OWNER; + + @Override + public Lock acquire() { + if (FeatureFlags.internalUser()) { + SQLiteDatabase db = DatabaseFactory.getInstance(ApplicationDependencies.getApplication()).getRawDatabase(); + + if (db.isDbLockedByCurrentThread()) { + return () -> {}; + } + + db.beginTransaction(); + + ownerThreadId = Thread.currentThread().getId(); + + return () -> { + ownerThreadId = -1; + db.setTransactionSuccessful(); + db.endTransaction(); + }; + } else { + LEGACY_LOCK.lock(); + return LEGACY_LOCK::unlock; + } + } + + /** + * Important: Only truly useful for debugging. Do not rely on this for functionality. There's tiny + * windows where this state might not be fully accurate. + * + * @return True if it's likely that some other thread owns this lock, and it's not you. + */ + public boolean isLikelyHeldByOtherThread() { + long ownerThreadId = this.ownerThreadId; + return ownerThreadId != -1 && ownerThreadId == Thread.currentThread().getId(); + } + + /** + * Important: Only truly useful for debugging. Do not rely on this for functionality. There's a + * tiny window where a thread may still own the lock, but the state we track around it has been + * cleared. + * + * @return The ID of the thread that likely owns this lock, or {@link #NO_OWNER} if no one owns it. + */ + public long getLikeyOwnerThreadId() { + return ownerThreadId; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyParcelable.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyParcelable.java new file mode 100644 index 00000000..8965e109 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyParcelable.java @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; + +public class IdentityKeyParcelable implements Parcelable { + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public IdentityKeyParcelable createFromParcel(Parcel in) { + try { + return new IdentityKeyParcelable(in); + } catch (InvalidKeyException e) { + throw new AssertionError(e); + } + } + + public IdentityKeyParcelable[] newArray(int size) { + return new IdentityKeyParcelable[size]; + } + }; + + private final IdentityKey identityKey; + + public IdentityKeyParcelable(@Nullable IdentityKey identityKey) { + this.identityKey = identityKey; + } + + public IdentityKeyParcelable(Parcel in) throws InvalidKeyException { + byte[] serialized = ParcelUtil.readByteArray(in); + + this.identityKey = serialized != null ? new IdentityKey(serialized, 0) : null; + } + + public @Nullable IdentityKey get() { + return identityKey; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + ParcelUtil.writeByteArray(dest, identityKey != null ? identityKey.serialize() : null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java new file mode 100644 index 00000000..08747a14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.backup.BackupProtos; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPrivateKey; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +/** + * Utility class for working with identity keys. + * + * @author Moxie Marlinspike + */ + +public class IdentityKeyUtil { + + @SuppressWarnings("unused") + private static final String TAG = IdentityKeyUtil.class.getSimpleName(); + + private static final String IDENTITY_PUBLIC_KEY_CIPHERTEXT_LEGACY_PREF = "pref_identity_public_curve25519"; + private static final String IDENTITY_PRIVATE_KEY_CIPHERTEXT_LEGACY_PREF = "pref_identity_private_curve25519"; + + private static final String IDENTITY_PUBLIC_KEY_PREF = "pref_identity_public_v3"; + private static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3"; + + public static boolean hasIdentityKey(Context context) { + SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0); + + return + preferences.contains(IDENTITY_PUBLIC_KEY_PREF) && + preferences.contains(IDENTITY_PRIVATE_KEY_PREF); + } + + public static @NonNull IdentityKey getIdentityKey(@NonNull Context context) { + if (!hasIdentityKey(context)) throw new AssertionError("There isn't one!"); + + try { + byte[] publicKeyBytes = Base64.decode(retrieve(context, IDENTITY_PUBLIC_KEY_PREF)); + return new IdentityKey(publicKeyBytes, 0); + } catch (IOException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + public static @NonNull IdentityKeyPair getIdentityKeyPair(@NonNull Context context) { + if (!hasIdentityKey(context)) throw new AssertionError("There isn't one!"); + + try { + IdentityKey publicKey = getIdentityKey(context); + ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(retrieve(context, IDENTITY_PRIVATE_KEY_PREF))); + + return new IdentityKeyPair(publicKey, privateKey); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static void generateIdentityKeys(Context context) { + ECKeyPair djbKeyPair = Curve.generateKeyPair(); + IdentityKey djbIdentityKey = new IdentityKey(djbKeyPair.getPublicKey()); + ECPrivateKey djbPrivateKey = djbKeyPair.getPrivateKey(); + + save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(djbIdentityKey.serialize())); + save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(djbPrivateKey.serialize())); + } + + public static void migrateIdentityKeys(@NonNull Context context, + @NonNull MasterSecret masterSecret) + { + if (!hasIdentityKey(context)) { + if (hasLegacyIdentityKeys(context)) { + IdentityKeyPair legacyPair = getLegacyIdentityKeyPair(context, masterSecret); + + save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(legacyPair.getPublicKey().serialize())); + save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(legacyPair.getPrivateKey().serialize())); + + delete(context, IDENTITY_PUBLIC_KEY_CIPHERTEXT_LEGACY_PREF); + delete(context, IDENTITY_PRIVATE_KEY_CIPHERTEXT_LEGACY_PREF); + } else { + generateIdentityKeys(context); + } + } + } + + public static List getBackupRecord(@NonNull Context context) { + SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0); + + return new LinkedList() {{ + add(BackupProtos.SharedPreference.newBuilder() + .setFile(MasterSecretUtil.PREFERENCES_NAME) + .setKey(IDENTITY_PUBLIC_KEY_PREF) + .setValue(preferences.getString(IDENTITY_PUBLIC_KEY_PREF, null)) + .build()); + add(BackupProtos.SharedPreference.newBuilder() + .setFile(MasterSecretUtil.PREFERENCES_NAME) + .setKey(IDENTITY_PRIVATE_KEY_PREF) + .setValue(preferences.getString(IDENTITY_PRIVATE_KEY_PREF, null)) + .build()); + }}; + } + + private static boolean hasLegacyIdentityKeys(Context context) { + return + retrieve(context, IDENTITY_PUBLIC_KEY_CIPHERTEXT_LEGACY_PREF) != null && + retrieve(context, IDENTITY_PRIVATE_KEY_CIPHERTEXT_LEGACY_PREF) != null; + } + + private static IdentityKeyPair getLegacyIdentityKeyPair(@NonNull Context context, + @NonNull MasterSecret masterSecret) + { + try { + MasterCipher masterCipher = new MasterCipher(masterSecret); + byte[] publicKeyBytes = Base64.decode(retrieve(context, IDENTITY_PUBLIC_KEY_CIPHERTEXT_LEGACY_PREF)); + IdentityKey identityKey = new IdentityKey(publicKeyBytes, 0); + ECPrivateKey privateKey = masterCipher.decryptKey(Base64.decode(retrieve(context, IDENTITY_PRIVATE_KEY_CIPHERTEXT_LEGACY_PREF))); + + return new IdentityKeyPair(identityKey, privateKey); + } catch (IOException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + private static String retrieve(Context context, String key) { + SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0); + return preferences.getString(key, null); + } + + private static void save(Context context, String key, String value) { + SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0); + Editor preferencesEditor = preferences.edit(); + + preferencesEditor.putString(key, value); + if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences"); + } + + private static void delete(Context context, String key) { + context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0).edit().remove(key).commit(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/InvalidPassphraseException.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/InvalidPassphraseException.java new file mode 100644 index 00000000..f73c984d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/InvalidPassphraseException.java @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +public class InvalidPassphraseException extends Exception { + + public InvalidPassphraseException() { + super(); + // TODO Auto-generated constructor stub + } + + public InvalidPassphraseException(String detailMessage) { + super(detailMessage); + // TODO Auto-generated constructor stub + } + + public InvalidPassphraseException(Throwable throwable) { + super(throwable); + // TODO Auto-generated constructor stub + } + + public InvalidPassphraseException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + // TODO Auto-generated constructor stub + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java new file mode 100644 index 00000000..fbd35ccf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java @@ -0,0 +1,208 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableEntryException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +public final class KeyStoreHelper { + + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "SignalSecret"; + + @RequiresApi(Build.VERSION_CODES.M) + public static SealedData seal(@NonNull byte[] input) { + SecretKey secretKey = getOrCreateKeyStoreEntry(); + + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + + byte[] iv = cipher.getIV(); + byte[] data = cipher.doFinal(input); + + return new SealedData(iv, data); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + public static byte[] unseal(@NonNull SealedData sealedData) { + SecretKey secretKey = getKeyStoreEntry(); + + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); + + return cipher.doFinal(sealedData.data); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey getOrCreateKeyStoreEntry() { + if (hasKeyStoreEntry()) return getKeyStoreEntry(); + else return createKeyStoreEntry(); + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey createKeyStoreEntry() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); + KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(); + + keyGenerator.init(keyGenParameterSpec); + + return keyGenerator.generateKey(); + } catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static SecretKey getKeyStoreEntry() { + KeyStore keyStore = getKeyStore(); + + try { + // Attempt 1 + return getSecretKey(keyStore); + } catch (UnrecoverableKeyException e) { + try { + // Attempt 2 + return getSecretKey(keyStore); + } catch (UnrecoverableKeyException e2) { + throw new AssertionError(e2); + } + } + } + + private static SecretKey getSecretKey(KeyStore keyStore) throws UnrecoverableKeyException { + try { + KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry) keyStore.getEntry(KEY_ALIAS, null); + return entry.getSecretKey(); + } catch (UnrecoverableKeyException e) { + throw e; + } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableEntryException e) { + throw new AssertionError(e); + } + } + + private static KeyStore getKeyStore() { + try { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEY_STORE); + keyStore.load(null); + return keyStore; + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static boolean hasKeyStoreEntry() { + try { + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + + return ks.containsAlias(KEY_ALIAS) && ks.entryInstanceOf(KEY_ALIAS, KeyStore.SecretKeyEntry.class); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + throw new AssertionError(e); + } + } + + public static class SealedData { + + @SuppressWarnings("unused") + private static final String TAG = SealedData.class.getSimpleName(); + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] iv; + + @JsonProperty + @JsonSerialize(using = ByteArraySerializer.class) + @JsonDeserialize(using = ByteArrayDeserializer.class) + private byte[] data; + + SealedData(@NonNull byte[] iv, @NonNull byte[] data) { + this.iv = iv; + this.data = data; + } + + @SuppressWarnings("unused") + public SealedData() {} + + public String serialize() { + try { + return JsonUtils.toJson(this); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static SealedData fromString(@NonNull String value) { + try { + return JsonUtils.fromJson(value, SealedData.class); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING)); + } + } + + private static class ByteArrayDeserializer extends JsonDeserializer { + + @Override + public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING); + } + } + + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java new file mode 100644 index 00000000..f325aa4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java @@ -0,0 +1,225 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPrivateKey; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Class that handles encryption for local storage. + * + * The protocol format is roughly: + * + * 1) 16 byte random IV. + * 2) AES-CBC(plaintext) + * 3) HMAC-SHA1 of 1 and 2 + * + * @author Moxie Marlinspike + */ + +public class MasterCipher { + + private static final String TAG = MasterCipher.class.getSimpleName(); + + private final MasterSecret masterSecret; + private final Cipher encryptingCipher; + private final Cipher decryptingCipher; + private final Mac hmac; + + public MasterCipher(MasterSecret masterSecret) { + try { + this.masterSecret = masterSecret; + this.encryptingCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.decryptingCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.hmac = Mac.getInstance("HmacSHA1"); + } catch (NoSuchPaddingException | NoSuchAlgorithmException nspe) { + throw new AssertionError(nspe); + } + } + + public byte[] encryptKey(ECPrivateKey privateKey) { + return encryptBytes(privateKey.serialize()); + } + + public String encryptBody(@NonNull String body) { + return encryptAndEncodeBytes(body.getBytes()); + } + + public String decryptBody(String body) throws InvalidMessageException { + return new String(decodeAndDecryptBytes(body)); + } + + public ECPrivateKey decryptKey(byte[] key) + throws org.whispersystems.libsignal.InvalidKeyException + { + try { + return Curve.decodePrivatePoint(decryptBytes(key)); + } catch (InvalidMessageException ime) { + throw new org.whispersystems.libsignal.InvalidKeyException(ime); + } + } + + public byte[] decryptBytes(@NonNull byte[] decodedBody) throws InvalidMessageException { + try { + Mac mac = getMac(masterSecret.getMacKey()); + byte[] encryptedBody = verifyMacBody(mac, decodedBody); + + Cipher cipher = getDecryptingCipher(masterSecret.getEncryptionKey(), encryptedBody); + byte[] encrypted = getDecryptedBody(cipher, encryptedBody); + + return encrypted; + } catch (GeneralSecurityException ge) { + throw new InvalidMessageException(ge); + } + } + + public byte[] encryptBytes(byte[] body) { + try { + Cipher cipher = getEncryptingCipher(masterSecret.getEncryptionKey()); + Mac mac = getMac(masterSecret.getMacKey()); + + byte[] encryptedBody = getEncryptedBody(cipher, body); + byte[] encryptedAndMacBody = getMacBody(mac, encryptedBody); + + return encryptedAndMacBody; + } catch (GeneralSecurityException ge) { + Log.w(TAG, "bodycipher", ge); + return null; + } + + } + + public boolean verifyMacFor(String content, byte[] theirMac) { + byte[] ourMac = getMacFor(content); + Log.i(TAG, "Our Mac: " + Hex.toString(ourMac)); + Log.i(TAG, "Thr Mac: " + Hex.toString(theirMac)); + return Arrays.equals(ourMac, theirMac); + } + + public byte[] getMacFor(String content) { + Log.w(TAG, "Macing: " + content); + try { + Mac mac = getMac(masterSecret.getMacKey()); + return mac.doFinal(content.getBytes()); + } catch (GeneralSecurityException ike) { + throw new AssertionError(ike); + } + } + + private byte[] decodeAndDecryptBytes(String body) throws InvalidMessageException { + try { + byte[] decodedBody = Base64.decode(body); + return decryptBytes(decodedBody); + } catch (IOException e) { + throw new InvalidMessageException("Bad Base64 Encoding...", e); + } + } + + private String encryptAndEncodeBytes(@NonNull byte[] bytes) { + byte[] encryptedAndMacBody = encryptBytes(bytes); + return Base64.encodeBytes(encryptedAndMacBody); + } + + private byte[] verifyMacBody(@NonNull Mac hmac, @NonNull byte[] encryptedAndMac) throws InvalidMessageException { + if (encryptedAndMac.length < hmac.getMacLength()) { + throw new InvalidMessageException("length(encrypted body + MAC) < length(MAC)"); + } + + byte[] encrypted = new byte[encryptedAndMac.length - hmac.getMacLength()]; + System.arraycopy(encryptedAndMac, 0, encrypted, 0, encrypted.length); + + byte[] remoteMac = new byte[hmac.getMacLength()]; + System.arraycopy(encryptedAndMac, encryptedAndMac.length - remoteMac.length, remoteMac, 0, remoteMac.length); + + byte[] localMac = hmac.doFinal(encrypted); + + if (!Arrays.equals(remoteMac, localMac)) + throw new InvalidMessageException("MAC doesen't match."); + + return encrypted; + } + + private byte[] getDecryptedBody(Cipher cipher, byte[] encryptedBody) throws IllegalBlockSizeException, BadPaddingException { + return cipher.doFinal(encryptedBody, cipher.getBlockSize(), encryptedBody.length - cipher.getBlockSize()); + } + + private byte[] getEncryptedBody(Cipher cipher, byte[] body) throws IllegalBlockSizeException, BadPaddingException { + byte[] encrypted = cipher.doFinal(body); + byte[] iv = cipher.getIV(); + + byte[] ivAndBody = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, ivAndBody, 0, iv.length); + System.arraycopy(encrypted, 0, ivAndBody, iv.length, encrypted.length); + + return ivAndBody; + } + + private Mac getMac(SecretKeySpec key) throws NoSuchAlgorithmException, InvalidKeyException { + // Mac hmac = Mac.getInstance("HmacSHA1"); + hmac.init(key); + + return hmac; + } + + private byte[] getMacBody(Mac hmac, byte[] encryptedBody) { + byte[] mac = hmac.doFinal(encryptedBody); + byte[] encryptedAndMac = new byte[encryptedBody.length + mac.length]; + + System.arraycopy(encryptedBody, 0, encryptedAndMac, 0, encryptedBody.length); + System.arraycopy(mac, 0, encryptedAndMac, encryptedBody.length, mac.length); + + return encryptedAndMac; + } + + private Cipher getDecryptingCipher(SecretKeySpec key, byte[] encryptedBody) throws InvalidKeyException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchPaddingException { + // Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv = new IvParameterSpec(encryptedBody, 0, decryptingCipher.getBlockSize()); + decryptingCipher.init(Cipher.DECRYPT_MODE, key, iv); + + return decryptingCipher; + } + + private Cipher getEncryptingCipher(SecretKeySpec key) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException { + // Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + encryptingCipher.init(Cipher.ENCRYPT_MODE, key); + + return encryptingCipher; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java new file mode 100644 index 00000000..4fc9c01e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java @@ -0,0 +1,120 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Arrays; + +import javax.crypto.spec.SecretKeySpec; + +/** + * When a user first initializes TextSecure, a few secrets + * are generated. These are: + * + * 1) A 128bit symmetric encryption key. + * 2) A 160bit symmetric MAC key. + * 3) An ECC keypair. + * + * The first two, along with the ECC keypair's private key, are + * then encrypted on disk using PBE. + * + * This class represents 1 and 2. + * + * @author Moxie Marlinspike + */ + +public class MasterSecret implements Parcelable { + + private final SecretKeySpec encryptionKey; + private final SecretKeySpec macKey; + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public MasterSecret createFromParcel(Parcel in) { + return new MasterSecret(in); + } + + @Override + public MasterSecret[] newArray(int size) { + return new MasterSecret[size]; + } + }; + + public MasterSecret(SecretKeySpec encryptionKey, SecretKeySpec macKey) { + this.encryptionKey = encryptionKey; + this.macKey = macKey; + } + + private MasterSecret(Parcel in) { + byte[] encryptionKeyBytes = new byte[in.readInt()]; + in.readByteArray(encryptionKeyBytes); + + byte[] macKeyBytes = new byte[in.readInt()]; + in.readByteArray(macKeyBytes); + + this.encryptionKey = new SecretKeySpec(encryptionKeyBytes, "AES"); + this.macKey = new SecretKeySpec(macKeyBytes, "HmacSHA1"); + + // SecretKeySpec does an internal copy in its constructor. + Arrays.fill(encryptionKeyBytes, (byte) 0x00); + Arrays.fill(macKeyBytes, (byte)0x00); + } + + + public SecretKeySpec getEncryptionKey() { + return this.encryptionKey; + } + + public SecretKeySpec getMacKey() { + return this.macKey; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(encryptionKey.getEncoded().length); + out.writeByteArray(encryptionKey.getEncoded()); + out.writeInt(macKey.getEncoded().length); + out.writeByteArray(macKey.getEncoded()); + } + + @Override + public int describeContents() { + return 0; + } + + public MasterSecret parcelClone() { + Parcel thisParcel = Parcel.obtain(); + Parcel thatParcel = Parcel.obtain(); + byte[] bytes = null; + + thisParcel.writeValue(this); + bytes = thisParcel.marshall(); + + thatParcel.unmarshall(bytes, 0, bytes.length); + thatParcel.setDataPosition(0); + + MasterSecret that = (MasterSecret)thatParcel.readValue(MasterSecret.class.getClassLoader()); + + thisParcel.recycle(); + thatParcel.recycle(); + + return that; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java new file mode 100644 index 00000000..94581065 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java @@ -0,0 +1,373 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.PBEParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Helper class for generating and securely storing a MasterSecret. + * + * @author Moxie Marlinspike + */ + +public class MasterSecretUtil { + + private static final String TAG = Log.tag(MasterSecretUtil.class); + + public static final String UNENCRYPTED_PASSPHRASE = "unencrypted"; + public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; + + private static final String ASYMMETRIC_LOCAL_PUBLIC_DJB = "asymmetric_master_secret_curve25519_public"; + private static final String ASYMMETRIC_LOCAL_PRIVATE_DJB = "asymmetric_master_secret_curve25519_private"; + + public static MasterSecret changeMasterSecretPassphrase(Context context, + MasterSecret masterSecret, + String newPassphrase) + { + try { + byte[] combinedSecrets = Util.combine(masterSecret.getEncryptionKey().getEncoded(), + masterSecret.getMacKey().getEncoded()); + + byte[] encryptionSalt = generateSalt(); + int iterations = generateIterationCount(newPassphrase, encryptionSalt); + byte[] encryptedMasterSecret = encryptWithPassphrase(encryptionSalt, iterations, combinedSecrets, newPassphrase); + byte[] macSalt = generateSalt(); + byte[] encryptedAndMacdMasterSecret = macWithPassphrase(macSalt, iterations, encryptedMasterSecret, newPassphrase); + + save(context, "encryption_salt", encryptionSalt); + save(context, "mac_salt", macSalt); + save(context, "passphrase_iterations", iterations); + save(context, "master_secret", encryptedAndMacdMasterSecret); + save(context, "passphrase_initialized", true); + + return masterSecret; + } catch (GeneralSecurityException gse) { + throw new AssertionError(gse); + } + } + + public static MasterSecret changeMasterSecretPassphrase(Context context, + String originalPassphrase, + String newPassphrase) + throws InvalidPassphraseException + { + MasterSecret masterSecret = getMasterSecret(context, originalPassphrase); + changeMasterSecretPassphrase(context, masterSecret, newPassphrase); + + return masterSecret; + } + + public static MasterSecret getMasterSecret(Context context, String passphrase) + throws InvalidPassphraseException + { + try { + byte[] encryptedAndMacdMasterSecret = retrieve(context, "master_secret"); + byte[] macSalt = retrieve(context, "mac_salt"); + int iterations = retrieve(context, "passphrase_iterations", 100); + byte[] encryptedMasterSecret = verifyMac(macSalt, iterations, encryptedAndMacdMasterSecret, passphrase); + byte[] encryptionSalt = retrieve(context, "encryption_salt"); + byte[] combinedSecrets = decryptWithPassphrase(encryptionSalt, iterations, encryptedMasterSecret, passphrase); + byte[] encryptionSecret = Util.split(combinedSecrets, 16, 20)[0]; + byte[] macSecret = Util.split(combinedSecrets, 16, 20)[1]; + + return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"), + new SecretKeySpec(macSecret, "HmacSHA1")); + } catch (GeneralSecurityException e) { + Log.w(TAG, e); + return null; //XXX + } catch (IOException e) { + Log.w(TAG, e); + return null; //XXX + } + } + + public static AsymmetricMasterSecret getAsymmetricMasterSecret(@NonNull Context context, + @Nullable MasterSecret masterSecret) + { + try { + byte[] djbPublicBytes = retrieve(context, ASYMMETRIC_LOCAL_PUBLIC_DJB); + byte[] djbPrivateBytes = retrieve(context, ASYMMETRIC_LOCAL_PRIVATE_DJB); + + ECPublicKey djbPublicKey = null; + ECPrivateKey djbPrivateKey = null; + + if (djbPublicBytes != null) { + djbPublicKey = Curve.decodePoint(djbPublicBytes, 0); + } + + if (masterSecret != null) { + MasterCipher masterCipher = new MasterCipher(masterSecret); + + if (djbPrivateBytes != null) { + djbPrivateKey = masterCipher.decryptKey(djbPrivateBytes); + } + } + + return new AsymmetricMasterSecret(djbPublicKey, djbPrivateKey); + } catch (InvalidKeyException | IOException ike) { + throw new AssertionError(ike); + } + } + + public static AsymmetricMasterSecret generateAsymmetricMasterSecret(Context context, + MasterSecret masterSecret) + { + MasterCipher masterCipher = new MasterCipher(masterSecret); + ECKeyPair keyPair = Curve.generateKeyPair(); + + save(context, ASYMMETRIC_LOCAL_PUBLIC_DJB, keyPair.getPublicKey().serialize()); + save(context, ASYMMETRIC_LOCAL_PRIVATE_DJB, masterCipher.encryptKey(keyPair.getPrivateKey())); + + return new AsymmetricMasterSecret(keyPair.getPublicKey(), keyPair.getPrivateKey()); + } + + public static MasterSecret generateMasterSecret(Context context, String passphrase) { + try { + byte[] encryptionSecret = generateEncryptionSecret(); + byte[] macSecret = generateMacSecret(); + byte[] masterSecret = Util.combine(encryptionSecret, macSecret); + byte[] encryptionSalt = generateSalt(); + int iterations = generateIterationCount(passphrase, encryptionSalt); + byte[] encryptedMasterSecret = encryptWithPassphrase(encryptionSalt, iterations, masterSecret, passphrase); + byte[] macSalt = generateSalt(); + byte[] encryptedAndMacdMasterSecret = macWithPassphrase(macSalt, iterations, encryptedMasterSecret, passphrase); + + save(context, "encryption_salt", encryptionSalt); + save(context, "mac_salt", macSalt); + save(context, "passphrase_iterations", iterations); + save(context, "master_secret", encryptedAndMacdMasterSecret); + save(context, "passphrase_initialized", true); + + return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"), + new SecretKeySpec(macSecret, "HmacSHA1")); + } catch (GeneralSecurityException e) { + Log.w(TAG, e); + return null; + } + } + + public static boolean hasAsymmericMasterSecret(Context context) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + return settings.contains(ASYMMETRIC_LOCAL_PUBLIC_DJB); + } + + public static boolean isPassphraseInitialized(Context context) { + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES_NAME, 0); + return preferences.getBoolean("passphrase_initialized", false); + } + + private static void save(Context context, String key, int value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putInt(key, value) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static void save(Context context, String key, byte[] value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putString(key, Base64.encodeBytes(value)) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static void save(Context context, String key, boolean value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putBoolean(key, value) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static byte[] retrieve(Context context, String key) throws IOException { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + String encodedValue = settings.getString(key, ""); + + if (TextUtils.isEmpty(encodedValue)) return null; + else return Base64.decode(encodedValue); + } + + private static int retrieve(Context context, String key, int defaultValue) throws IOException { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + return settings.getInt(key, defaultValue); + } + + private static byte[] generateEncryptionSecret() { + try { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(128); + + SecretKey key = generator.generateKey(); + return key.getEncoded(); + } catch (NoSuchAlgorithmException ex) { + Log.w(TAG, ex); + return null; + } + } + + private static byte[] generateMacSecret() { + try { + KeyGenerator generator = KeyGenerator.getInstance("HmacSHA1"); + return generator.generateKey().getEncoded(); + } catch (NoSuchAlgorithmException e) { + Log.w(TAG, e); + return null; + } + } + + private static byte[] generateSalt() { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[16]; + random.nextBytes(salt); + + return salt; + } + + private static int generateIterationCount(String passphrase, byte[] salt) { + int TARGET_ITERATION_TIME = 50; //ms + int MINIMUM_ITERATION_COUNT = 100; //default for low-end devices + int BENCHMARK_ITERATION_COUNT = 10000; //baseline starting iteration count + + try { + PBEKeySpec keyspec = new PBEKeySpec(passphrase.toCharArray(), salt, BENCHMARK_ITERATION_COUNT); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBEWITHSHA1AND128BITAES-CBC-BC"); + + long startTime = System.currentTimeMillis(); + skf.generateSecret(keyspec); + long finishTime = System.currentTimeMillis(); + + int scaledIterationTarget = (int) (((double)BENCHMARK_ITERATION_COUNT / (double)(finishTime - startTime)) * TARGET_ITERATION_TIME); + + if (scaledIterationTarget < MINIMUM_ITERATION_COUNT) return MINIMUM_ITERATION_COUNT; + else return scaledIterationTarget; + } catch (NoSuchAlgorithmException e) { + Log.w(TAG, e); + return MINIMUM_ITERATION_COUNT; + } catch (InvalidKeySpecException e) { + Log.w(TAG, e); + return MINIMUM_ITERATION_COUNT; + } + } + + private static SecretKey getKeyFromPassphrase(String passphrase, byte[] salt, int iterations) + throws GeneralSecurityException + { + PBEKeySpec keyspec = new PBEKeySpec(passphrase.toCharArray(), salt, iterations); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBEWITHSHA1AND128BITAES-CBC-BC"); + return skf.generateSecret(keyspec); + } + + private static Cipher getCipherFromPassphrase(String passphrase, byte[] salt, int iterations, int opMode) + throws GeneralSecurityException + { + SecretKey key = getKeyFromPassphrase(passphrase, salt, iterations); + Cipher cipher = Cipher.getInstance(key.getAlgorithm()); + cipher.init(opMode, key, new PBEParameterSpec(salt, iterations)); + + return cipher; + } + + private static byte[] encryptWithPassphrase(byte[] encryptionSalt, int iterations, byte[] data, String passphrase) + throws GeneralSecurityException + { + Cipher cipher = getCipherFromPassphrase(passphrase, encryptionSalt, iterations, Cipher.ENCRYPT_MODE); + return cipher.doFinal(data); + } + + private static byte[] decryptWithPassphrase(byte[] encryptionSalt, int iterations, byte[] data, String passphrase) + throws GeneralSecurityException, IOException + { + Cipher cipher = getCipherFromPassphrase(passphrase, encryptionSalt, iterations, Cipher.DECRYPT_MODE); + return cipher.doFinal(data); + } + + private static Mac getMacForPassphrase(String passphrase, byte[] salt, int iterations) + throws GeneralSecurityException + { + SecretKey key = getKeyFromPassphrase(passphrase, salt, iterations); + byte[] pbkdf2 = key.getEncoded(); + SecretKeySpec hmacKey = new SecretKeySpec(pbkdf2, "HmacSHA1"); + Mac hmac = Mac.getInstance("HmacSHA1"); + hmac.init(hmacKey); + + return hmac; + } + + private static byte[] verifyMac(byte[] macSalt, int iterations, byte[] encryptedAndMacdData, String passphrase) throws InvalidPassphraseException, GeneralSecurityException, IOException { + Mac hmac = getMacForPassphrase(passphrase, macSalt, iterations); + + byte[] encryptedData = new byte[encryptedAndMacdData.length - hmac.getMacLength()]; + System.arraycopy(encryptedAndMacdData, 0, encryptedData, 0, encryptedData.length); + + byte[] givenMac = new byte[hmac.getMacLength()]; + System.arraycopy(encryptedAndMacdData, encryptedAndMacdData.length-hmac.getMacLength(), givenMac, 0, givenMac.length); + + byte[] localMac = hmac.doFinal(encryptedData); + + if (Arrays.equals(givenMac, localMac)) return encryptedData; + else throw new InvalidPassphraseException("MAC Error"); + } + + private static byte[] macWithPassphrase(byte[] macSalt, int iterations, byte[] data, String passphrase) throws GeneralSecurityException { + Mac hmac = getMacForPassphrase(passphrase, macSalt, iterations); + byte[] mac = hmac.doFinal(data); + byte[] result = new byte[data.length + mac.length]; + + System.arraycopy(data, 0, result, 0, data.length); + System.arraycopy(mac, 0, result, data.length, mac.length); + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernDecryptingPartInputStream.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernDecryptingPartInputStream.java new file mode 100644 index 00000000..268d7a5f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernDecryptingPartInputStream.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.crypto; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.Conversions; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ModernDecryptingPartInputStream { + + public static InputStream createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull byte[] random, @NonNull File file, long offset) + throws IOException + { + return createFor(attachmentSecret, random, new FileInputStream(file), offset); + } + + public static InputStream createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull File file, long offset) + throws IOException + { + FileInputStream inputStream = new FileInputStream(file); + byte[] random = new byte[32]; + + readFully(inputStream, random); + + return createFor(attachmentSecret, random, inputStream, offset); + } + + private static InputStream createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull byte[] random, @NonNull InputStream inputStream, long offset) throws IOException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(attachmentSecret.getModernKey(), "HmacSHA256")); + + byte[] iv = new byte[16]; + int remainder = (int) (offset % 16); + Conversions.longTo4ByteArray(iv, 12, offset / 16); + + byte[] key = mac.doFinal(random); + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); + + long skipped = inputStream.skip(offset - remainder); + + if (skipped != offset - remainder) { + throw new IOException("Skip failed: " + skipped + " vs " + (offset - remainder)); + } + + CipherInputStream cipherInputStream = new CipherInputStream(inputStream, cipher); + byte[] remainderBuffer = new byte[remainder]; + + readFully(cipherInputStream, remainderBuffer); + + return cipherInputStream; + } catch (NoSuchAlgorithmException | InvalidKeyException | InvalidAlgorithmParameterException | NoSuchPaddingException e) { + throw new AssertionError(e); + } + } + + private static void readFully(InputStream in, byte[] buffer) throws IOException { + int offset = 0; + + for (;;) { + int read = in.read(buffer, offset, buffer.length-offset); + + if (read == -1) throw new IOException("Prematurely reached end of stream!"); + else if (read + offset < buffer.length) offset += read; + else return; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java new file mode 100644 index 00000000..73f5bb87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.util.Pair; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Constructs an OutputStream that encrypts data written to it with the AttachmentSecret provided. + * + * The on-disk format is very simple, and intentionally no longer includes authentication. + */ +public class ModernEncryptingPartOutputStream { + + public static Pair createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull File file, boolean inline) + throws IOException + { + byte[] random = new byte[32]; + new SecureRandom().nextBytes(random); + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(attachmentSecret.getModernKey(), "HmacSHA256")); + + FileOutputStream fileOutputStream = new FileOutputStream(file); + byte[] iv = new byte[16]; + byte[] key = mac.doFinal(random); + + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); + + if (inline) { + fileOutputStream.write(random); + } + + return new Pair<>(random, new CipherOutputStream(fileOutputStream, cipher)); + } catch (NoSuchAlgorithmException | InvalidKeyException | InvalidAlgorithmParameterException | NoSuchPaddingException e) { + throw new AssertionError(e); + } + } + + public static long getPlaintextLength(long cipherTextLength) { + return cipherTextLength - 32; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/PreKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/PreKeyUtil.java new file mode 100644 index 00000000..4644701b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/PreKeyUtil.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2013-2018 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; + +import org.thoughtcrime.securesms.crypto.storage.TextSecurePreKeyStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.PreKeyStore; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyStore; +import org.whispersystems.libsignal.util.Medium; + +import java.util.LinkedList; +import java.util.List; + +public class PreKeyUtil { + + @SuppressWarnings("unused") + private static final String TAG = PreKeyUtil.class.getSimpleName(); + + private static final int BATCH_SIZE = 100; + + public synchronized static List generatePreKeys(Context context) { + PreKeyStore preKeyStore = new TextSecurePreKeyStore(context); + List records = new LinkedList<>(); + int preKeyIdOffset = TextSecurePreferences.getNextPreKeyId(context); + + for (int i=0;i profileKeyOptional(@Nullable byte[] profileKey) { + return Optional.fromNullable(profileKeyOrNull(profileKey)); + } + + public static @NonNull Optional profileKeyOptionalOrThrow(@NonNull byte[] profileKey) { + return Optional.of(profileKeyOrThrow(profileKey)); + } + + public static @NonNull ProfileKey createNew() { + try { + return new ProfileKey(Util.getSecretBytes(32)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/PublicKey.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/PublicKey.java new file mode 100644 index 00000000..b52573b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/PublicKey.java @@ -0,0 +1,104 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import org.signal.core.util.Conversions; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPublicKey; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class PublicKey { + + private static final String TAG = PublicKey.class.getSimpleName(); + + public static final int KEY_SIZE = 3 + ECPublicKey.KEY_SIZE; + + private final ECPublicKey publicKey; + private int id; + + public PublicKey(PublicKey publicKey) { + this.id = publicKey.id; + + // FIXME :: This not strictly an accurate copy constructor. + this.publicKey = publicKey.publicKey; + } + + public PublicKey(int id, ECPublicKey publicKey) { + this.publicKey = publicKey; + this.id = id; + } + + public PublicKey(byte[] bytes, int offset) throws InvalidKeyException { + Log.i(TAG, "PublicKey Length: " + (bytes.length - offset)); + + if ((bytes.length - offset) < KEY_SIZE) + throw new InvalidKeyException("Provided bytes are too short."); + + this.id = Conversions.byteArrayToMedium(bytes, offset); + this.publicKey = Curve.decodePoint(bytes, offset + 3); + } + + public PublicKey(byte[] bytes) throws InvalidKeyException { + this(bytes, 0); + } + + public int getType() { + return publicKey.getType(); + } + + public void setId(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public ECPublicKey getKey() { + return publicKey; + } + + public String getFingerprint() { + return Hex.toString(getFingerprintBytes()); + } + + public byte[] getFingerprintBytes() { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + return md.digest(serialize()); + } catch (NoSuchAlgorithmException nsae) { + Log.w(TAG, "LocalKeyPair", nsae); + throw new IllegalArgumentException("SHA-1 isn't supported!"); + } + } + + public byte[] serialize() { + byte[] keyIdBytes = Conversions.mediumToByteArray(id); + byte[] serializedPoint = publicKey.serialize(); + + Log.i(TAG, "Serializing public key point: " + Hex.toString(serializedPoint)); + + return Util.combine(keyIdBytes, serializedPoint); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/SecurityEvent.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/SecurityEvent.java new file mode 100644 index 00000000..477703e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/SecurityEvent.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.service.KeyCachingService; + +/** + * This class processes key exchange interactions. + * + * @author Moxie Marlinspike + */ + +public class SecurityEvent { + + public static final String SECURITY_UPDATE_EVENT = "org.thoughtcrime.securesms.KEY_EXCHANGE_UPDATE"; + + public static void broadcastSecurityUpdateEvent(Context context) { + Intent intent = new Intent(SECURITY_UPDATE_EVENT); + intent.setPackage(context.getPackageName()); + context.sendBroadcast(intent, KeyCachingService.KEY_PERMISSION); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java new file mode 100644 index 00000000..f8d125c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.state.SessionStore; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class SessionUtil { + + public static boolean hasSession(@NonNull Context context, @NonNull RecipientId id) { + SessionStore sessionStore = new TextSecureSessionStore(context); + SignalProtocolAddress axolotlAddress = new SignalProtocolAddress(Recipient.resolved(id).requireServiceId(), SignalServiceAddress.DEFAULT_DEVICE_ID); + + return sessionStore.containsSession(axolotlAddress); + } + + public static void archiveSiblingSessions(Context context, SignalProtocolAddress address) { + TextSecureSessionStore sessionStore = new TextSecureSessionStore(context); + sessionStore.archiveSiblingSessions(address); + } + + public static void archiveAllSessions(Context context) { + new TextSecureSessionStore(context).archiveAllSessions(); + } + + public static void archiveSession(Context context, RecipientId recipientId, int deviceId) { + new TextSecureSessionStore(context).archiveSession(recipientId, deviceId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/UnidentifiedAccessUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/UnidentifiedAccessUtil.java new file mode 100644 index 00000000..959ad7f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/UnidentifiedAccessUtil.java @@ -0,0 +1,169 @@ +package org.thoughtcrime.securesms.crypto; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.libsignal.metadata.certificate.CertificateValidator; +import org.signal.libsignal.metadata.certificate.InvalidCertificateException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.keyvalue.CertificateType; +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class UnidentifiedAccessUtil { + + private static final String TAG = UnidentifiedAccessUtil.class.getSimpleName(); + + public static CertificateValidator getCertificateValidator() { + try { + ECPublicKey unidentifiedSenderTrustRoot = Curve.decodePoint(Base64.decode(BuildConfig.UNIDENTIFIED_SENDER_TRUST_ROOT), 0); + return new CertificateValidator(unidentifiedSenderTrustRoot); + } catch (InvalidKeyException | IOException e) { + throw new AssertionError(e); + } + } + + @WorkerThread + public static Optional getAccessFor(@NonNull Context context, @NonNull Recipient recipient) { + return getAccessFor(context, recipient, true); + } + + @WorkerThread + public static Optional getAccessFor(@NonNull Context context, @NonNull Recipient recipient, boolean log) { + return getAccessFor(context, Collections.singletonList(recipient), log).get(0); + } + + @WorkerThread + public static List> getAccessFor(@NonNull Context context, @NonNull List recipients) { + return getAccessFor(context, recipients, true); + } + + @WorkerThread + public static List> getAccessFor(@NonNull Context context, @NonNull List recipients, boolean log) { + byte[] ourUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); + + if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) { + ourUnidentifiedAccessKey = Util.getSecretBytes(16); + } + + List> access = new ArrayList<>(recipients.size()); + + Map typeCounts = new HashMap<>(); + + for (Recipient recipient : recipients) { + byte[] theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient); + CertificateType certificateType = getUnidentifiedAccessCertificateType(recipient); + byte[] ourUnidentifiedAccessCertificate = SignalStore.certificateValues().getUnidentifiedAccessCertificate(certificateType); + + int typeCount = Util.getOrDefault(typeCounts, certificateType, 0); + typeCount++; + typeCounts.put(certificateType, typeCount); + + if (theirUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) { + try { + access.add(Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(theirUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate), + new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate)))); + } catch (InvalidCertificateException e) { + Log.w(TAG, e); + access.add(Optional.absent()); + } + } else { + access.add(Optional.absent()); + } + } + + int unidentifiedCount = Stream.of(access).filter(Optional::isPresent).toList().size(); + int otherCount = access.size() - unidentifiedCount; + + if (log) { + Log.i(TAG, "Unidentified: " + unidentifiedCount + ", Other: " + otherCount + ". Types: " + typeCounts); + } + + return access; + } + + public static Optional getAccessForSync(@NonNull Context context) { + try { + byte[] ourUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); + byte[] ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate(Recipient.self()); + + if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) { + ourUnidentifiedAccessKey = Util.getSecretBytes(16); + } + + if (ourUnidentifiedAccessCertificate != null) { + return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate), + new UnidentifiedAccess(ourUnidentifiedAccessKey, + ourUnidentifiedAccessCertificate))); + } + + return Optional.absent(); + } catch (InvalidCertificateException e) { + Log.w(TAG, e); + return Optional.absent(); + } + } + + private static @NonNull CertificateType getUnidentifiedAccessCertificateType(@NonNull Recipient recipient) { + PhoneNumberPrivacyValues.PhoneNumberSharingMode sendPhoneNumberTo = SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode(); + + switch (sendPhoneNumberTo) { + case EVERYONE: return CertificateType.UUID_AND_E164; + case CONTACTS: return recipient.isSystemContact() ? CertificateType.UUID_AND_E164 : CertificateType.UUID_ONLY; + case NOBODY : return CertificateType.UUID_ONLY; + default : throw new AssertionError(); + } + } + + private static byte[] getUnidentifiedAccessCertificate(@NonNull Recipient recipient) { + return SignalStore.certificateValues() + .getUnidentifiedAccessCertificate(getUnidentifiedAccessCertificateType(recipient)); + } + + private static @Nullable byte[] getTargetUnidentifiedAccessKey(@NonNull Recipient recipient) { + ProfileKey theirProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.resolve().getProfileKey()); + + switch (recipient.resolve().getUnidentifiedAccessMode()) { + case UNKNOWN: + if (theirProfileKey == null) return Util.getSecretBytes(16); + else return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey); + case DISABLED: + return null; + case ENABLED: + if (theirProfileKey == null) return null; + else return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey); + case UNRESTRICTED: + return Util.getSecretBytes(16); + default: + throw new AssertionError("Unknown mode: " + recipient.getUnidentifiedAccessMode().getMode()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalProtocolStoreImpl.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalProtocolStoreImpl.java new file mode 100644 index 00000000..5cf86b80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalProtocolStoreImpl.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.crypto.storage; + +import android.content.Context; + +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyIdException; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.state.IdentityKeyStore; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.PreKeyStore; +import org.whispersystems.libsignal.state.SessionRecord; +import org.whispersystems.libsignal.state.SessionStore; +import org.whispersystems.libsignal.state.SignalProtocolStore; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyStore; +import org.whispersystems.signalservice.api.SignalServiceProtocolStore; +import org.whispersystems.signalservice.api.SignalServiceSessionStore; + +import java.util.List; + +public class SignalProtocolStoreImpl implements SignalServiceProtocolStore { + + private final PreKeyStore preKeyStore; + private final SignedPreKeyStore signedPreKeyStore; + private final IdentityKeyStore identityKeyStore; + private final SignalServiceSessionStore sessionStore; + + public SignalProtocolStoreImpl(Context context) { + this.preKeyStore = new TextSecurePreKeyStore(context); + this.signedPreKeyStore = new TextSecurePreKeyStore(context); + this.identityKeyStore = new TextSecureIdentityKeyStore(context); + this.sessionStore = new TextSecureSessionStore(context); + } + + @Override + public IdentityKeyPair getIdentityKeyPair() { + return identityKeyStore.getIdentityKeyPair(); + } + + @Override + public int getLocalRegistrationId() { + return identityKeyStore.getLocalRegistrationId(); + } + + @Override + public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) { + return identityKeyStore.saveIdentity(address, identityKey); + } + + @Override + public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) { + return identityKeyStore.isTrustedIdentity(address, identityKey, direction); + } + + @Override + public IdentityKey getIdentity(SignalProtocolAddress address) { + return identityKeyStore.getIdentity(address); + } + + @Override + public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException { + return preKeyStore.loadPreKey(preKeyId); + } + + @Override + public void storePreKey(int preKeyId, PreKeyRecord record) { + preKeyStore.storePreKey(preKeyId, record); + } + + @Override + public boolean containsPreKey(int preKeyId) { + return preKeyStore.containsPreKey(preKeyId); + } + + @Override + public void removePreKey(int preKeyId) { + preKeyStore.removePreKey(preKeyId); + } + + @Override + public SessionRecord loadSession(SignalProtocolAddress axolotlAddress) { + return sessionStore.loadSession(axolotlAddress); + } + + @Override + public List getSubDeviceSessions(String number) { + return sessionStore.getSubDeviceSessions(number); + } + + @Override + public void storeSession(SignalProtocolAddress axolotlAddress, SessionRecord record) { + sessionStore.storeSession(axolotlAddress, record); + } + + @Override + public boolean containsSession(SignalProtocolAddress axolotlAddress) { + return sessionStore.containsSession(axolotlAddress); + } + + @Override + public void deleteSession(SignalProtocolAddress axolotlAddress) { + sessionStore.deleteSession(axolotlAddress); + } + + @Override + public void deleteAllSessions(String number) { + sessionStore.deleteAllSessions(number); + } + + @Override + public void archiveSession(SignalProtocolAddress address) { + sessionStore.archiveSession(address); + } + + @Override + public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException { + return signedPreKeyStore.loadSignedPreKey(signedPreKeyId); + } + + @Override + public List loadSignedPreKeys() { + return signedPreKeyStore.loadSignedPreKeys(); + } + + @Override + public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) { + signedPreKeyStore.storeSignedPreKey(signedPreKeyId, record); + } + + @Override + public boolean containsSignedPreKey(int signedPreKeyId) { + return signedPreKeyStore.containsSignedPreKey(signedPreKeyId); + } + + @Override + public void removeSignedPreKey(int signedPreKeyId) { + signedPreKeyStore.removeSignedPreKey(signedPreKeyId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureIdentityKeyStore.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureIdentityKeyStore.java new file mode 100644 index 00000000..dd3a7f62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureIdentityKeyStore.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.crypto.storage; + +import android.content.Context; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.SessionUtil; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.state.IdentityKeyStore; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.concurrent.TimeUnit; + +public class TextSecureIdentityKeyStore implements IdentityKeyStore { + + private static final int TIMESTAMP_THRESHOLD_SECONDS = 5; + + private static final String TAG = TextSecureIdentityKeyStore.class.getSimpleName(); + private static final Object LOCK = new Object(); + + private final Context context; + + public TextSecureIdentityKeyStore(Context context) { + this.context = context; + } + + @Override + public IdentityKeyPair getIdentityKeyPair() { + return IdentityKeyUtil.getIdentityKeyPair(context); + } + + @Override + public int getLocalRegistrationId() { + return TextSecurePreferences.getLocalRegistrationId(context); + } + + public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey, boolean nonBlockingApproval) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + Recipient recipient = Recipient.external(context, address.getName()); + Optional identityRecord = identityDatabase.getIdentity(recipient.getId()); + + if (!identityRecord.isPresent()) { + Log.i(TAG, "Saving new identity..."); + identityDatabase.saveIdentity(recipient.getId(), identityKey, VerifiedStatus.DEFAULT, true, System.currentTimeMillis(), nonBlockingApproval); + return false; + } + + if (!identityRecord.get().getIdentityKey().equals(identityKey)) { + Log.i(TAG, "Replacing existing identity..."); + VerifiedStatus verifiedStatus; + + if (identityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED || + identityRecord.get().getVerifiedStatus() == VerifiedStatus.UNVERIFIED) + { + verifiedStatus = VerifiedStatus.UNVERIFIED; + } else { + verifiedStatus = VerifiedStatus.DEFAULT; + } + + identityDatabase.saveIdentity(recipient.getId(), identityKey, verifiedStatus, false, System.currentTimeMillis(), nonBlockingApproval); + IdentityUtil.markIdentityUpdate(context, recipient.getId()); + SessionUtil.archiveSiblingSessions(context, address); + return true; + } + + if (isNonBlockingApprovalRequired(identityRecord.get())) { + Log.i(TAG, "Setting approval status..."); + identityDatabase.setApproval(recipient.getId(), nonBlockingApproval); + return false; + } + + return false; + } + } + + @Override + public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) { + return saveIdentity(address, identityKey, false); + } + + @Override + public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + RecipientId ourRecipientId = Recipient.self().getId(); + RecipientId theirRecipientId = Recipient.external(context, address.getName()).getId(); + + if (ourRecipientId.equals(theirRecipientId)) { + return identityKey.equals(IdentityKeyUtil.getIdentityKey(context)); + } + + switch (direction) { + case SENDING: return isTrustedForSending(identityKey, identityDatabase.getIdentity(theirRecipientId)); + case RECEIVING: return true; + default: throw new AssertionError("Unknown direction: " + direction); + } + } else { + Log.w(TAG, "Tried to check if identity is trusted for " + address.getName() + ", but no matching recipient existed!"); + switch (direction) { + case SENDING: return false; + case RECEIVING: return true; + default: throw new AssertionError("Unknown direction: " + direction); + } + } + } + } + + @Override + public IdentityKey getIdentity(SignalProtocolAddress address) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + Optional record = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipientId); + + if (record.isPresent()) { + return record.get().getIdentityKey(); + } else { + return null; + } + } else { + Log.w(TAG, "Tried to get identity for " + address.getName() + ", but no matching recipient existed!"); + return null; + } + } + + private boolean isTrustedForSending(IdentityKey identityKey, Optional identityRecord) { + if (!identityRecord.isPresent()) { + Log.w(TAG, "Nothing here, returning true..."); + return true; + } + + if (!identityKey.equals(identityRecord.get().getIdentityKey())) { + Log.w(TAG, "Identity keys don't match..."); + return false; + } + + if (identityRecord.get().getVerifiedStatus() == VerifiedStatus.UNVERIFIED) { + Log.w(TAG, "Needs unverified approval!"); + return false; + } + + if (isNonBlockingApprovalRequired(identityRecord.get())) { + Log.w(TAG, "Needs non-blocking approval!"); + return false; + } + + return true; + } + + private boolean isNonBlockingApprovalRequired(IdentityRecord identityRecord) { + return !identityRecord.isFirstUse() && + System.currentTimeMillis() - identityRecord.getTimestamp() < TimeUnit.SECONDS.toMillis(TIMESTAMP_THRESHOLD_SECONDS) && + !identityRecord.isApprovedNonBlocking(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecurePreKeyStore.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecurePreKeyStore.java new file mode 100644 index 00000000..e0adb131 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecurePreKeyStore.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.crypto.storage; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.whispersystems.libsignal.InvalidKeyIdException; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.PreKeyStore; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyStore; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.List; + +public class TextSecurePreKeyStore implements PreKeyStore, SignedPreKeyStore { + + @SuppressWarnings("unused") + private static final String TAG = TextSecurePreKeyStore.class.getSimpleName(); + + @NonNull + private final Context context; + + public TextSecurePreKeyStore(@NonNull Context context) { + this.context = context; + } + + @Override + public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + PreKeyRecord preKeyRecord = DatabaseFactory.getPreKeyDatabase(context).getPreKey(preKeyId); + + if (preKeyRecord == null) throw new InvalidKeyIdException("No such key: " + preKeyId); + else return preKeyRecord; + } + } + + @Override + public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + SignedPreKeyRecord signedPreKeyRecord = DatabaseFactory.getSignedPreKeyDatabase(context).getSignedPreKey(signedPreKeyId); + + if (signedPreKeyRecord == null) throw new InvalidKeyIdException("No such signed prekey: " + signedPreKeyId); + else return signedPreKeyRecord; + } + } + + @Override + public List loadSignedPreKeys() { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + return DatabaseFactory.getSignedPreKeyDatabase(context).getAllSignedPreKeys(); + } + } + + @Override + public void storePreKey(int preKeyId, PreKeyRecord record) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + DatabaseFactory.getPreKeyDatabase(context).insertPreKey(preKeyId, record); + } + } + + @Override + public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + DatabaseFactory.getSignedPreKeyDatabase(context).insertSignedPreKey(signedPreKeyId, record); + } + } + + @Override + public boolean containsPreKey(int preKeyId) { + return DatabaseFactory.getPreKeyDatabase(context).getPreKey(preKeyId) != null; + } + + @Override + public boolean containsSignedPreKey(int signedPreKeyId) { + return DatabaseFactory.getSignedPreKeyDatabase(context).getSignedPreKey(signedPreKeyId) != null; + } + + @Override + public void removePreKey(int preKeyId) { + DatabaseFactory.getPreKeyDatabase(context).removePreKey(preKeyId); + } + + @Override + public void removeSignedPreKey(int signedPreKeyId) { + DatabaseFactory.getSignedPreKeyDatabase(context).removeSignedPreKey(signedPreKeyId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureSessionStore.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureSessionStore.java new file mode 100644 index 00000000..d56f43eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/TextSecureSessionStore.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.crypto.storage; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.SessionDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.protocol.CiphertextMessage; +import org.whispersystems.libsignal.state.SessionRecord; +import org.whispersystems.signalservice.api.SignalServiceSessionStore; +import org.whispersystems.signalservice.api.SignalSessionLock; + +import java.util.Collections; +import java.util.List; + +public class TextSecureSessionStore implements SignalServiceSessionStore { + + private static final String TAG = TextSecureSessionStore.class.getSimpleName(); + + @NonNull private final Context context; + + public TextSecureSessionStore(@NonNull Context context) { + this.context = context; + } + + @Override + public SessionRecord loadSession(@NonNull SignalProtocolAddress address) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + SessionRecord sessionRecord = DatabaseFactory.getSessionDatabase(context).load(recipientId, address.getDeviceId()); + + if (sessionRecord == null) { + Log.w(TAG, "No existing session information found."); + return new SessionRecord(); + } + + return sessionRecord; + } + } + + @Override + public void storeSession(@NonNull SignalProtocolAddress address, @NonNull SessionRecord record) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + RecipientId id = Recipient.external(context, address.getName()).getId(); + DatabaseFactory.getSessionDatabase(context).store(id, address.getDeviceId(), record); + } + } + + @Override + public boolean containsSession(SignalProtocolAddress address) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + SessionRecord sessionRecord = DatabaseFactory.getSessionDatabase(context).load(recipientId, address.getDeviceId()); + + return sessionRecord != null && + sessionRecord.getSessionState().hasSenderChain() && + sessionRecord.getSessionState().getSessionVersion() == CiphertextMessage.CURRENT_VERSION; + } else { + return false; + } + } + } + + @Override + public void deleteSession(SignalProtocolAddress address) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + DatabaseFactory.getSessionDatabase(context).delete(recipientId, address.getDeviceId()); + } else { + Log.w(TAG, "Tried to delete session for " + address.toString() + ", but none existed!"); + } + } + } + + @Override + public void deleteAllSessions(String name) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(name)) { + RecipientId recipientId = Recipient.external(context, name).getId(); + DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId); + } + } + } + + @Override + public List getSubDeviceSessions(String name) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(name)) { + RecipientId recipientId = Recipient.external(context, name).getId(); + return DatabaseFactory.getSessionDatabase(context).getSubDevices(recipientId); + } else { + Log.w(TAG, "Tried to get sub device sessions for " + name + ", but none existed!"); + return Collections.emptyList(); + } + } + } + + @Override + public void archiveSession(SignalProtocolAddress address) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + archiveSession(recipientId, address.getDeviceId()); + } + } + } + + public void archiveSession(@NonNull RecipientId recipientId, int deviceId) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + SessionRecord session = DatabaseFactory.getSessionDatabase(context).load(recipientId, deviceId); + if (session != null) { + session.archiveCurrentState(); + DatabaseFactory.getSessionDatabase(context).store(recipientId, deviceId, session); + } + } + } + + public void archiveSiblingSessions(@NonNull SignalProtocolAddress address) { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) { + RecipientId recipientId = Recipient.external(context, address.getName()).getId(); + List sessions = DatabaseFactory.getSessionDatabase(context).getAllFor(recipientId); + + for (SessionDatabase.SessionRow row : sessions) { + if (row.getDeviceId() != address.getDeviceId()) { + row.getRecord().archiveCurrentState(); + storeSession(new SignalProtocolAddress(Recipient.resolved(row.getRecipientId()).requireServiceId(), row.getDeviceId()), row.getRecord()); + } + } + } else { + Log.w(TAG, "Tried to archive sibling sessions for " + address.toString() + ", but none existed!"); + } + } + } + + public void archiveAllSessions() { + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + List sessions = DatabaseFactory.getSessionDatabase(context).getAll(); + + for (SessionDatabase.SessionRow row : sessions) { + row.getRecord().archiveCurrentState(); + storeSession(new SignalProtocolAddress(Recipient.resolved(row.getRecipientId()).requireServiceId(), row.getDeviceId()), row.getRecord()); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ApnDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ApnDatabase.java new file mode 100644 index 00000000..48fa192a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ApnDatabase.java @@ -0,0 +1,174 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.content.res.AssetManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.text.TextUtils; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.mms.LegacyMmsConnection.Apn; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * Database to query APN and MMSC information + */ +public class ApnDatabase { + private static final String TAG = ApnDatabase.class.getSimpleName(); + + private final SQLiteDatabase db; + private final Context context; + + private static final String DATABASE_NAME = "apns.db"; + private static final String ASSET_PATH = "databases" + File.separator + DATABASE_NAME; + + private static final String TABLE_NAME = "apns"; + private static final String ID_COLUMN = "_id"; + private static final String MCC_MNC_COLUMN = "mccmnc"; + private static final String MCC_COLUMN = "mcc"; + private static final String MNC_COLUMN = "mnc"; + private static final String CARRIER_COLUMN = "carrier"; + private static final String APN_COLUMN = "apn"; + private static final String MMSC_COLUMN = "mmsc"; + private static final String PORT_COLUMN = "port"; + private static final String TYPE_COLUMN = "type"; + private static final String PROTOCOL_COLUMN = "protocol"; + private static final String BEARER_COLUMN = "bearer"; + private static final String ROAMING_PROTOCOL_COLUMN = "roaming_protocol"; + private static final String CARRIER_ENABLED_COLUMN = "carrier_enabled"; + private static final String MMS_PROXY_COLUMN = "mmsproxy"; + private static final String MMS_PORT_COLUMN = "mmsport"; + private static final String PROXY_COLUMN = "proxy"; + private static final String MVNO_MATCH_DATA_COLUMN = "mvno_match_data"; + private static final String MVNO_TYPE_COLUMN = "mvno"; + private static final String AUTH_TYPE_COLUMN = "authtype"; + private static final String USER_COLUMN = "user"; + private static final String PASSWORD_COLUMN = "password"; + private static final String SERVER_COLUMN = "server"; + + private static final String BASE_SELECTION = MCC_MNC_COLUMN + " = ?"; + + private static ApnDatabase instance = null; + + public synchronized static ApnDatabase getInstance(Context context) throws IOException { + if (instance == null) instance = new ApnDatabase(context.getApplicationContext()); + return instance; + } + + private ApnDatabase(final Context context) throws IOException { + this.context = context; + + File dbFile = context.getDatabasePath(DATABASE_NAME); + + if (!dbFile.getParentFile().exists() && !dbFile.getParentFile().mkdir()) { + throw new IOException("couldn't make databases directory"); + } + + StreamUtil.copy(context.getAssets().open(ASSET_PATH, AssetManager.ACCESS_STREAMING), + new FileOutputStream(dbFile)); + + try { + this.db = SQLiteDatabase.openDatabase(context.getDatabasePath(DATABASE_NAME).getPath(), + null, + SQLiteDatabase.OPEN_READONLY | SQLiteDatabase.NO_LOCALIZED_COLLATORS); + } catch (SQLiteException e) { + throw new IOException(e); + } + } + + private Apn getCustomApnParameters() { + String mmsc = TextSecurePreferences.getMmscUrl(context).trim(); + + if (!TextUtils.isEmpty(mmsc) && !mmsc.startsWith("http")) + mmsc = "http://" + mmsc; + + String proxy = TextSecurePreferences.getMmscProxy(context); + String port = TextSecurePreferences.getMmscProxyPort(context); + String user = TextSecurePreferences.getMmscUsername(context); + String pass = TextSecurePreferences.getMmscPassword(context); + + return new Apn(mmsc, proxy, port, user, pass); + } + + public Apn getDefaultApnParameters(String mccmnc, String apn) { + if (mccmnc == null) { + Log.w(TAG, "mccmnc was null, returning null"); + return Apn.EMPTY; + } + + Cursor cursor = null; + + try { + if (apn != null) { + Log.d(TAG, "Querying table for MCC+MNC " + mccmnc + " and APN name " + apn); + cursor = db.query(TABLE_NAME, null, + BASE_SELECTION + " AND " + APN_COLUMN + " = ?", + new String[] {mccmnc, apn}, + null, null, null); + } + + if (cursor == null || !cursor.moveToFirst()) { + if (cursor != null) cursor.close(); + Log.d(TAG, "Querying table for MCC+MNC " + mccmnc + " without APN name"); + cursor = db.query(TABLE_NAME, null, + BASE_SELECTION, + new String[] {mccmnc}, + null, null, null); + } + + if (cursor != null && cursor.moveToFirst()) { + Apn params = new Apn(cursor.getString(cursor.getColumnIndexOrThrow(MMSC_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(MMS_PROXY_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(MMS_PORT_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(USER_COLUMN)), + cursor.getString(cursor.getColumnIndexOrThrow(PASSWORD_COLUMN))); + Log.d(TAG, "Returning preferred APN " + params); + return params; + } + + Log.w(TAG, "No matching APNs found, returning null"); + + return Apn.EMPTY; + } finally { + if (cursor != null) cursor.close(); + } + + } + + public Optional getMmsConnectionParameters(String mccmnc, String apn) { + Apn customApn = getCustomApnParameters(); + Apn defaultApn = getDefaultApnParameters(mccmnc, apn); + Apn result = new Apn(customApn, defaultApn, + TextSecurePreferences.getUseCustomMmsc(context), + TextSecurePreferences.getUseCustomMmscProxy(context), + TextSecurePreferences.getUseCustomMmscProxyPort(context), + TextSecurePreferences.getUseCustomMmscUsername(context), + TextSecurePreferences.getUseCustomMmscPassword(context)); + + if (TextUtils.isEmpty(result.getMmsc())) return Optional.absent(); + else return Optional.of(result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java new file mode 100644 index 00000000..6c06e5af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -0,0 +1,1457 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.media.MediaDataSource; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.bumptech.glide.Glide; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.json.JSONArray; +import org.json.JSONException; +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.audio.AudioHash; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData; +import org.thoughtcrime.securesms.mms.MediaStream; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.video.EncryptedMediaDataSource; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public class AttachmentDatabase extends Database { + + private static final String TAG = AttachmentDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "part"; + public static final String ROW_ID = "_id"; + static final String ATTACHMENT_JSON_ALIAS = "attachment_json"; + public static final String MMS_ID = "mid"; + static final String CONTENT_TYPE = "ct"; + static final String NAME = "name"; + static final String CONTENT_DISPOSITION = "cd"; + static final String CONTENT_LOCATION = "cl"; + public static final String DATA = "_data"; + static final String TRANSFER_STATE = "pending_push"; + private static final String TRANSFER_FILE = "transfer_file"; + public static final String SIZE = "data_size"; + static final String FILE_NAME = "file_name"; + public static final String UNIQUE_ID = "unique_id"; + static final String DIGEST = "digest"; + static final String VOICE_NOTE = "voice_note"; + static final String BORDERLESS = "borderless"; + static final String QUOTE = "quote"; + public static final String STICKER_PACK_ID = "sticker_pack_id"; + public static final String STICKER_PACK_KEY = "sticker_pack_key"; + static final String STICKER_ID = "sticker_id"; + static final String STICKER_EMOJI = "sticker_emoji"; + static final String FAST_PREFLIGHT_ID = "fast_preflight_id"; + public static final String DATA_RANDOM = "data_random"; + static final String WIDTH = "width"; + static final String HEIGHT = "height"; + static final String CAPTION = "caption"; + static final String DATA_HASH = "data_hash"; + static final String VISUAL_HASH = "blur_hash"; + static final String TRANSFORM_PROPERTIES = "transform_properties"; + static final String DISPLAY_ORDER = "display_order"; + static final String UPLOAD_TIMESTAMP = "upload_timestamp"; + static final String CDN_NUMBER = "cdn_number"; + + public static final String DIRECTORY = "parts"; + + public static final int TRANSFER_PROGRESS_DONE = 0; + public static final int TRANSFER_PROGRESS_STARTED = 1; + public static final int TRANSFER_PROGRESS_PENDING = 2; + public static final int TRANSFER_PROGRESS_FAILED = 3; + + public static final long PREUPLOAD_MESSAGE_ID = -8675309; + + private static final String PART_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?"; + private static final String PART_ID_WHERE_NOT = ROW_ID + " != ? AND " + UNIQUE_ID + " != ?"; + + private static final String[] PROJECTION = new String[] {ROW_ID, + MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION, + CDN_NUMBER, CONTENT_LOCATION, DATA, + TRANSFER_STATE, SIZE, FILE_NAME, UNIQUE_ID, DIGEST, + FAST_PREFLIGHT_ID, VOICE_NOTE, BORDERLESS, QUOTE, DATA_RANDOM, + WIDTH, HEIGHT, CAPTION, STICKER_PACK_ID, + STICKER_PACK_KEY, STICKER_ID, STICKER_EMOJI, DATA_HASH, VISUAL_HASH, + TRANSFORM_PROPERTIES, TRANSFER_FILE, DISPLAY_ORDER, + UPLOAD_TIMESTAMP }; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " + + MMS_ID + " INTEGER, " + + "seq" + " INTEGER DEFAULT 0, " + + CONTENT_TYPE + " TEXT, " + + NAME + " TEXT, " + + "chset" + " INTEGER, " + + CONTENT_DISPOSITION + " TEXT, " + + "fn" + " TEXT, " + + "cid" + " TEXT, " + + CONTENT_LOCATION + " TEXT, " + + "ctt_s" + " INTEGER, " + + "ctt_t" + " TEXT, " + + "encrypted" + " INTEGER, " + + TRANSFER_STATE + " INTEGER, " + + DATA + " TEXT, " + + SIZE + " INTEGER, " + + FILE_NAME + " TEXT, " + + UNIQUE_ID + " INTEGER NOT NULL, " + + DIGEST + " BLOB, " + + FAST_PREFLIGHT_ID + " TEXT, " + + VOICE_NOTE + " INTEGER DEFAULT 0, " + + BORDERLESS + " INTEGER DEFAULT 0, " + + DATA_RANDOM + " BLOB, " + + QUOTE + " INTEGER DEFAULT 0, " + + WIDTH + " INTEGER DEFAULT 0, " + + HEIGHT + " INTEGER DEFAULT 0, " + + CAPTION + " TEXT DEFAULT NULL, " + + STICKER_PACK_ID + " TEXT DEFAULT NULL, " + + STICKER_PACK_KEY + " DEFAULT NULL, " + + STICKER_ID + " INTEGER DEFAULT -1, " + + STICKER_EMOJI + " STRING DEFAULT NULL, " + + DATA_HASH + " TEXT DEFAULT NULL, " + + VISUAL_HASH + " TEXT DEFAULT NULL, " + + TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " + + TRANSFER_FILE + " TEXT DEFAULT NULL, " + + DISPLAY_ORDER + " INTEGER DEFAULT 0, " + + UPLOAD_TIMESTAMP + " INTEGER DEFAULT 0, " + + CDN_NUMBER + " INTEGER DEFAULT 0);"; + + public static final String[] CREATE_INDEXS = { + "CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");", + "CREATE INDEX IF NOT EXISTS pending_push_index ON " + TABLE_NAME + " (" + TRANSFER_STATE + ");", + "CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON " + TABLE_NAME + " (" + STICKER_PACK_ID + ");", + "CREATE INDEX IF NOT EXISTS part_data_hash_index ON " + TABLE_NAME + " (" + DATA_HASH + ");", + "CREATE INDEX IF NOT EXISTS part_data_index ON " + TABLE_NAME + " (" + DATA + ");" + }; + + private final AttachmentSecret attachmentSecret; + + public AttachmentDatabase(Context context, SQLCipherOpenHelper databaseHelper, AttachmentSecret attachmentSecret) { + super(context, databaseHelper); + this.attachmentSecret = attachmentSecret; + } + + public @NonNull InputStream getAttachmentStream(AttachmentId attachmentId, long offset) + throws IOException + { + InputStream dataStream = getDataStream(attachmentId, DATA, offset); + + if (dataStream == null) throw new IOException("No stream for: " + attachmentId); + else return dataStream; + } + + public boolean containsStickerPackId(@NonNull String stickerPackId) { + String selection = STICKER_PACK_ID + " = ?"; + String[] args = new String[] { stickerPackId }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + + public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) + throws MmsException + { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_FAILED); + + database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); + notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId)); + } + + public @Nullable DatabaseAttachment getAttachment(@NonNull AttachmentId attachmentId) + { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, PROJECTION, PART_ID_WHERE, attachmentId.toStrings(), null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + List list = getAttachment(cursor); + + if (list != null && list.size() > 0) { + return list.get(0); + } + } + + return null; + } finally { + if (cursor != null) + cursor.close(); + } + } + + public @NonNull List getAttachmentsForMessage(long mmsId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, PROJECTION, MMS_ID + " = ?", new String[] {mmsId+""}, + null, null, UNIQUE_ID + " ASC, " + ROW_ID + " ASC"); + + while (cursor != null && cursor.moveToNext()) { + results.addAll(getAttachment(cursor)); + } + + return results; + } finally { + if (cursor != null) + cursor.close(); + } + } + + public boolean hasAttachment(@NonNull AttachmentId id) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, + new String[]{ROW_ID, UNIQUE_ID}, + PART_ID_WHERE, + id.toStrings(), + null, + null, + null)) { + if (cursor != null && cursor.getCount() > 0) { + return true; + } + } + return false; + } + + public boolean hasAttachmentFilesForMessage(long mmsId) { + String selection = MMS_ID + " = ? AND (" + DATA + " NOT NULL OR " + TRANSFER_STATE + " != ?)"; + String[] args = new String[] { String.valueOf(mmsId), String.valueOf(TRANSFER_PROGRESS_DONE) }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + + public @NonNull List getPendingAttachments() { + final SQLiteDatabase database = databaseHelper.getReadableDatabase(); + final List attachments = new LinkedList<>(); + + Cursor cursor = null; + try { + cursor = database.query(TABLE_NAME, PROJECTION, TRANSFER_STATE + " = ?", new String[] {String.valueOf(TRANSFER_PROGRESS_STARTED)}, null, null, null); + while (cursor != null && cursor.moveToNext()) { + attachments.addAll(getAttachment(cursor)); + } + } finally { + if (cursor != null) cursor.close(); + } + + return attachments; + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public void deleteAttachmentsForMessage(long mmsId) { + Log.d(TAG, "[deleteAttachmentsForMessage] mmsId: " + mmsId); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[] {DATA, CONTENT_TYPE, ROW_ID, UNIQUE_ID}, MMS_ID + " = ?", + new String[] {mmsId+""}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + deleteAttachmentOnDisk(CursorUtil.requireString(cursor, DATA), + CursorUtil.requireString(cursor, CONTENT_TYPE), + new AttachmentId(CursorUtil.requireLong(cursor, ROW_ID), + CursorUtil.requireLong(cursor, UNIQUE_ID))); + } + } finally { + if (cursor != null) + cursor.close(); + } + + database.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {mmsId + ""}); + notifyAttachmentListeners(); + } + + /** + * Deletes all attachments with an ID of {@link #PREUPLOAD_MESSAGE_ID}. These represent + * attachments that were pre-uploaded and haven't been assigned to a message. This should only be + * done when you *know* that all attachments *should* be assigned a real mmsId. For instance, when + * the app starts. Otherwise you could delete attachments that are legitimately being + * pre-uploaded. + */ + public int deleteAbandonedPreuploadedAttachments() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String query = MMS_ID + " = ?"; + String[] args = new String[] { String.valueOf(PREUPLOAD_MESSAGE_ID) }; + int count = 0; + + try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)); + long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID)); + AttachmentId id = new AttachmentId(rowId, uniqueId); + + deleteAttachment(id); + count++; + } + } + + return count; + } + + public void deleteAttachmentFilesForViewOnceMessage(long mmsId) { + Log.d(TAG, "[deleteAttachmentFilesForViewOnceMessage] mmsId: " + mmsId); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[] {DATA, CONTENT_TYPE, ROW_ID, UNIQUE_ID}, MMS_ID + " = ?", + new String[] {mmsId+""}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + deleteAttachmentOnDisk(CursorUtil.requireString(cursor, DATA), + CursorUtil.requireString(cursor, CONTENT_TYPE), + new AttachmentId(CursorUtil.requireLong(cursor, ROW_ID), + CursorUtil.requireLong(cursor, UNIQUE_ID))); + } + } finally { + if (cursor != null) + cursor.close(); + } + + ContentValues values = new ContentValues(); + values.put(DATA, (String) null); + values.put(DATA_RANDOM, (byte[]) null); + values.put(DATA_HASH, (String) null); + values.put(FILE_NAME, (String) null); + values.put(CAPTION, (String) null); + values.put(SIZE, 0); + values.put(WIDTH, 0); + values.put(HEIGHT, 0); + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE); + values.put(VISUAL_HASH, (String) null); + values.put(CONTENT_TYPE, MediaUtil.VIEW_ONCE); + + database.update(TABLE_NAME, values, MMS_ID + " = ?", new String[] {mmsId + ""}); + notifyAttachmentListeners(); + + long threadId = DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId); + if (threadId > 0) { + notifyConversationListeners(threadId); + } + } + + public void deleteAttachment(@NonNull AttachmentId id) { + Log.d(TAG, "[deleteAttachment] attachmentId: " + id); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, + new String[]{DATA, CONTENT_TYPE}, + PART_ID_WHERE, + id.toStrings(), + null, + null, + null)) + { + if (cursor == null || !cursor.moveToNext()) { + Log.w(TAG, "Tried to delete an attachment, but it didn't exist."); + return; + } + String data = CursorUtil.requireString(cursor, DATA); + String contentType = CursorUtil.requireString(cursor, CONTENT_TYPE); + + database.delete(TABLE_NAME, PART_ID_WHERE, id.toStrings()); + deleteAttachmentOnDisk(data, contentType, id); + notifyAttachmentListeners(); + } + } + + public void trimAllAbandonedAttachments() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String selectAllMmsIds = "SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME; + String selectDataInUse = "SELECT DISTINCT " + DATA + " FROM " + TABLE_NAME + " WHERE " + QUOTE + " = 0 AND (" + MMS_ID + " IN (" + selectAllMmsIds + ") OR " + MMS_ID + " = " + PREUPLOAD_MESSAGE_ID + ")"; + String where = MMS_ID + " NOT IN (" + selectAllMmsIds + ") AND " + DATA + " NOT IN (" + selectDataInUse + ")"; + + db.delete(TABLE_NAME, where, null); + } + + public void deleteAbandonedAttachmentFiles() { + Set filesOnDisk = new HashSet<>(); + Set filesInDb = new HashSet<>(); + + File attachmentDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + for (File file : attachmentDirectory.listFiles()) { + filesOnDisk.add(file.getAbsolutePath()); + } + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(true, TABLE_NAME, new String[] { DATA }, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + filesInDb.add(CursorUtil.requireString(cursor, DATA)); + } + } + + filesInDb.addAll(DatabaseFactory.getStickerDatabase(context).getAllStickerFiles()); + + Set onDiskButNotInDatabase = SetUtil.difference(filesOnDisk, filesInDb); + + for (String filePath : onDiskButNotInDatabase) { + //noinspection ResultOfMethodCallIgnored + new File(filePath).delete(); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + void deleteAllAttachments() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, null, null); + + FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE)); + + notifyAttachmentListeners(); + } + + private void deleteAttachmentOnDisk(@Nullable String data, + @Nullable String contentType, + @NonNull AttachmentId attachmentId) + { + DataUsageResult dataUsage = getAttachmentFileUsages(data, attachmentId); + + if (dataUsage.hasStrongReference()) { + Log.i(TAG, "[deleteAttachmentOnDisk] Attachment in use. Skipping deletion. " + data + " " + attachmentId); + return; + } + + Log.i(TAG, "[deleteAttachmentOnDisk] No other strong uses of this attachment. Safe to delete. " + data + " " + attachmentId); + + if (!TextUtils.isEmpty(data)) { + if (new File(data).delete()) { + Log.i(TAG, "[deleteAttachmentOnDisk] Deleted attachment file. " + data + " " + attachmentId); + + List removableWeakReferences = dataUsage.getRemovableWeakReferences(); + + if (removableWeakReferences.size() > 0) { + Log.i(TAG, String.format(Locale.US, "[deleteAttachmentOnDisk] Deleting %d weak references for %s", removableWeakReferences.size(), data)); + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + int deletedCount = 0; + database.beginTransaction(); + try { + for (AttachmentId weakReference : removableWeakReferences) { + Log.i(TAG, String.format("[deleteAttachmentOnDisk] Clearing weak reference for %s %s", data, weakReference)); + ContentValues values = new ContentValues(); + values.putNull(DATA); + values.putNull(DATA_RANDOM); + values.putNull(DATA_HASH); + deletedCount += database.update(TABLE_NAME, values, PART_ID_WHERE, weakReference.toStrings()); + } + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + String logMessage = String.format(Locale.US, "[deleteAttachmentOnDisk] Cleared %d/%d weak references for %s", deletedCount, removableWeakReferences.size(), data); + if (deletedCount != removableWeakReferences.size()) { + Log.w(TAG, logMessage); + } else { + Log.i(TAG, logMessage); + } + } + } else { + Log.w(TAG, "[deleteAttachmentOnDisk] Failed to delete attachment. " + data + " " + attachmentId); + } + } + + if (MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)) { + Glide.get(context).clearDiskCache(); + } + } + + private @NonNull DataUsageResult getAttachmentFileUsages(@Nullable String data, @NonNull AttachmentId attachmentId) { + if (data == null) return DataUsageResult.NOT_IN_USE; + + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String selection = DATA + " = ? AND " + UNIQUE_ID + " != ? AND " + ROW_ID + " != ?"; + String[] args = {data, Long.toString(attachmentId.getUniqueId()), Long.toString(attachmentId.getRowId())}; + List quoteRows = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, new String[]{ROW_ID, UNIQUE_ID, QUOTE}, selection, args, null, null, null, null)) { + while (cursor.moveToNext()) { + boolean isQuote = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1; + if (isQuote) { + quoteRows.add(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID)))); + } else { + return DataUsageResult.IN_USE; + } + } + } + + return new DataUsageResult(quoteRows); + } + + public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId attachmentId, @NonNull InputStream inputStream) + throws MmsException + { + DatabaseAttachment placeholder = getAttachment(attachmentId); + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + DataInfo oldInfo = getAttachmentDataFileInfo(attachmentId, DATA); + DataInfo dataInfo = setAttachmentData(inputStream, attachmentId); + File transferFile = getTransferFile(databaseHelper.getReadableDatabase(), attachmentId); + + if (oldInfo != null) { + updateAttachmentDataHash(database, oldInfo.hash, dataInfo); + } + + values.put(DATA, dataInfo.file.getAbsolutePath()); + values.put(SIZE, dataInfo.length); + values.put(DATA_RANDOM, dataInfo.random); + values.put(DATA_HASH, dataInfo.hash); + + String visualHashString = getVisualHashStringOrNull(placeholder); + if (visualHashString != null) { + values.put(VISUAL_HASH, visualHashString); + } + + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE); + values.put(TRANSFER_FILE, (String)null); + + values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize()); + + if (database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()) == 0) { + //noinspection ResultOfMethodCallIgnored + dataInfo.file.delete(); + } else { + notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId)); + notifyConversationListListeners(); + } + + if (transferFile != null) { + //noinspection ResultOfMethodCallIgnored + transferFile.delete(); + } + } + + private static @Nullable String getVisualHashStringOrNull(@Nullable Attachment attachment) { + if (attachment == null) return null; + else if (attachment.getBlurHash() != null) return attachment.getBlurHash().getHash(); + else if (attachment.getAudioHash() != null) return attachment.getAudioHash().getHash(); + else return null; + } + + public void copyAttachmentData(@NonNull AttachmentId sourceId, @NonNull AttachmentId destinationId) + throws MmsException + { + DatabaseAttachment sourceAttachment = getAttachment(sourceId); + + if (sourceAttachment == null) { + throw new MmsException("Cannot find attachment for source!"); + } + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + DataInfo sourceDataInfo = getAttachmentDataFileInfo(sourceId, DATA); + + if (sourceDataInfo == null) { + throw new MmsException("No attachment data found for source!"); + } + + ContentValues contentValues = new ContentValues(); + + contentValues.put(DATA, sourceDataInfo.file.getAbsolutePath()); + contentValues.put(DATA_HASH, sourceDataInfo.hash); + contentValues.put(SIZE, sourceDataInfo.length); + contentValues.put(DATA_RANDOM, sourceDataInfo.random); + + contentValues.put(TRANSFER_STATE, sourceAttachment.getTransferState()); + contentValues.put(CDN_NUMBER, sourceAttachment.getCdnNumber()); + contentValues.put(CONTENT_LOCATION, sourceAttachment.getLocation()); + contentValues.put(DIGEST, sourceAttachment.getDigest()); + contentValues.put(CONTENT_DISPOSITION, sourceAttachment.getKey()); + contentValues.put(NAME, sourceAttachment.getRelay()); + contentValues.put(SIZE, sourceAttachment.getSize()); + contentValues.put(FAST_PREFLIGHT_ID, sourceAttachment.getFastPreflightId()); + contentValues.put(WIDTH, sourceAttachment.getWidth()); + contentValues.put(HEIGHT, sourceAttachment.getHeight()); + contentValues.put(CONTENT_TYPE, sourceAttachment.getContentType()); + contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(sourceAttachment)); + + database.update(TABLE_NAME, contentValues, PART_ID_WHERE, destinationId.toStrings()); + } + + public void updateAttachmentCaption(@NonNull AttachmentId id, @Nullable String caption) { + ContentValues values = new ContentValues(1); + values.put(CAPTION, caption); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings()); + } + + public void updateDisplayOrder(@NonNull Map orderMap) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Map.Entry entry : orderMap.entrySet()) { + ContentValues values = new ContentValues(1); + values.put(DISPLAY_ORDER, entry.getValue()); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, PART_ID_WHERE, entry.getKey().toStrings()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + } + + public void updateAttachmentAfterUpload(@NonNull AttachmentId id, @NonNull Attachment attachment, long uploadTimestamp) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + DataInfo dataInfo = getAttachmentDataFileInfo(id, DATA); + ContentValues values = new ContentValues(); + + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE); + values.put(CDN_NUMBER, attachment.getCdnNumber()); + values.put(CONTENT_LOCATION, attachment.getLocation()); + values.put(DIGEST, attachment.getDigest()); + values.put(CONTENT_DISPOSITION, attachment.getKey()); + values.put(NAME, attachment.getRelay()); + values.put(SIZE, attachment.getSize()); + values.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId()); + values.put(VISUAL_HASH, getVisualHashStringOrNull(attachment)); + values.put(UPLOAD_TIMESTAMP, uploadTimestamp); + + if (dataInfo != null && dataInfo.hash != null) { + updateAttachmentAndMatchingHashes(database, id, dataInfo.hash, values); + } else { + database.update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings()); + } + } + + public @NonNull DatabaseAttachment insertAttachmentForPreUpload(@NonNull Attachment attachment) throws MmsException { + Map result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, + Collections.singletonList(attachment), + Collections.emptyList()); + + if (result.values().isEmpty()) { + throw new MmsException("Bad attachment result!"); + } + + DatabaseAttachment databaseAttachment = getAttachment(result.values().iterator().next()); + + if (databaseAttachment == null) { + throw new MmsException("Failed to retrieve attachment we just inserted!"); + } + + return databaseAttachment; + } + + public void updateMessageId(@NonNull Collection attachmentIds, long mmsId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + ContentValues values = new ContentValues(1); + values.put(MMS_ID, mmsId); + + for (AttachmentId attachmentId : attachmentIds) { + db.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @NonNull Map insertAttachmentsForMessage(long mmsId, @NonNull List attachments, @NonNull List quoteAttachment) + throws MmsException + { + Log.d(TAG, "insertParts(" + attachments.size() + ")"); + + Map insertedAttachments = new HashMap<>(); + + for (Attachment attachment : attachments) { + AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote()); + insertedAttachments.put(attachment, attachmentId); + Log.i(TAG, "Inserted attachment at ID: " + attachmentId); + } + + for (Attachment attachment : quoteAttachment) { + AttachmentId attachmentId = insertAttachment(mmsId, attachment, true); + insertedAttachments.put(attachment, attachmentId); + Log.i(TAG, "Inserted quoted attachment at ID: " + attachmentId); + } + + return insertedAttachments; + } + + /** + * @param onlyModifyThisAttachment If false and more than one attachment shares this file, they will all be updated. + * If true, then guarantees not to affect other attachments. + */ + public void updateAttachmentData(@NonNull DatabaseAttachment databaseAttachment, + @NonNull MediaStream mediaStream, + boolean onlyModifyThisAttachment) + throws MmsException, IOException + { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + DataInfo oldDataInfo = getAttachmentDataFileInfo(databaseAttachment.getAttachmentId(), DATA); + + if (oldDataInfo == null) { + throw new MmsException("No attachment data found!"); + } + + File destination = oldDataInfo.file; + + if (onlyModifyThisAttachment) { + if (fileReferencedByMoreThanOneAttachment(destination)) { + Log.i(TAG, "Creating a new file as this one is used by more than one attachment"); + destination = newFile(); + } + } + + DataInfo dataInfo = setAttachmentData(destination, + mediaStream.getStream(), + databaseAttachment.getAttachmentId()); + + ContentValues contentValues = new ContentValues(); + contentValues.put(SIZE, dataInfo.length); + contentValues.put(CONTENT_TYPE, mediaStream.getMimeType()); + contentValues.put(WIDTH, mediaStream.getWidth()); + contentValues.put(HEIGHT, mediaStream.getHeight()); + contentValues.put(DATA, dataInfo.file.getAbsolutePath()); + contentValues.put(DATA_RANDOM, dataInfo.random); + contentValues.put(DATA_HASH, dataInfo.hash); + + int updateCount = updateAttachmentAndMatchingHashes(database, databaseAttachment.getAttachmentId(), oldDataInfo.hash, contentValues); + Log.i(TAG, "[updateAttachmentData] Updated " + updateCount + " rows."); + } + + /** + * Returns true if the file referenced by two or more attachments. + * Returns false if the file is referenced by zero or one attachments. + */ + private boolean fileReferencedByMoreThanOneAttachment(@NonNull File file) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String selection = DATA + " = ?"; + String[] args = new String[]{file.getAbsolutePath()}; + + try (Cursor cursor = database.query(TABLE_NAME, null, selection, args, null, null, null, "2")) { + return cursor != null && cursor.moveToFirst() && cursor.moveToNext(); + } + } + + public void markAttachmentAsTransformed(@NonNull AttachmentId attachmentId) { + updateAttachmentTransformProperties(attachmentId, TransformProperties.forSkipTransform()); + } + + public void updateAttachmentTransformProperties(@NonNull AttachmentId attachmentId, @NonNull TransformProperties transformProperties) { + DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA); + + if (dataInfo == null) { + Log.w(TAG, "[updateAttachmentTransformProperties] No data info found!"); + return; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()); + + int updateCount = updateAttachmentAndMatchingHashes(databaseHelper.getWritableDatabase(), attachmentId, dataInfo.hash, contentValues); + Log.i(TAG, "[updateAttachmentTransformProperties] Updated " + updateCount + " rows."); + } + + public @NonNull File getOrCreateTransferFile(@NonNull AttachmentId attachmentId) throws IOException { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + File existing = getTransferFile(db, attachmentId); + + if (existing != null) { + return existing; + } + + File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File transferFile = File.createTempFile("transfer", ".mms", partsDirectory); + + ContentValues values = new ContentValues(); + values.put(TRANSFER_FILE, transferFile.getAbsolutePath()); + + db.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); + + return transferFile; + } + + private @Nullable static File getTransferFile(@NonNull SQLiteDatabase db, @NonNull AttachmentId attachmentId) { + try (Cursor cursor = db.query(TABLE_NAME, new String[] { TRANSFER_FILE }, PART_ID_WHERE, attachmentId.toStrings(), null, null, "1")) { + if (cursor != null && cursor.moveToFirst()) { + String path = cursor.getString(cursor.getColumnIndexOrThrow(TRANSFER_FILE)); + if (path != null) { + return new File(path); + } + } + } + + return null; + } + + private static int updateAttachmentAndMatchingHashes(@NonNull SQLiteDatabase database, + @NonNull AttachmentId attachmentId, + @Nullable String dataHash, + @NonNull ContentValues contentValues) + { + String selection = "(" + ROW_ID + " = ? AND " + UNIQUE_ID + " = ?) OR " + + "(" + DATA_HASH + " NOT NULL AND " + DATA_HASH + " = ?)"; + String[] args = new String[]{String.valueOf(attachmentId.getRowId()), + String.valueOf(attachmentId.getUniqueId()), + String.valueOf(dataHash)}; + + return database.update(TABLE_NAME, contentValues, selection, args); + } + + private static void updateAttachmentDataHash(@NonNull SQLiteDatabase database, + @NonNull String oldHash, + @NonNull DataInfo newData) + { + if (oldHash == null) return; + + ContentValues contentValues = new ContentValues(); + contentValues.put(DATA, newData.file.getAbsolutePath()); + contentValues.put(DATA_RANDOM, newData.random); + contentValues.put(DATA_HASH, newData.hash); + database.update(TABLE_NAME, + contentValues, + DATA_HASH + " = ?", + new String[]{oldHash}); + } + + public void updateAttachmentFileName(@NonNull AttachmentId attachmentId, + @Nullable String fileName) + { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(1); + contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(fileName)); + + database.update(TABLE_NAME, contentValues, PART_ID_WHERE, attachmentId.toStrings()); + } + + public void markAttachmentUploaded(long messageId, Attachment attachment) { + ContentValues values = new ContentValues(1); + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE); + database.update(TABLE_NAME, values, PART_ID_WHERE, ((DatabaseAttachment)attachment).getAttachmentId().toStrings()); + + notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId)); + } + + public void setTransferState(long messageId, @NonNull Attachment attachment, int transferState) { + if (!(attachment instanceof DatabaseAttachment)) { + throw new AssertionError("Attempt to update attachment that doesn't belong to DB!"); + } + + setTransferState(messageId, ((DatabaseAttachment) attachment).getAttachmentId(), transferState); + } + + public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, int transferState) { + final ContentValues values = new ContentValues(1); + final SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + values.put(TRANSFER_STATE, transferState); + database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); + notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId)); + } + + /** + * Returns (pack_id, pack_key) pairs that are referenced in attachments but not in the stickers + * database. + */ + public @Nullable Cursor getUnavailableStickerPacks() { + String query = "SELECT DISTINCT " + STICKER_PACK_ID + ", " + STICKER_PACK_KEY + + " FROM " + TABLE_NAME + + " WHERE " + + STICKER_PACK_ID + " NOT NULL AND " + + STICKER_PACK_KEY + " NOT NULL AND " + + STICKER_PACK_ID + " NOT IN (" + + "SELECT DISTINCT " + StickerDatabase.PACK_ID + " FROM " + StickerDatabase.TABLE_NAME + + ")"; + + return databaseHelper.getReadableDatabase().rawQuery(query, null); + } + + public boolean hasStickerAttachments() { + String selection = STICKER_PACK_ID + " NOT NULL"; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, null, null, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + protected @Nullable InputStream getDataStream(AttachmentId attachmentId, String dataType, long offset) + { + DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, dataType); + + if (dataInfo == null) { + return null; + } + + try { + if (dataInfo.random != null && dataInfo.random.length == 32) { + return ModernDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.random, dataInfo.file, offset); + } else { + InputStream stream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.file); + long skipped = stream.skip(offset); + + if (skipped != offset) { + Log.w(TAG, "Skip failed: " + skipped + " vs " + offset); + return null; + } + + return stream; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private @Nullable DataInfo getAttachmentDataFileInfo(@NonNull AttachmentId attachmentId, @NonNull String dataType) + { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[]{dataType, SIZE, DATA_RANDOM, DATA_HASH}, PART_ID_WHERE, attachmentId.toStrings(), + null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + if (cursor.isNull(cursor.getColumnIndexOrThrow(dataType))) { + return null; + } + + return new DataInfo(new File(cursor.getString(cursor.getColumnIndexOrThrow(dataType))), + cursor.getLong(cursor.getColumnIndexOrThrow(SIZE)), + cursor.getBlob(cursor.getColumnIndexOrThrow(DATA_RANDOM)), + cursor.getString(cursor.getColumnIndexOrThrow(DATA_HASH))); + } else { + return null; + } + } finally { + if (cursor != null) + cursor.close(); + } + + } + + private @NonNull DataInfo setAttachmentData(@NonNull Uri uri, + @Nullable AttachmentId attachmentId) + throws MmsException + { + try { + InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); + return setAttachmentData(inputStream, attachmentId); + } catch (IOException e) { + throw new MmsException(e); + } + } + + private @NonNull DataInfo setAttachmentData(@NonNull InputStream in, + @Nullable AttachmentId attachmentId) + throws MmsException + { + try { + File dataFile = newFile(); + return setAttachmentData(dataFile, in, attachmentId); + } catch (IOException e) { + throw new MmsException(e); + } + } + + public File newFile() throws IOException { + File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + return File.createTempFile("part", ".mms", partsDirectory); + } + + private @NonNull DataInfo setAttachmentData(@NonNull File destination, + @NonNull InputStream in, + @Nullable AttachmentId attachmentId) + throws MmsException + { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + DigestInputStream digestInputStream = new DigestInputStream(in, messageDigest); + Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, destination, false); + long length = StreamUtil.copy(digestInputStream, out.second); + String hash = Base64.encodeBytes(digestInputStream.getMessageDigest().digest()); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + Optional sharedDataInfo = findDuplicateDataFileInfo(database, hash, attachmentId); + if (sharedDataInfo.isPresent()) { + Log.i(TAG, "[setAttachmentData] Duplicate data file found! " + sharedDataInfo.get().file.getAbsolutePath()); + if (!destination.equals(sharedDataInfo.get().file) && destination.delete()) { + Log.i(TAG, "[setAttachmentData] Deleted original file. " + destination); + } + return sharedDataInfo.get(); + } else { + Log.i(TAG, "[setAttachmentData] No matching attachment data found. " + destination.getAbsolutePath()); + } + + return new DataInfo(destination, length, out.first, hash); + } catch (IOException | NoSuchAlgorithmException e) { + throw new MmsException(e); + } + } + + private static @NonNull Optional findDuplicateDataFileInfo(@NonNull SQLiteDatabase database, + @NonNull String hash, + @Nullable AttachmentId excludedAttachmentId) + { + + Pair selectorArgs = buildSharedFileSelectorArgs(hash, excludedAttachmentId); + try (Cursor cursor = database.query(TABLE_NAME, + new String[]{DATA, DATA_RANDOM, SIZE}, + selectorArgs.first, + selectorArgs.second, + null, + null, + null, + "1")) + { + if (cursor == null || !cursor.moveToFirst()) return Optional.absent(); + + if (cursor.getCount() > 0) { + DataInfo dataInfo = new DataInfo(new File(CursorUtil.requireString(cursor, DATA)), + CursorUtil.requireLong(cursor, SIZE), + CursorUtil.requireBlob(cursor, DATA_RANDOM), + hash); + return Optional.of(dataInfo); + } else { + return Optional.absent(); + } + } + } + + private static Pair buildSharedFileSelectorArgs(@NonNull String newHash, + @Nullable AttachmentId attachmentId) + { + final String selector; + final String[] selection; + + if (attachmentId == null) { + selector = DATA_HASH + " = ?"; + selection = new String[]{newHash}; + } else { + selector = PART_ID_WHERE_NOT + " AND " + DATA_HASH + " = ?"; + selection = new String[]{Long.toString(attachmentId.getRowId()), + Long.toString(attachmentId.getUniqueId()), + newHash}; + } + + return Pair.create(selector, selection); + } + + public List getAttachment(@NonNull Cursor cursor) { + try { + if (cursor.getColumnIndex(AttachmentDatabase.ATTACHMENT_JSON_ALIAS) != -1) { + if (cursor.isNull(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS))) { + return new LinkedList<>(); + } + + List result = new LinkedList<>(); + JSONArray array = new JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS))); + + for (int i=0;i= 0 + ? new StickerLocator(object.getString(STICKER_PACK_ID), + object.getString(STICKER_PACK_KEY), + object.getInt(STICKER_ID), + object.getString(STICKER_EMOJI)) + : null, + MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(object.getString(VISUAL_HASH)), + MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(object.getString(VISUAL_HASH)) : null, + TransformProperties.parse(object.getString(TRANSFORM_PROPERTIES)), + object.getInt(DISPLAY_ORDER), + object.getLong(UPLOAD_TIMESTAMP))); + } + } + + return result; + } else { + String contentType = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE)); + return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), + cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))), + cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)), + !cursor.isNull(cursor.getColumnIndexOrThrow(DATA)), + MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType), + contentType, + cursor.getInt(cursor.getColumnIndexOrThrow(TRANSFER_STATE)), + cursor.getLong(cursor.getColumnIndexOrThrow(SIZE)), + cursor.getString(cursor.getColumnIndexOrThrow(FILE_NAME)), + cursor.getInt(cursor.getColumnIndexOrThrow(CDN_NUMBER)), + cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION)), + cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_DISPOSITION)), + cursor.getString(cursor.getColumnIndexOrThrow(NAME)), + cursor.getBlob(cursor.getColumnIndexOrThrow(DIGEST)), + cursor.getString(cursor.getColumnIndexOrThrow(FAST_PREFLIGHT_ID)), + cursor.getInt(cursor.getColumnIndexOrThrow(VOICE_NOTE)) == 1, + cursor.getInt(cursor.getColumnIndexOrThrow(BORDERLESS)) == 1, + cursor.getInt(cursor.getColumnIndexOrThrow(WIDTH)), + cursor.getInt(cursor.getColumnIndexOrThrow(HEIGHT)), + cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1, + cursor.getString(cursor.getColumnIndexOrThrow(CAPTION)), + cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)) >= 0 + ? new StickerLocator(CursorUtil.requireString(cursor, STICKER_PACK_ID), + CursorUtil.requireString(cursor, STICKER_PACK_KEY), + CursorUtil.requireInt(cursor, STICKER_ID), + CursorUtil.requireString(cursor, STICKER_EMOJI)) + : null, + MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))), + MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))) : null, + TransformProperties.parse(cursor.getString(cursor.getColumnIndexOrThrow(TRANSFORM_PROPERTIES))), + cursor.getInt(cursor.getColumnIndexOrThrow(DISPLAY_ORDER)), + cursor.getLong(cursor.getColumnIndexOrThrow(UPLOAD_TIMESTAMP)))); + } + } catch (JSONException e) { + throw new AssertionError(e); + } + } + + private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean quote) + throws MmsException + { + Log.d(TAG, "Inserting attachment for mms id: " + mmsId); + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + database.beginTransaction(); + try { + DataInfo dataInfo = null; + long uniqueId = System.currentTimeMillis(); + + if (attachment.getUri() != null) { + dataInfo = setAttachmentData(attachment.getUri(), null); + Log.d(TAG, "Wrote part to file: " + dataInfo.file.getAbsolutePath()); + } + + Attachment template = attachment; + + if (dataInfo != null && dataInfo.hash != null) { + Attachment possibleTemplate = findTemplateAttachment(dataInfo.hash); + + if (possibleTemplate != null) { + Log.i(TAG, "Found a duplicate attachment upon insertion. Using it as a template."); + template = possibleTemplate; + } + } + + boolean useTemplateUpload = template.getUploadTimestamp() > attachment.getUploadTimestamp() && + template.getTransferState() == TRANSFER_PROGRESS_DONE && + template.getTransformProperties().shouldSkipTransform() && + !attachment.getTransformProperties().isVideoEdited(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(MMS_ID, mmsId); + contentValues.put(CONTENT_TYPE, template.getContentType()); + contentValues.put(TRANSFER_STATE, attachment.getTransferState()); + contentValues.put(UNIQUE_ID, uniqueId); + contentValues.put(CDN_NUMBER, useTemplateUpload ? template.getCdnNumber() : attachment.getCdnNumber()); + contentValues.put(CONTENT_LOCATION, useTemplateUpload ? template.getLocation() : attachment.getLocation()); + contentValues.put(DIGEST, useTemplateUpload ? template.getDigest() : attachment.getDigest()); + contentValues.put(CONTENT_DISPOSITION, useTemplateUpload ? template.getKey() : attachment.getKey()); + contentValues.put(NAME, useTemplateUpload ? template.getRelay() : attachment.getRelay()); + contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.getFileName())); + contentValues.put(SIZE, template.getSize()); + contentValues.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId()); + contentValues.put(VOICE_NOTE, attachment.isVoiceNote() ? 1 : 0); + contentValues.put(BORDERLESS, attachment.isBorderless() ? 1 : 0); + contentValues.put(WIDTH, template.getWidth()); + contentValues.put(HEIGHT, template.getHeight()); + contentValues.put(QUOTE, quote); + contentValues.put(CAPTION, attachment.getCaption()); + contentValues.put(UPLOAD_TIMESTAMP, useTemplateUpload ? template.getUploadTimestamp() : attachment.getUploadTimestamp()); + if (attachment.getTransformProperties().isVideoEdited()) { + contentValues.putNull(VISUAL_HASH); + contentValues.put(TRANSFORM_PROPERTIES, attachment.getTransformProperties().serialize()); + } else { + contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(template)); + contentValues.put(TRANSFORM_PROPERTIES, template.getTransformProperties().serialize()); + } + + if (attachment.isSticker()) { + contentValues.put(STICKER_PACK_ID, attachment.getSticker().getPackId()); + contentValues.put(STICKER_PACK_KEY, attachment.getSticker().getPackKey()); + contentValues.put(STICKER_ID, attachment.getSticker().getStickerId()); + contentValues.put(STICKER_EMOJI, attachment.getSticker().getEmoji()); + } + + if (dataInfo != null) { + contentValues.put(DATA, dataInfo.file.getAbsolutePath()); + contentValues.put(SIZE, dataInfo.length); + contentValues.put(DATA_RANDOM, dataInfo.random); + if (attachment.getTransformProperties().isVideoEdited()) { + contentValues.putNull(DATA_HASH); + } else { + contentValues.put(DATA_HASH, dataInfo.hash); + } + } + + boolean notifyPacks = attachment.isSticker() && !hasStickerAttachments(); + long rowId = database.insert(TABLE_NAME, null, contentValues); + AttachmentId attachmentId = new AttachmentId(rowId, uniqueId); + + if (notifyPacks) { + notifyStickerPackListeners(); + } + + database.setTransactionSuccessful(); + + return attachmentId; + } finally { + database.endTransaction(); + } + } + + private @Nullable DatabaseAttachment findTemplateAttachment(@NonNull String dataHash) { + String selection = DATA_HASH + " = ?"; + String[] args = new String[] { dataHash }; + + try (Cursor cursor = databaseHelper.getWritableDatabase().query(TABLE_NAME, null, selection, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return getAttachment(cursor).get(0); + } + } + + return null; + } + + @WorkerThread + public void writeAudioHash(@NonNull AttachmentId attachmentId, @Nullable AudioWaveFormData audioWaveForm) { + Log.i(TAG, "updating part audio wave form for #" + attachmentId); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(1); + + if (audioWaveForm != null) { + values.put(VISUAL_HASH, new AudioHash(audioWaveForm).getHash()); + } else { + values.putNull(VISUAL_HASH); + } + + database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); + } + + + @RequiresApi(23) + public @Nullable MediaDataSource mediaDataSourceFor(@NonNull AttachmentId attachmentId) { + DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA); + + if (dataInfo == null) { + Log.w(TAG, "No data file found for video attachment..."); + return null; + } + + return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length); + } + + private static class DataInfo { + private final File file; + private final long length; + private final byte[] random; + private final String hash; + + private DataInfo(File file, long length, byte[] random, String hash) { + this.file = file; + this.length = length; + this.random = random; + this.hash = hash; + } + } + + private static final class DataUsageResult { + private final boolean hasStrongReference; + private final List removableWeakReferences; + + private static final DataUsageResult IN_USE = new DataUsageResult(true, Collections.emptyList()); + private static final DataUsageResult NOT_IN_USE = new DataUsageResult(false, Collections.emptyList()); + + DataUsageResult(@NonNull List removableWeakReferences) { + this(false, removableWeakReferences); + } + + private DataUsageResult(boolean hasStrongReference, @NonNull List removableWeakReferences) { + if (hasStrongReference && removableWeakReferences.size() > 0) { + throw new AssertionError(); + } + this.hasStrongReference = hasStrongReference; + this.removableWeakReferences = removableWeakReferences; + } + + boolean hasStrongReference() { + return hasStrongReference; + } + + /** + * Entries in here can be removed from the database. + *

+ * Only possible to be non-empty when {@link #hasStrongReference} is false. + */ + @NonNull List getRemovableWeakReferences() { + return removableWeakReferences; + } + } + + public static final class TransformProperties { + + @JsonProperty private final boolean skipTransform; + @JsonProperty private final boolean videoTrim; + @JsonProperty private final long videoTrimStartTimeUs; + @JsonProperty private final long videoTrimEndTimeUs; + + @JsonCreator + public TransformProperties(@JsonProperty("skipTransform") boolean skipTransform, + @JsonProperty("videoTrim") boolean videoTrim, + @JsonProperty("videoTrimStartTimeUs") long videoTrimStartTimeUs, + @JsonProperty("videoTrimEndTimeUs") long videoTrimEndTimeUs) + { + this.skipTransform = skipTransform; + this.videoTrim = videoTrim; + this.videoTrimStartTimeUs = videoTrimStartTimeUs; + this.videoTrimEndTimeUs = videoTrimEndTimeUs; + } + + public static @NonNull TransformProperties empty() { + return new TransformProperties(false, false, 0, 0); + } + + public static @NonNull TransformProperties forSkipTransform() { + return new TransformProperties(true, false, 0, 0); + } + + public static @NonNull TransformProperties forVideoTrim(long videoTrimStartTimeUs, long videoTrimEndTimeUs) { + return new TransformProperties(false, true, videoTrimStartTimeUs, videoTrimEndTimeUs); + } + + public boolean shouldSkipTransform() { + return skipTransform; + } + + public boolean isVideoEdited() { + return isVideoTrim(); + } + + public boolean isVideoTrim() { + return videoTrim; + } + + public long getVideoTrimStartTimeUs() { + return videoTrimStartTimeUs; + } + + public long getVideoTrimEndTimeUs() { + return videoTrimEndTimeUs; + } + + @NonNull String serialize() { + return JsonUtil.toJson(this); + } + + static @NonNull TransformProperties parse(@Nullable String serialized) { + if (serialized == null) { + return empty(); + } + + try { + return JsonUtil.fromJson(serialized, TransformProperties.class); + } catch (IOException e) { + Log.w(TAG, "Failed to parse TransformProperties!", e); + return empty(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ContentValuesBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/database/ContentValuesBuilder.java new file mode 100644 index 00000000..53a05683 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ContentValuesBuilder.java @@ -0,0 +1,59 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; + +import com.google.android.mms.pdu_alt.EncodedStringValue; + +import org.thoughtcrime.securesms.util.Util; + +public class ContentValuesBuilder { + + private final ContentValues contentValues; + + public ContentValuesBuilder(ContentValues contentValues) { + this.contentValues = contentValues; + } + + public void add(String key, String charsetKey, EncodedStringValue value) { + if (value != null) { + contentValues.put(key, Util.toIsoString(value.getTextString())); + contentValues.put(charsetKey, value.getCharacterSet()); + } + } + + public void add(String contentKey, byte[] value) { + if (value != null) { + contentValues.put(contentKey, Util.toIsoString(value)); + } + } + + public void add(String contentKey, int b) { + if (b != 0) + contentValues.put(contentKey, b); + } + + public void add(String contentKey, long value) { + if (value != -1L) + contentValues.put(contentKey, value); + } + + public ContentValues getContentValues() { + return contentValues; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java b/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java new file mode 100644 index 00000000..9c40e19e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.database; + +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; + +/** + * A list backed by a {@link Cursor} that retrieves models using a provided {@link ModelBuilder}. + * Allows you to abstract away the use of a {@link Cursor} while still getting the benefits of a + * {@link Cursor} (e.g. windowing). + * + * The one special consideration that must be made is that because this contains a cursor, you must + * call {@link #close()} when you are finished with it. + * + * Given that this is cursor-backed, it is effectively immutable. + */ +public class CursorList implements List, ObservableContent { + + private final Cursor cursor; + private final ModelBuilder modelBuilder; + + public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder modelBuilder) { + this.cursor = cursor; + this.modelBuilder = modelBuilder; + + forceQueryLoad(); + } + + public static CursorList emptyList() { + //noinspection ConstantConditions,unchecked + return (CursorList) new CursorList(emptyCursor(), null); + } + + private static Cursor emptyCursor() { + return new MatrixCursor(new String[] { "a" }, 0); + } + + @Override + public int size() { + return cursor.getCount(); + } + + @Override + public boolean isEmpty() { + return size() == 0; + } + + @Override + public boolean contains(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull Iterator iterator() { + return new Iterator() { + int index = 0; + + @Override + public boolean hasNext() { + return cursor.getCount() > 0 && !cursor.isLast(); + } + + @Override + public T next() { + cursor.moveToPosition(index++); + return modelBuilder.build(cursor); + } + }; + } + + @Override + public @NonNull Object[] toArray() { + Object[] out = new Object[size()]; + for (int i = 0; i < cursor.getCount(); i++) { + cursor.moveToPosition(i); + out[i] = modelBuilder.build(cursor); + } + return out; + } + + @Override + public boolean add(T o) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(@NonNull Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(int i, @NonNull Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public T get(int i) { + cursor.moveToPosition(i); + return modelBuilder.build(cursor); + } + + @Override + public T set(int i, T o) { + throw new UnsupportedOperationException(); + } + + @Override + public void add(int i, T o) { + throw new UnsupportedOperationException(); + } + + @Override + public T remove(int i) { + throw new UnsupportedOperationException(); + } + + @Override + public int indexOf(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public int lastIndexOf(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull ListIterator listIterator() { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull ListIterator listIterator(int i) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull List subList(int i, int i1) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(@NonNull Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(@NonNull Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsAll(@NonNull Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull T[] toArray(@Nullable Object[] objects) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + if (!cursor.isClosed()) { + cursor.close(); + } + } + + @Override + public void registerContentObserver(@NonNull ContentObserver observer) { + cursor.registerContentObserver(observer); + } + + @Override + public void unregisterContentObserver(@NonNull ContentObserver observer) { + cursor.unregisterContentObserver(observer); + } + + private void forceQueryLoad() { + cursor.getCount(); + } + + public interface ModelBuilder { + T build(@NonNull Cursor cursor); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java new file mode 100644 index 00000000..b5e54471 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java @@ -0,0 +1,301 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +/** + * RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView. + */ +public abstract class CursorRecyclerViewAdapter extends RecyclerView.Adapter { + private final @NonNull Context context; + private final DataSetObserver observer = new AdapterDataSetObserver(); + + @VisibleForTesting static final int HEADER_TYPE = Integer.MIN_VALUE; + @VisibleForTesting static final int FOOTER_TYPE = Integer.MIN_VALUE + 1; + @VisibleForTesting static final long HEADER_ID = Long.MIN_VALUE; + @VisibleForTesting static final long FOOTER_ID = Long.MIN_VALUE + 1; + + private @Nullable Cursor cursor; + private boolean valid; + private @Nullable View header; + private @Nullable View footer; + + private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder { + + private ViewGroup container; + + HeaderFooterViewHolder(@NonNull View itemView) { + super(itemView); + this.container = (ViewGroup) itemView; + } + + void bind(@Nullable View view) { + unbind(); + + if (view != null) { + container.addView(view); + } + } + + void unbind() { + container.removeAllViews(); + } + } + + protected CursorRecyclerViewAdapter(@NonNull Context context, @Nullable Cursor cursor) { + this.context = context; + this.cursor = cursor; + if (cursor != null) { + valid = true; + cursor.registerDataSetObserver(observer); + } + } + + protected @NonNull Context getContext() { + return context; + } + + public @Nullable Cursor getCursor() { + return cursor; + } + + public void setHeaderView(@Nullable View header) { + this.header = header; + } + + public View getHeaderView() { + return this.header; + } + + public void setFooterView(@Nullable View footer) { + this.footer = footer; + } + + public boolean hasHeaderView() { + return header != null; + } + + public boolean hasFooterView() { + return footer != null; + } + + public void changeCursor(Cursor cursor) { + Cursor old = swapCursor(cursor); + if (old != null) { + old.close(); + } + } + + public Cursor swapCursor(Cursor newCursor) { + if (newCursor == cursor) { + return null; + } + + final Cursor oldCursor = cursor; + if (oldCursor != null) { + oldCursor.unregisterDataSetObserver(observer); + } + + cursor = newCursor; + if (cursor != null) { + cursor.registerDataSetObserver(observer); + } + + valid = cursor != null; + notifyDataSetChanged(); + return oldCursor; + } + + @Override + public int getItemCount() { + if (!isActiveCursor()) return 0; + + return cursor.getCount() + + getFastAccessSize() + + (hasHeaderView() ? 1 : 0) + + (hasFooterView() ? 1 : 0); + } + + public int getCursorCount() { + return cursor.getCount(); + } + + @SuppressWarnings("unchecked") + @Override + public final void onViewRecycled(@NonNull ViewHolder holder) { + if (!(holder instanceof HeaderFooterViewHolder)) { + onItemViewRecycled((VH)holder); + } else { + ((HeaderFooterViewHolder) holder).unbind(); + } + } + + public void onItemViewRecycled(VH holder) {} + + @Override + public @NonNull final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case HEADER_TYPE: + case FOOTER_TYPE: + return new HeaderFooterViewHolder(LayoutInflater.from(context).inflate(R.layout.cursor_adapter_header_footer_view, parent, false)); + default: + return onCreateItemViewHolder(parent, viewType); + } + } + + public abstract VH onCreateItemViewHolder(ViewGroup parent, int viewType); + + @Override + public final void onBindViewHolder(@NonNull ViewHolder viewHolder, int position, @NonNull List payloads) { + if (arePayloadsValid(payloads) && !isHeaderPosition(position) && !isFooterPosition(position)) { + if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position, payloads); + else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position), payloads); + } else { + onBindViewHolder(viewHolder, position); + } + } + + @SuppressWarnings("unchecked") + @Override + public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (isHeaderPosition(position)) { + ((HeaderFooterViewHolder) viewHolder).bind(header); + } else if (isFooterPosition(position)) { + ((HeaderFooterViewHolder) viewHolder).bind(footer); + } else { + if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position); + else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position)); + } + } + + public abstract void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor); + + protected boolean arePayloadsValid(@NonNull List payloads) { + return false; + } + + protected void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor, @NonNull List payloads) { + } + + protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) { + } + + protected void onBindFastAccessItemViewHolder(VH viewHolder, int position, @NonNull List payloads) { + } + + @Override + public final int getItemViewType(int position) { + if (isHeaderPosition(position)) return HEADER_TYPE; + if (isFooterPosition(position)) return FOOTER_TYPE; + if (isFastAccessPosition(position)) return getFastAccessItemViewType(position); + return getItemViewType(getCursorAtPositionOrThrow(position)); + } + + public int getItemViewType(@NonNull Cursor cursor) { + return 0; + } + + @Override + public final long getItemId(int position) { + if (isHeaderPosition(position)) return HEADER_ID; + else if (isFooterPosition(position)) return FOOTER_ID; + else if (isFastAccessPosition(position)) return getFastAccessItemId(position); + + long itemId = getItemId(getCursorAtPositionOrThrow(position)); + return itemId <= Long.MIN_VALUE + 1 ? itemId + 2 : itemId; + } + + public long getItemId(@NonNull Cursor cursor) { + return cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + } + + protected @NonNull Cursor getCursorAtPositionOrThrow(final int position) { + if (!isActiveCursor()) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + if (!cursor.moveToPosition(getCursorPosition(position))) { + throw new IllegalStateException("couldn't move cursor to position " + position + " (actual cursor position " + getCursorPosition(position) + ")"); + } + return cursor; + } + + protected boolean isActiveCursor() { + return valid && cursor != null; + } + + protected boolean isFooterPosition(int position) { + return hasFooterView() && position == getItemCount() - 1; + } + + protected boolean isHeaderPosition(int position) { + return hasHeaderView() && position == 0; + } + + private int getCursorPosition(int position) { + if (hasHeaderView()) { + position -= 1; + } + + return position - getFastAccessSize(); + } + + protected int getFastAccessItemViewType(int position) { + return 0; + } + + protected boolean isFastAccessPosition(int position) { + return false; + } + + protected long getFastAccessItemId(int position) { + return 0; + } + + protected int getFastAccessSize() { + return 0; + } + + private class AdapterDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + valid = true; + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + valid = false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java new file mode 100644 index 00000000..e890d669 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -0,0 +1,118 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.Set; + +public abstract class Database { + + protected static final String ID_WHERE = "_id = ?"; + + protected SQLCipherOpenHelper databaseHelper; + protected final Context context; + + public Database(Context context, SQLCipherOpenHelper databaseHelper) { + this.context = context; + this.databaseHelper = databaseHelper; + } + + protected void notifyConversationListeners(Set threadIds) { + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadIds); + + for (long threadId : threadIds) { + notifyConversationListeners(threadId); + } + } + + protected void notifyConversationListeners(long threadId) { + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); + + context.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null); + notifyVerboseConversationListeners(threadId); + } + + protected void notifyVerboseConversationListeners(long threadId) { + ApplicationDependencies.getDatabaseObserver().notifyVerboseConversationListeners(threadId); + context.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId), null); + } + + protected void notifyConversationListListeners() { + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); + context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); + } + + protected void notifyStickerListeners() { + context.getContentResolver().notifyChange(DatabaseContentProviders.Sticker.CONTENT_URI, null); + } + + protected void notifyStickerPackListeners() { + context.getContentResolver().notifyChange(DatabaseContentProviders.StickerPack.CONTENT_URI, null); + } + + @Deprecated + protected void setNotifyConversationListeners(Cursor cursor, long threadId) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId)); + } + + @Deprecated + protected void setNotifyConversationListeners(Cursor cursor) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForAllThreads()); + } + + @Deprecated + protected void setNotifyVerboseConversationListeners(Cursor cursor, long threadId) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId)); + } + + @Deprecated + protected void setNotifyConversationListListeners(Cursor cursor) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI); + } + + @Deprecated + protected void setNotifyStickerListeners(Cursor cursor) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Sticker.CONTENT_URI); + } + + @Deprecated + protected void setNotifyStickerPackListeners(Cursor cursor) { + cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.StickerPack.CONTENT_URI); + } + + protected void registerAttachmentListeners(@NonNull ContentObserver observer) { + context.getContentResolver().registerContentObserver(DatabaseContentProviders.Attachment.CONTENT_URI, + true, + observer); + } + + protected void notifyAttachmentListeners() { + context.getContentResolver().notifyChange(DatabaseContentProviders.Attachment.CONTENT_URI, null); + } + + public void reset(SQLCipherOpenHelper databaseHelper) { + this.databaseHelper = databaseHelper; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java new file mode 100644 index 00000000..ed22c13c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.BuildConfig; + +/** + * Starting in API 26, a {@link ContentProvider} needs to be defined for each authority you wish to + * observe changes on. These classes essentially do nothing except exist so Android doesn't complain. + */ +public class DatabaseContentProviders { + + public static class ConversationList extends NoopContentProvider { + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.conversationlist"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY; + + public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + } + + public static class Conversation extends NoopContentProvider { + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.conversation"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/"; + + public static Uri getUriForThread(long threadId) { + return Uri.parse(CONTENT_URI_STRING + threadId); + } + + public static Uri getVerboseUriForThread(long threadId) { + return Uri.parse(CONTENT_URI_STRING + "verbose/" + threadId); + } + + public static Uri getUriForAllThreads() { + return Uri.parse(CONTENT_URI_STRING); + } + } + + public static class Attachment extends NoopContentProvider { + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.attachment"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY; + + public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + } + + public static class Sticker extends NoopContentProvider { + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.sticker"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY; + + public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + } + + public static class StickerPack extends NoopContentProvider { + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.stickerpack"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY; + + public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + } + + private static abstract class NoopContentProvider extends ContentProvider { + + @Override + public boolean onCreate() { + return false; + } + + @Nullable + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { + return null; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return null; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java new file mode 100644 index 00000000..4d49278d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2018 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.contacts.ContactsDatabase; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper; +import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class DatabaseFactory { + + private static final Object lock = new Object(); + + private static volatile DatabaseFactory instance; + + private final SQLCipherOpenHelper databaseHelper; + private final SmsDatabase sms; + private final MmsDatabase mms; + private final AttachmentDatabase attachments; + private final MediaDatabase media; + private final ThreadDatabase thread; + private final MmsSmsDatabase mmsSmsDatabase; + private final IdentityDatabase identityDatabase; + private final DraftDatabase draftDatabase; + private final PushDatabase pushDatabase; + private final GroupDatabase groupDatabase; + private final RecipientDatabase recipientDatabase; + private final ContactsDatabase contactsDatabase; + private final GroupReceiptDatabase groupReceiptDatabase; + private final OneTimePreKeyDatabase preKeyDatabase; + private final SignedPreKeyDatabase signedPreKeyDatabase; + private final SessionDatabase sessionDatabase; + private final SearchDatabase searchDatabase; + private final StickerDatabase stickerDatabase; + private final StorageKeyDatabase storageKeyDatabase; + private final RemappedRecordsDatabase remappedRecordsDatabase; + private final MentionDatabase mentionDatabase; + + public static DatabaseFactory getInstance(Context context) { + if (instance == null) { + synchronized (lock) { + if (instance == null) { + instance = new DatabaseFactory(context.getApplicationContext()); + } + } + } + return instance; + } + + public static MmsSmsDatabase getMmsSmsDatabase(Context context) { + return getInstance(context).mmsSmsDatabase; + } + + public static ThreadDatabase getThreadDatabase(Context context) { + return getInstance(context).thread; + } + + public static MessageDatabase getSmsDatabase(Context context) { + return getInstance(context).sms; + } + + public static MessageDatabase getMmsDatabase(Context context) { + return getInstance(context).mms; + } + + public static AttachmentDatabase getAttachmentDatabase(Context context) { + return getInstance(context).attachments; + } + + public static MediaDatabase getMediaDatabase(Context context) { + return getInstance(context).media; + } + + public static IdentityDatabase getIdentityDatabase(Context context) { + return getInstance(context).identityDatabase; + } + + public static DraftDatabase getDraftDatabase(Context context) { + return getInstance(context).draftDatabase; + } + + /** + * @deprecated You probably shouldn't be using this anymore. It used to store encrypted envelopes, + * but now it's skipped over in favor of other mechanisms. It's only accessible to + * support old migrations and stuff. + */ + @Deprecated + public static PushDatabase getPushDatabase(Context context) { + return getInstance(context).pushDatabase; + } + + public static GroupDatabase getGroupDatabase(Context context) { + return getInstance(context).groupDatabase; + } + + public static RecipientDatabase getRecipientDatabase(Context context) { + return getInstance(context).recipientDatabase; + } + + public static ContactsDatabase getContactsDatabase(Context context) { + return getInstance(context).contactsDatabase; + } + + public static GroupReceiptDatabase getGroupReceiptDatabase(Context context) { + return getInstance(context).groupReceiptDatabase; + } + + public static OneTimePreKeyDatabase getPreKeyDatabase(Context context) { + return getInstance(context).preKeyDatabase; + } + + public static SignedPreKeyDatabase getSignedPreKeyDatabase(Context context) { + return getInstance(context).signedPreKeyDatabase; + } + + public static SessionDatabase getSessionDatabase(Context context) { + return getInstance(context).sessionDatabase; + } + + public static SearchDatabase getSearchDatabase(Context context) { + return getInstance(context).searchDatabase; + } + + public static StickerDatabase getStickerDatabase(Context context) { + return getInstance(context).stickerDatabase; + } + + public static StorageKeyDatabase getStorageKeyDatabase(Context context) { + return getInstance(context).storageKeyDatabase; + } + + static RemappedRecordsDatabase getRemappedRecordsDatabase(Context context) { + return getInstance(context).remappedRecordsDatabase; + } + + public static MentionDatabase getMentionDatabase(Context context) { + return getInstance(context).mentionDatabase; + } + + public static SQLiteDatabase getBackupDatabase(Context context) { + return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase(); + } + + public static void upgradeRestored(Context context, SQLiteDatabase database){ + synchronized (lock) { + getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1); + getInstance(context).databaseHelper.markCurrent(database); + getInstance(context).mms.trimEntriesForExpiredMessages(); + getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS key_value"); + getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS megaphone"); + getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS job_spec"); + getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS constraint_spec"); + getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS dependency_spec"); + + instance.databaseHelper.close(); + instance = null; + } + } + + public static boolean inTransaction(Context context) { + return getInstance(context).databaseHelper.getWritableDatabase().inTransaction(); + } + + private DatabaseFactory(@NonNull Context context) { + SQLiteDatabase.loadLibs(context); + + DatabaseSecret databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context); + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + + this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret); + this.sms = new SmsDatabase(context, databaseHelper); + this.mms = new MmsDatabase(context, databaseHelper); + this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret); + this.media = new MediaDatabase(context, databaseHelper); + this.thread = new ThreadDatabase(context, databaseHelper); + this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper); + this.identityDatabase = new IdentityDatabase(context, databaseHelper); + this.draftDatabase = new DraftDatabase(context, databaseHelper); + this.pushDatabase = new PushDatabase(context, databaseHelper); + this.groupDatabase = new GroupDatabase(context, databaseHelper); + this.recipientDatabase = new RecipientDatabase(context, databaseHelper); + this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper); + this.contactsDatabase = new ContactsDatabase(context); + this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper); + this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper); + this.sessionDatabase = new SessionDatabase(context, databaseHelper); + this.searchDatabase = new SearchDatabase(context, databaseHelper); + this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret); + this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper); + this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper); + this.mentionDatabase = new MentionDatabase(context, databaseHelper); + } + + public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, + int fromVersion, LegacyMigrationJob.DatabaseUpgradeListener listener) + { + databaseHelper.getWritableDatabase(); + + ClassicOpenHelper legacyOpenHelper = null; + + if (fromVersion < LegacyMigrationJob.ASYMMETRIC_MASTER_SECRET_FIX_VERSION) { + legacyOpenHelper = new ClassicOpenHelper(context); + legacyOpenHelper.onApplicationLevelUpgrade(context, masterSecret, fromVersion, listener); + } + + if (fromVersion < LegacyMigrationJob.SQLCIPHER && TextSecurePreferences.getNeedsSqlCipherMigration(context)) { + if (legacyOpenHelper == null) { + legacyOpenHelper = new ClassicOpenHelper(context); + } + + SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret, + legacyOpenHelper.getWritableDatabase(), + databaseHelper.getWritableDatabase().getSqlCipherDatabase(), + listener); + } + } + + public void triggerDatabaseAccess() { + databaseHelper.getWritableDatabase(); + } + + public SQLiteDatabase getRawDatabase() { + return databaseHelper.getWritableDatabase().getSqlCipherDatabase(); + } + + public boolean hasTable(String table) { + return SqlUtil.tableExists(databaseHelper.getReadableDatabase().getSqlCipherDatabase(), table); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java new file mode 100644 index 00000000..ac622873 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; +import android.database.ContentObserver; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; + +/** + * Allows listening to database changes to varying degrees of specificity. + * + * A replacement for the observer system in {@link Database}. We should move to this over time. + */ +public final class DatabaseObserver { + + private final Application application; + private final Executor executor; + + private final Set conversationListObservers; + private final Map> conversationObservers; + private final Map> verboseConversationObservers; + + public DatabaseObserver(Application application) { + this.application = application; + this.executor = new SerialExecutor(SignalExecutors.BOUNDED); + this.conversationListObservers = new HashSet<>(); + this.conversationObservers = new HashMap<>(); + this.verboseConversationObservers = new HashMap<>(); + } + + public void registerConversationListObserver(@NonNull Observer listener) { + executor.execute(() -> { + conversationListObservers.add(listener); + }); + } + + public void registerConversationObserver(long threadId, @NonNull Observer listener) { + executor.execute(() -> { + registerMapped(conversationObservers, threadId, listener); + }); + } + + public void registerVerboseConversationObserver(long threadId, @NonNull Observer listener) { + executor.execute(() -> { + registerMapped(verboseConversationObservers, threadId, listener); + }); + } + + public void unregisterObserver(@NonNull Observer listener) { + executor.execute(() -> { + conversationListObservers.remove(listener); + unregisterMapped(conversationObservers, listener); + unregisterMapped(verboseConversationObservers, listener); + }); + } + + public void notifyConversationListeners(Set threadIds) { + executor.execute(() -> { + for (long threadId : threadIds) { + notifyMapped(conversationObservers, threadId); + notifyMapped(verboseConversationObservers, threadId); + } + }); + + for (long threadId : threadIds) { + application.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null); + application.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId), null); + } + } + + public void notifyConversationListeners(long threadId) { + executor.execute(() -> { + notifyMapped(conversationObservers, threadId); + notifyMapped(verboseConversationObservers, threadId); + }); + + application.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null); + application.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId), null); + } + + public void notifyVerboseConversationListeners(long threadId) { + executor.execute(() -> { + notifyMapped(verboseConversationObservers, threadId); + }); + + application.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId), null); + } + + public void notifyConversationListListeners() { + executor.execute(() -> { + for (Observer listener : conversationListObservers) { + listener.onChanged(); + } + }); + + application.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); + } + + private void registerMapped(@NonNull Map> map, @NonNull K key, @NonNull Observer listener) { + Set listeners = map.get(key); + + if (listeners == null) { + listeners = new HashSet<>(); + } + + listeners.add(listener); + map.put(key, listeners); + } + + private void unregisterMapped(@NonNull Map> map, @NonNull Observer listener) { + for (Map.Entry> entry : map.entrySet()) { + entry.getValue().remove(listener); + } + } + + private static void notifyMapped(@NonNull Map> map, @NonNull K key) { + Set listeners = map.get(key); + + if (listeners != null) { + for (Observer listener : listeners) { + listener.onChanged(); + } + } + } + + public interface Observer { + /** + * Called when the relevant data changes. Executed on a serial executor, so don't do any + * long-running tasks! + */ + void onChanged(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java new file mode 100644 index 00000000..df9b5128 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; + +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public class DraftDatabase extends Database { + + static final String TABLE_NAME = "drafts"; + public static final String ID = "_id"; + public static final String THREAD_ID = "thread_id"; + public static final String DRAFT_TYPE = "type"; + public static final String DRAFT_VALUE = "value"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);"; + + public static final String[] CREATE_INDEXS = { + "CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", + }; + + public DraftDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void insertDrafts(long threadId, List drafts) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + for (Draft draft : drafts) { + ContentValues values = new ContentValues(3); + values.put(THREAD_ID, threadId); + values.put(DRAFT_TYPE, draft.getType()); + values.put(DRAFT_VALUE, draft.getValue()); + + db.insert(TABLE_NAME, null, values); + } + } + + public void clearDrafts(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); + } + + void clearDrafts(Set threadIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + StringBuilder where = new StringBuilder(); + List arguments = new LinkedList<>(); + + for (long threadId : threadIds) { + where.append(" OR ") + .append(THREAD_ID) + .append(" = ?"); + + arguments.add(String.valueOf(threadId)); + } + + db.delete(TABLE_NAME, where.toString().substring(4), arguments.toArray(new String[0])); + } + + void clearAllDrafts() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, null, null); + } + + public Drafts getDrafts(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Drafts results = new Drafts(); + + try (Cursor cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE)); + String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE)); + + results.add(new Draft(type, value)); + } + + return results; + } + } + + public static class Draft { + public static final String TEXT = "text"; + public static final String IMAGE = "image"; + public static final String VIDEO = "video"; + public static final String AUDIO = "audio"; + public static final String LOCATION = "location"; + public static final String QUOTE = "quote"; + public static final String MENTION = "mention"; + + private final String type; + private final String value; + + public Draft(String type, String value) { + this.type = type; + this.value = value; + } + + public String getType() { + return type; + } + + public String getValue() { + return value; + } + + String getSnippet(Context context) { + switch (type) { + case TEXT: return value; + case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet); + case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet); + case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet); + case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet); + case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet); + default: return null; + } + } + } + + public static class Drafts extends LinkedList { + public @Nullable Draft getDraftOfType(String type) { + for (Draft draft : this) { + if (type.equals(draft.getType())) { + return draft; + } + } + return null; + } + + public @NonNull String getSnippet(Context context) { + Draft textDraft = getDraftOfType(Draft.TEXT); + if (textDraft != null) { + return textDraft.getSnippet(context); + } else if (size() > 0) { + return get(0).getSnippet(context); + } else { + return ""; + } + } + + public @Nullable Uri getUriSnippet() { + Draft imageDraft = getDraftOfType(Draft.IMAGE); + + if (imageDraft != null && imageDraft.getValue() != null) { + return Uri.parse(imageDraft.getValue()); + } + + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java b/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java new file mode 100644 index 00000000..699e0045 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.database; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.LRUCache; + +import java.util.HashMap; +import java.util.Map; + +public class EarlyReceiptCache { + + private static final String TAG = EarlyReceiptCache.class.getSimpleName(); + + private final LRUCache> cache = new LRUCache<>(100); + private final String name; + + public EarlyReceiptCache(@NonNull String name) { + this.name = name; + } + + public synchronized void increment(long timestamp, @NonNull RecipientId origin) { + Map receipts = cache.get(timestamp); + + if (receipts == null) { + receipts = new HashMap<>(); + } + + Long count = receipts.get(origin); + + if (count != null) { + receipts.put(origin, ++count); + } else { + receipts.put(origin, 1L); + } + + cache.put(timestamp, receipts); + } + + public synchronized Map remove(long timestamp) { + Map receipts = cache.remove(timestamp); + return receipts != null ? receipts : new HashMap<>(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java new file mode 100644 index 00000000..94625378 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -0,0 +1,1173 @@ +package org.thoughtcrime.securesms.database; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.groups.GroupAccessControl; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.Closeable; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class GroupDatabase extends Database { + + private static final String TAG = Log.tag(GroupDatabase.class); + + static final String TABLE_NAME = "groups"; + private static final String ID = "_id"; + static final String GROUP_ID = "group_id"; + static final String RECIPIENT_ID = "recipient_id"; + private static final String TITLE = "title"; + static final String MEMBERS = "members"; + private static final String AVATAR_ID = "avatar_id"; + private static final String AVATAR_KEY = "avatar_key"; + private static final String AVATAR_CONTENT_TYPE = "avatar_content_type"; + private static final String AVATAR_RELAY = "avatar_relay"; + private static final String AVATAR_DIGEST = "avatar_digest"; + private static final String TIMESTAMP = "timestamp"; + static final String ACTIVE = "active"; + static final String MMS = "mms"; + private static final String EXPECTED_V2_ID = "expected_v2_id"; + private static final String UNMIGRATED_V1_MEMBERS = "former_v1_members"; + + + /* V2 Group columns */ + /** 32 bytes serialized {@link GroupMasterKey} */ + public static final String V2_MASTER_KEY = "master_key"; + /** Increments with every change to the group */ + private static final String V2_REVISION = "revision"; + /** Serialized {@link DecryptedGroup} protobuf */ + private static final String V2_DECRYPTED_GROUP = "decrypted_group"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + GROUP_ID + " TEXT, " + + RECIPIENT_ID + " INTEGER, " + + TITLE + " TEXT, " + + MEMBERS + " TEXT, " + + AVATAR_ID + " INTEGER, " + + AVATAR_KEY + " BLOB, " + + AVATAR_CONTENT_TYPE + " TEXT, " + + AVATAR_RELAY + " TEXT, " + + TIMESTAMP + " INTEGER, " + + ACTIVE + " INTEGER DEFAULT 1, " + + AVATAR_DIGEST + " BLOB, " + + MMS + " INTEGER DEFAULT 0, " + + V2_MASTER_KEY + " BLOB, " + + V2_REVISION + " BLOB, " + + V2_DECRYPTED_GROUP + " BLOB, " + + EXPECTED_V2_ID + " TEXT DEFAULT NULL, " + + UNMIGRATED_V1_MEMBERS + " TEXT DEFAULT NULL);"; + + public static final String[] CREATE_INDEXS = { + "CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");", + "CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", + "CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON " + TABLE_NAME + " (" + EXPECTED_V2_ID + ");" + }; + + private static final String[] GROUP_PROJECTION = { + GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, UNMIGRATED_V1_MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST, + TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP + }; + + static final List TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).map(columnName -> TABLE_NAME + "." + columnName).toList(); + + public GroupDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public Optional getGroup(RecipientId recipientId) { + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()}, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return getGroup(cursor); + } + + return Optional.absent(); + } + } + + public Optional getGroup(@NonNull GroupId groupId) { + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", + new String[] {groupId.toString()}, + null, null, null)) + { + if (cursor != null && cursor.moveToNext()) { + return getGroup(cursor); + } + + return Optional.absent(); + } + } + + public boolean groupExists(@NonNull GroupId groupId) { + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", + new String[] {groupId.toString()}, + null, null, null)) + { + return cursor.moveToNext(); + } + } + + /** + * @return A gv1 group whose expected v2 ID matches the one provided. + */ + public Optional getGroupV1ByExpectedV2(@NonNull GroupId.V2 gv2Id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + try (Cursor cursor = db.query(TABLE_NAME, GROUP_PROJECTION, EXPECTED_V2_ID + " = ?", SqlUtil.buildArgs(gv2Id), null, null, null)) { + if (cursor.moveToFirst()) { + return getGroup(cursor); + } else { + return Optional.absent(); + } + } + } + + /** + * Removes the specified members from the list of 'unmigrated V1 members' -- the list of members + * that were either dropped or had to be invited when migrating the group from V1->V2. + */ + public void removeUnmigratedV1Members(@NonNull GroupId.V2 id, @NonNull List toRemove) { + Optional group = getGroup(id); + + if (!group.isPresent()) { + Log.w(TAG, "Couldn't find the group!", new Throwable()); + return; + } + + List newUnmigrated = group.get().getUnmigratedV1Members(); + newUnmigrated.removeAll(toRemove); + + ContentValues values = new ContentValues(); + values.put(UNMIGRATED_V1_MEMBERS, newUnmigrated.isEmpty() ? null : RecipientId.toSerializedList(newUnmigrated)); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(id)); + + Recipient.live(Recipient.externalGroupExact(context, id).getId()).refresh(); + } + + Optional getGroup(Cursor cursor) { + Reader reader = new Reader(cursor); + return Optional.fromNullable(reader.getCurrent()); + } + + /** + * @return local db group revision or -1 if not present. + */ + public int getGroupV2Revision(@NonNull GroupId.V2 groupId) { + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", + new String[] {groupId.toString()}, + null, null, null)) + { + if (cursor != null && cursor.moveToNext()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(V2_REVISION)); + } + + return -1; + } + } + + /** + * Call if you are sure this group should exist. + *

+ * Finds group and throws if it cannot. + */ + public @NonNull GroupRecord requireGroup(@NonNull GroupId groupId) { + Optional group = getGroup(groupId); + + if (!group.isPresent()) { + throw new AssertionError("Group not found"); + } + + return group.get(); + } + + public boolean isUnknownGroup(@NonNull GroupId groupId) { + Optional group = getGroup(groupId); + + if (!group.isPresent()) { + return true; + } + + boolean noMetadata = !group.get().hasAvatar() && TextUtils.isEmpty(group.get().getTitle()); + boolean noMembers = group.get().getMembers().isEmpty() || (group.get().getMembers().size() == 1 && group.get().getMembers().contains(Recipient.self().getId())); + + return noMetadata && noMembers; + } + + public Reader getGroupsFilteredByTitle(String constraint, boolean includeInactive, boolean excludeV1) { + String query; + String[] queryArgs; + + if (includeInactive) { + query = TITLE + " LIKE ? AND (" + ACTIVE + " = ? OR " + RECIPIENT_ID + " IN (SELECT " + ThreadDatabase.RECIPIENT_ID + " FROM " + ThreadDatabase.TABLE_NAME + "))"; + queryArgs = new String[]{"%" + constraint + "%", "1"}; + } else { + query = TITLE + " LIKE ? AND " + ACTIVE + " = ?"; + queryArgs = new String[]{"%" + constraint + "%", "1"}; + } + + if (excludeV1) { + query += " AND " + EXPECTED_V2_ID + " IS NULL"; + } + + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, queryArgs, null, null, TITLE + " COLLATE NOCASE ASC"); + + return new Reader(cursor); + } + + public GroupId.Mms getOrCreateMmsGroupForMembers(List members) { + Collections.sort(members); + + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {GROUP_ID}, + MEMBERS + " = ? AND " + MMS + " = ?", + new String[] {RecipientId.toSerializedList(members), "1"}, + null, null, null); + try { + if (cursor != null && cursor.moveToNext()) { + return GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))) + .requireMms(); + } else { + GroupId.Mms groupId = GroupId.createMms(new SecureRandom()); + create(groupId, null, members); + return groupId; + } + } finally { + if (cursor != null) cursor.close(); + } + } + + @WorkerThread + public List getPushGroupNamesContainingMember(@NonNull RecipientId recipientId) { + return Stream.of(getPushGroupsContainingMember(recipientId)) + .map(groupRecord -> Recipient.resolved(groupRecord.getRecipientId()).getDisplayName(context)) + .toList(); + } + + @WorkerThread + public @NonNull List getPushGroupsContainingMember(@NonNull RecipientId recipientId) { + return getGroupsContainingMember(recipientId, true); + } + + public @NonNull List getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly) { + return getGroupsContainingMember(recipientId, pushOnly, false); + } + + @WorkerThread + public @NonNull List getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly, boolean includeInactive) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String table = TABLE_NAME + " INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID; + String query = MEMBERS + " LIKE ?"; + String[] args = SqlUtil.buildArgs("%" + recipientId.serialize() + "%"); + String orderBy = ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC"; + + if (pushOnly) { + query += " AND " + MMS + " = ?"; + args = SqlUtil.appendArg(args, "0"); + } + + if (!includeInactive) { + query += " AND " + ACTIVE + " = ?"; + args = SqlUtil.appendArg(args, "1"); + } + + List groups = new LinkedList<>(); + + try (Cursor cursor = database.query(table, null, query, args, null, null, orderBy)) { + while (cursor != null && cursor.moveToNext()) { + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); + + if (RecipientId.serializedListContains(serializedMembers, recipientId)) { + groups.add(new Reader(cursor).getCurrent()); + } + } + } + + return groups; + } + + public Reader getGroups() { + @SuppressLint("Recycle") + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); + return new Reader(cursor); + } + + public int getActiveGroupCount() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] cols = { "COUNT(*)" }; + String query = ACTIVE + " = 1"; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + @WorkerThread + public @NonNull List getGroupMembers(@NonNull GroupId groupId, @NonNull MemberSet memberSet) { + if (groupId.isV2()) { + return getGroup(groupId).transform(g -> g.requireV2GroupProperties().getMemberRecipients(memberSet)) + .or(Collections.emptyList()); + } else { + List currentMembers = getCurrentMembers(groupId); + List recipients = new ArrayList<>(currentMembers.size()); + + for (RecipientId member : currentMembers) { + Recipient resolved = Recipient.resolved(member); + if (memberSet.includeSelf || !resolved.isSelf()) { + recipients.add(resolved); + } + } + + return recipients; + } + } + + public void create(@NonNull GroupId.V1 groupId, + @Nullable String title, + @NonNull Collection members, + @Nullable SignalServiceAttachmentPointer avatar, + @Nullable String relay) + { + if (groupExists(groupId.deriveV2MigrationGroupId())) { + throw new LegacyGroupInsertException(groupId); + } + create(groupId, title, members, avatar, relay, null, null); + } + + public void create(@NonNull GroupId.Mms groupId, + @Nullable String title, + @NonNull Collection members) + { + create(groupId, Util.isEmpty(title) ? null : title, members, null, null, null, null); + } + + public GroupId.V2 create(@NonNull GroupMasterKey groupMasterKey, + @NonNull DecryptedGroup groupState) + { + GroupId.V2 groupId = GroupId.v2(groupMasterKey); + + if (getGroupV1ByExpectedV2(groupId).isPresent()) { + throw new MissedGroupMigrationInsertException(groupId); + } + + create(groupId, groupState.getTitle(), Collections.emptyList(), null, null, groupMasterKey, groupState); + + return groupId; + } + + /** + * @param groupMasterKey null for V1, must be non-null for V2 (presence dictates group version). + */ + private void create(@NonNull GroupId groupId, + @Nullable String title, + @NonNull Collection memberCollection, + @Nullable SignalServiceAttachmentPointer avatar, + @Nullable String relay, + @Nullable GroupMasterKey groupMasterKey, + @Nullable DecryptedGroup groupState) + { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + List members = new ArrayList<>(new HashSet<>(memberCollection)); + + Collections.sort(members); + + ContentValues contentValues = new ContentValues(); + contentValues.put(RECIPIENT_ID, groupRecipientId.serialize()); + contentValues.put(GROUP_ID, groupId.toString()); + contentValues.put(TITLE, title); + contentValues.put(MEMBERS, RecipientId.toSerializedList(members)); + + if (avatar != null) { + contentValues.put(AVATAR_ID, avatar.getRemoteId().getV2().get()); + contentValues.put(AVATAR_KEY, avatar.getKey()); + contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType()); + contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull()); + } else { + contentValues.put(AVATAR_ID, 0); + } + + contentValues.put(AVATAR_RELAY, relay); + contentValues.put(TIMESTAMP, System.currentTimeMillis()); + + if (groupId.isV2()) { + contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0); + } else if (groupId.isV1()) { + contentValues.put(ACTIVE, 1); + contentValues.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString()); + } else { + contentValues.put(ACTIVE, 1); + } + + contentValues.put(MMS, groupId.isMms()); + + if (groupMasterKey != null) { + if (groupState == null) { + throw new AssertionError("V2 master key but no group state"); + } + groupId.requireV2(); + contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize()); + contentValues.put(V2_REVISION, groupState.getRevision()); + contentValues.put(V2_DECRYPTED_GROUP, groupState.toByteArray()); + contentValues.put(MEMBERS, serializeV2GroupMembers(groupState)); + } else { + if (groupId.isV2()) { + throw new AssertionError("V2 group id but no master key"); + } + } + + databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); + + if (groupState != null && groupState.hasDisappearingMessagesTimer()) { + recipientDatabase.setExpireMessages(groupRecipientId, groupState.getDisappearingMessagesTimer().getDuration()); + } + + Recipient.live(groupRecipientId).refresh(); + + notifyConversationListListeners(); + } + + public void update(@NonNull GroupId.V1 groupId, + @Nullable String title, + @Nullable SignalServiceAttachmentPointer avatar) + { + ContentValues contentValues = new ContentValues(); + if (title != null) contentValues.put(TITLE, title); + + if (avatar != null) { + contentValues.put(AVATAR_ID, avatar.getRemoteId().getV2().get()); + contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType()); + contentValues.put(AVATAR_KEY, avatar.getKey()); + contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull()); + } else { + contentValues.put(AVATAR_ID, 0); + } + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, + GROUP_ID + " = ?", + new String[] {groupId.toString()}); + + RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient.live(groupRecipient).refresh(); + + notifyConversationListListeners(); + } + + /** + * Migrates a V1 group to a V2 group. + * + * @param decryptedGroup The state that represents the group on the server. This will be used to + * determine if we need to save our old membership list and stuff. + */ + public @NonNull GroupId.V2 migrateToV2(long threadId, + @NonNull GroupId.V1 groupIdV1, + @NonNull DecryptedGroup decryptedGroup) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + GroupId.V2 groupIdV2 = groupIdV1.deriveV2MigrationGroupId(); + GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); + + db.beginTransaction(); + try { + GroupRecord record = getGroup(groupIdV1).get(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(GROUP_ID, groupIdV2.toString()); + contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize()); + contentValues.putNull(EXPECTED_V2_ID); + + List newMembers = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(decryptedGroup.getMembersList())); + List pendingMembers = uuidsToRecipientIds(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList())); + + newMembers.addAll(pendingMembers); + + List droppedMembers = new ArrayList<>(SetUtil.difference(record.getMembers(), newMembers)); + List unmigratedMembers = Util.concatenatedList(pendingMembers, droppedMembers); + + contentValues.put(UNMIGRATED_V1_MEMBERS, unmigratedMembers.isEmpty() ? null : RecipientId.toSerializedList(unmigratedMembers)); + + int updated = db.update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupIdV1.toString())); + + if (updated != 1) { + throw new AssertionError(); + } + + DatabaseFactory.getRecipientDatabase(context).updateGroupId(groupIdV1, groupIdV2); + + update(groupMasterKey, decryptedGroup); + + DatabaseFactory.getSmsDatabase(context).insertGroupV1MigrationEvents(record.getRecipientId(), + threadId, + new GroupMigrationMembershipChange(pendingMembers, droppedMembers)); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return groupIdV2; + } + + public void update(@NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup decryptedGroup) { + update(GroupId.v2(groupMasterKey), decryptedGroup); + } + + public void update(@NonNull GroupId.V2 groupId, @NonNull DecryptedGroup decryptedGroup) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + Optional existingGroup = getGroup(groupId); + String title = decryptedGroup.getTitle(); + ContentValues contentValues = new ContentValues(); + + if (existingGroup.isPresent() && existingGroup.get().getUnmigratedV1Members().size() > 0 && existingGroup.get().isV2Group()) { + Set unmigratedV1Members = new HashSet<>(existingGroup.get().getUnmigratedV1Members()); + + DecryptedGroupChange change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().getDecryptedGroup(), decryptedGroup); + + List addedMembers = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(change.getNewMembersList())); + List removedMembers = uuidsToRecipientIds(DecryptedGroupUtil.removedMembersUuidList(change)); + List addedInvites = uuidsToRecipientIds(DecryptedGroupUtil.pendingToUuidList(change.getNewPendingMembersList())); + List removedInvites = uuidsToRecipientIds(DecryptedGroupUtil.removedPendingMembersUuidList(change)); + List acceptedInvites = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(change.getPromotePendingMembersList())); + + unmigratedV1Members.removeAll(addedMembers); + unmigratedV1Members.removeAll(removedMembers); + unmigratedV1Members.removeAll(addedInvites); + unmigratedV1Members.removeAll(removedInvites); + unmigratedV1Members.removeAll(acceptedInvites); + + contentValues.put(UNMIGRATED_V1_MEMBERS, unmigratedV1Members.isEmpty() ? null : RecipientId.toSerializedList(unmigratedV1Members)); + } + + contentValues.put(TITLE, title); + contentValues.put(V2_REVISION, decryptedGroup.getRevision()); + contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray()); + contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup)); + contentValues.put(ACTIVE, gv2GroupActive(decryptedGroup) ? 1 : 0); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, + GROUP_ID + " = ?", + new String[]{ groupId.toString() }); + + if (decryptedGroup.hasDisappearingMessagesTimer()) { + recipientDatabase.setExpireMessages(groupRecipientId, decryptedGroup.getDisappearingMessagesTimer().getDuration()); + } + + Recipient.live(groupRecipientId).refresh(); + + notifyConversationListListeners(); + } + + public void updateTitle(@NonNull GroupId.V1 groupId, String title) { + updateTitle((GroupId) groupId, title); + } + + public void updateTitle(@NonNull GroupId.Mms groupId, @Nullable String title) { + updateTitle((GroupId) groupId, Util.isEmpty(title) ? null : title); + } + + private void updateTitle(@NonNull GroupId groupId, String title) { + if (!groupId.isV1() && !groupId.isMms()) { + throw new AssertionError(); + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(TITLE, title); + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", + new String[] {groupId.toString()}); + + RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient.live(groupRecipient).refresh(); + } + + /** + * Used to bust the Glide cache when an avatar changes. + */ + public void onAvatarUpdated(@NonNull GroupId groupId, boolean hasAvatar) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(AVATAR_ID, hasAvatar ? Math.abs(new SecureRandom().nextLong()) : 0); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", + new String[] {groupId.toString()}); + + RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient.live(groupRecipient).refresh(); + } + + public void updateMembers(@NonNull GroupId groupId, List members) { + Collections.sort(members); + + ContentValues contents = new ContentValues(); + contents.put(MEMBERS, RecipientId.toSerializedList(members)); + contents.put(ACTIVE, 1); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + new String[] {groupId.toString()}); + + RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient.live(groupRecipient).refresh(); + } + + public void remove(@NonNull GroupId groupId, RecipientId source) { + List currentMembers = getCurrentMembers(groupId); + currentMembers.remove(source); + + ContentValues contents = new ContentValues(); + contents.put(MEMBERS, RecipientId.toSerializedList(currentMembers)); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + new String[] {groupId.toString()}); + + RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient.live(groupRecipient).refresh(); + } + + private static boolean gv2GroupActive(@NonNull DecryptedGroup decryptedGroup) { + UUID uuid = Recipient.self().getUuid().get(); + + return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() || + DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent(); + } + + private List getCurrentMembers(@NonNull GroupId groupId) { + Cursor cursor = null; + + try { + cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {MEMBERS}, + GROUP_ID + " = ?", + new String[] {groupId.toString()}, + null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); + return RecipientId.fromSerializedList(serializedMembers); + } + + return new LinkedList<>(); + } finally { + if (cursor != null) + cursor.close(); + } + } + + public boolean isActive(@NonNull GroupId groupId) { + Optional record = getGroup(groupId); + return record.isPresent() && record.get().isActive(); + } + + public void setActive(@NonNull GroupId groupId, boolean active) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + values.put(ACTIVE, active ? 1 : 0); + database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId.toString()}); + } + + @WorkerThread + public boolean isCurrentMember(@NonNull GroupId.Push groupId, @NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, new String[] {MEMBERS}, + GROUP_ID + " = ?", new String[] {groupId.toString()}, + null, null, null)) + { + if (cursor.moveToNext()) { + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); + return RecipientId.serializedListContains(serializedMembers, recipientId); + } else { + return false; + } + } + } + + + private static List uuidsToRecipientIds(@NonNull List uuids) { + List groupMembers = new ArrayList<>(uuids.size()); + + for (UUID uuid : uuids) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + Log.w(TAG, "Seen unknown UUID in members list"); + } else { + groupMembers.add(RecipientId.from(uuid, null)); + } + } + + Collections.sort(groupMembers); + + return groupMembers; + } + + private static String serializeV2GroupMembers(@NonNull DecryptedGroup decryptedGroup) { + List uuids = DecryptedGroupUtil.membersToUuidList(decryptedGroup.getMembersList()); + List recipientIds = uuidsToRecipientIds(uuids); + + return RecipientId.toSerializedList(recipientIds); + } + + public @NonNull List getAllGroupV2Ids() { + List result = new LinkedList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{ GROUP_ID }, null, null, null, null, null)) { + while (cursor.moveToNext()) { + GroupId groupId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))); + if (groupId.isV2()) { + result.add(groupId.requireV2()); + } + } + } + + return result; + } + + /** + * Key: The 'expected' V2 ID (i.e. what a V1 ID would map to when migrated) + * Value: The matching V1 ID + */ + public @NonNull Map getAllExpectedV2Ids() { + Map result = new HashMap<>(); + + String[] projection = new String[]{ GROUP_ID, EXPECTED_V2_ID }; + String query = EXPECTED_V2_ID + " NOT NULL"; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, projection, query, null, null, null, null)) { + while (cursor.moveToNext()) { + GroupId.V1 groupId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))).requireV1(); + GroupId.V2 expectedId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(EXPECTED_V2_ID))).requireV2(); + + result.put(expectedId, groupId); + } + } + + return result; + } + + public static class Reader implements Closeable { + + private final Cursor cursor; + + public Reader(Cursor cursor) { + this.cursor = cursor; + } + + public @Nullable GroupRecord getNext() { + if (cursor == null || !cursor.moveToNext()) { + return null; + } + + return getCurrent(); + } + + public @Nullable GroupRecord getCurrent() { + if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null || cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)) == 0) { + return null; + } + + return new GroupRecord(GroupId.parseOrThrow(CursorUtil.requireString(cursor, GROUP_ID)), + RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), + CursorUtil.requireString(cursor, TITLE), + CursorUtil.requireString(cursor, MEMBERS), + CursorUtil.requireString(cursor, UNMIGRATED_V1_MEMBERS), + CursorUtil.requireLong(cursor, AVATAR_ID), + CursorUtil.requireBlob(cursor, AVATAR_KEY), + CursorUtil.requireString(cursor, AVATAR_CONTENT_TYPE), + CursorUtil.requireString(cursor, AVATAR_RELAY), + CursorUtil.requireBoolean(cursor, ACTIVE), + CursorUtil.requireBlob(cursor, AVATAR_DIGEST), + CursorUtil.requireBoolean(cursor, MMS), + CursorUtil.requireBlob(cursor, V2_MASTER_KEY), + CursorUtil.requireInt(cursor, V2_REVISION), + CursorUtil.requireBlob(cursor, V2_DECRYPTED_GROUP)); + } + + @Override + public void close() { + if (this.cursor != null) + this.cursor.close(); + } + } + + public static class GroupRecord { + + private final GroupId id; + private final RecipientId recipientId; + private final String title; + private final List members; + private final List unmigratedV1Members; + private final long avatarId; + private final byte[] avatarKey; + private final byte[] avatarDigest; + private final String avatarContentType; + private final String relay; + private final boolean active; + private final boolean mms; + @Nullable private final V2GroupProperties v2GroupProperties; + + public GroupRecord(@NonNull GroupId id, + @NonNull RecipientId recipientId, + String title, + String members, + @Nullable String unmigratedV1Members, + long avatarId, + byte[] avatarKey, + String avatarContentType, + String relay, + boolean active, + byte[] avatarDigest, + boolean mms, + @Nullable byte[] groupMasterKeyBytes, + int groupRevision, + @Nullable byte[] decryptedGroupBytes) + { + this.id = id; + this.recipientId = recipientId; + this.title = title; + this.avatarId = avatarId; + this.avatarKey = avatarKey; + this.avatarDigest = avatarDigest; + this.avatarContentType = avatarContentType; + this.relay = relay; + this.active = active; + this.mms = mms; + + V2GroupProperties v2GroupProperties = null; + if (groupMasterKeyBytes != null && decryptedGroupBytes != null) { + GroupMasterKey groupMasterKey; + try { + groupMasterKey = new GroupMasterKey(groupMasterKeyBytes); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + v2GroupProperties = new V2GroupProperties(groupMasterKey, groupRevision, decryptedGroupBytes); + } + this.v2GroupProperties = v2GroupProperties; + + if (!TextUtils.isEmpty(members)) { + this.members = RecipientId.fromSerializedList(members); + } else { + this.members = Collections.emptyList(); + } + + if (!TextUtils.isEmpty(unmigratedV1Members)) { + this.unmigratedV1Members = RecipientId.fromSerializedList(unmigratedV1Members); + } else { + this.unmigratedV1Members = Collections.emptyList(); + } + } + + public GroupId getId() { + return id; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public String getTitle() { + return title; + } + + public @NonNull List getMembers() { + return members; + } + + /** V1 members that were lost during the V1->V2 migration */ + public @NonNull List getUnmigratedV1Members() { + return unmigratedV1Members; + } + + public boolean hasAvatar() { + return avatarId != 0; + } + + public long getAvatarId() { + return avatarId; + } + + public byte[] getAvatarKey() { + return avatarKey; + } + + public byte[] getAvatarDigest() { + return avatarDigest; + } + + public String getAvatarContentType() { + return avatarContentType; + } + + public String getRelay() { + return relay; + } + + public boolean isActive() { + return active; + } + + public boolean isMms() { + return mms; + } + + public boolean isV1Group() { + return !mms && !isV2Group(); + } + + public boolean isV2Group() { + return v2GroupProperties != null; + } + + public @NonNull V2GroupProperties requireV2GroupProperties() { + if (v2GroupProperties == null) { + throw new AssertionError(); + } + + return v2GroupProperties; + } + + public boolean isAdmin(@NonNull Recipient recipient) { + return isV2Group() && requireV2GroupProperties().isAdmin(recipient); + } + + public MemberLevel memberLevel(@NonNull Recipient recipient) { + if (isV2Group()) { + return requireV2GroupProperties().memberLevel(recipient); + } else if (isMms() && recipient.isSelf()) { + return MemberLevel.FULL_MEMBER; + } else { + return members.contains(recipient.getId()) ? MemberLevel.FULL_MEMBER + : MemberLevel.NOT_A_MEMBER; + } + } + + /** + * Who is allowed to add to the membership of this group. + */ + public GroupAccessControl getMembershipAdditionAccessControl() { + if (isV2Group()) { + if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getMembers() == AccessControl.AccessRequired.MEMBER) { + return GroupAccessControl.ALL_MEMBERS; + } + return GroupAccessControl.ONLY_ADMINS; + } else { + return id.isV1() ? GroupAccessControl.ALL_MEMBERS : GroupAccessControl.ONLY_ADMINS; + } + } + + /** + * Who is allowed to modify the attributes of this group, name/avatar/timer etc. + */ + public GroupAccessControl getAttributesAccessControl() { + if (isV2Group()) { + if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getAttributes() == AccessControl.AccessRequired.MEMBER) { + return GroupAccessControl.ALL_MEMBERS; + } + return GroupAccessControl.ONLY_ADMINS; + } else { + return GroupAccessControl.ALL_MEMBERS; + } + } + + /** + * Whether or not the recipient is a pending member. + */ + public boolean isPendingMember(@NonNull Recipient recipient) { + if (isV2Group()) { + Optional uuid = recipient.getUuid(); + if (uuid.isPresent()) { + return DecryptedGroupUtil.findPendingByUuid(requireV2GroupProperties().getDecryptedGroup().getPendingMembersList(), uuid.get()) + .isPresent(); + } + } + return false; + } + } + + public static class V2GroupProperties { + + @NonNull private final GroupMasterKey groupMasterKey; + private final int groupRevision; + @NonNull private final byte[] decryptedGroupBytes; + private DecryptedGroup decryptedGroup; + + private V2GroupProperties(@NonNull GroupMasterKey groupMasterKey, int groupRevision, @NonNull byte[] decryptedGroup) { + this.groupMasterKey = groupMasterKey; + this.groupRevision = groupRevision; + this.decryptedGroupBytes = decryptedGroup; + } + + public @NonNull GroupMasterKey getGroupMasterKey() { + return groupMasterKey; + } + + public int getGroupRevision() { + return groupRevision; + } + + public @NonNull DecryptedGroup getDecryptedGroup() { + try { + if (decryptedGroup == null) { + decryptedGroup = DecryptedGroup.parseFrom(decryptedGroupBytes); + } + return decryptedGroup; + } catch (InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + } + + public boolean isAdmin(@NonNull Recipient recipient) { + Optional uuid = recipient.getUuid(); + + if (!uuid.isPresent()) { + return false; + } + + return DecryptedGroupUtil.findMemberByUuid(getDecryptedGroup().getMembersList(), uuid.get()) + .transform(t -> t.getRole() == Member.Role.ADMINISTRATOR) + .or(false); + } + + public MemberLevel memberLevel(@NonNull Recipient recipient) { + Optional uuid = recipient.getUuid(); + + if (!uuid.isPresent()) { + return MemberLevel.NOT_A_MEMBER; + } + + DecryptedGroup decryptedGroup = getDecryptedGroup(); + + return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid.get()) + .transform(member -> member.getRole() == Member.Role.ADMINISTRATOR + ? MemberLevel.ADMINISTRATOR + : MemberLevel.FULL_MEMBER) + .or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid.get()) + .transform(m -> MemberLevel.PENDING_MEMBER) + .or(() -> DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.getRequestingMembersList(), uuid.get()) + .transform(m -> MemberLevel.REQUESTING_MEMBER) + .or(MemberLevel.NOT_A_MEMBER))); + } + + public List getMemberRecipients(@NonNull MemberSet memberSet) { + return Recipient.resolvedList(getMemberRecipientIds(memberSet)); + } + + public List getMemberRecipientIds(@NonNull MemberSet memberSet) { + boolean includeSelf = memberSet.includeSelf; + DecryptedGroup groupV2 = getDecryptedGroup(); + UUID selfUuid = Recipient.self().getUuid().get(); + List recipients = new ArrayList<>(groupV2.getMembersCount() + groupV2.getPendingMembersCount()); + int unknownMembers = 0; + int unknownPending = 0; + + for (UUID uuid : DecryptedGroupUtil.toUuidList(groupV2.getMembersList())) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + unknownMembers++; + } else if (includeSelf || !selfUuid.equals(uuid)) { + recipients.add(RecipientId.from(uuid, null)); + } + } + if (memberSet.includePending) { + for (UUID uuid : DecryptedGroupUtil.pendingToUuidList(groupV2.getPendingMembersList())) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + unknownPending++; + } else if (includeSelf || !selfUuid.equals(uuid)) { + recipients.add(RecipientId.from(uuid, null)); + } + } + } + + if ((unknownMembers + unknownPending) > 0) { + Log.w(TAG, String.format(Locale.US, "Group contains %d + %d unknown pending and full members", unknownPending, unknownMembers)); + } + + return recipients; + } + } + + public enum MemberSet { + FULL_MEMBERS_INCLUDING_SELF(true, false), + FULL_MEMBERS_EXCLUDING_SELF(false, false), + FULL_MEMBERS_AND_PENDING_INCLUDING_SELF(true, true), + FULL_MEMBERS_AND_PENDING_EXCLUDING_SELF(false, true); + + private final boolean includeSelf; + private final boolean includePending; + + MemberSet(boolean includeSelf, boolean includePending) { + this.includeSelf = includeSelf; + this.includePending = includePending; + } + } + + public enum MemberLevel { + NOT_A_MEMBER(false), + PENDING_MEMBER(false), + REQUESTING_MEMBER(false), + FULL_MEMBER(true), + ADMINISTRATOR(true); + + private final boolean inGroup; + + MemberLevel(boolean inGroup){ + this.inGroup = inGroup; + } + + public boolean isInGroup() { + return inGroup; + } + } + + public static class LegacyGroupInsertException extends IllegalStateException { + public LegacyGroupInsertException(@Nullable GroupId id) { + super("Tried to create a new GV1 entry when we already had a migrated GV2! " + id); + } + } + + public static class MissedGroupMigrationInsertException extends IllegalStateException { + public MissedGroupMigrationInsertException(@Nullable GroupId id) { + super("Tried to create a new GV2 entry when we already had a V1 group that mapped to the new ID! " + id); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java new file mode 100644 index 00000000..4ec34b0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.database; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.Pair; + +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +public class GroupReceiptDatabase extends Database { + + public static final String TABLE_NAME = "group_receipts"; + + private static final String ID = "_id"; + public static final String MMS_ID = "mms_id"; + static final String RECIPIENT_ID = "address"; + private static final String STATUS = "status"; + private static final String TIMESTAMP = "timestamp"; + private static final String UNIDENTIFIED = "unidentified"; + + public static final int STATUS_UNKNOWN = -1; + public static final int STATUS_UNDELIVERED = 0; + public static final int STATUS_DELIVERED = 1; + public static final int STATUS_READ = 2; + public static final int STATUS_VIEWED = 3; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + MMS_ID + " INTEGER, " + RECIPIENT_ID + " INTEGER, " + STATUS + " INTEGER, " + TIMESTAMP + " INTEGER, " + UNIDENTIFIED + " INTEGER DEFAULT 0);"; + + public static final String[] CREATE_INDEXES = { + "CREATE INDEX IF NOT EXISTS group_receipt_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");", + }; + + public GroupReceiptDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void insert(Collection recipientIds, long mmsId, int status, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (RecipientId recipientId : recipientIds) { + ContentValues values = new ContentValues(4); + values.put(MMS_ID, mmsId); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(STATUS, status); + values.put(TIMESTAMP, timestamp); + + db.insert(TABLE_NAME, null, values); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void update(@NonNull RecipientId recipientId, long mmsId, int status, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(2); + values.put(STATUS, status); + values.put(TIMESTAMP, timestamp); + + db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + RECIPIENT_ID + " = ? AND " + STATUS + " < ?", + new String[] {String.valueOf(mmsId), recipientId.serialize(), String.valueOf(status)}); + } + + public void setUnidentified(Collection> results, long mmsId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + String query = MMS_ID + " = ? AND " + RECIPIENT_ID + " = ?"; + + for (Pair result : results) { + ContentValues values = new ContentValues(1); + values.put(UNIDENTIFIED, result.second() ? 1 : 0); + + db.update(TABLE_NAME, values, query, new String[]{ String.valueOf(mmsId), result.first().serialize()}); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public @NonNull List getGroupReceiptInfo(long mmsId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = db.query(TABLE_NAME, null, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + results.add(new GroupReceiptInfo(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), + cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)), + cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)), + cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1)); + } + } + + return results; + } + + void deleteRowsForMessage(long mmsId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}); + } + + void deleteAbandonedRows() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, MMS_ID + " NOT IN (SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME + ")", null); + } + + void deleteAllRows() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, null, null); + } + + public static class GroupReceiptInfo { + private final RecipientId recipientId; + private final int status; + private final long timestamp; + private final boolean unidentified; + + GroupReceiptInfo(@NonNull RecipientId recipientId, int status, long timestamp, boolean unidentified) { + this.recipientId = recipientId; + this.status = status; + this.timestamp = timestamp; + this.unidentified = unidentified; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public int getStatus() { + return status; + } + + public long getTimestamp() { + return timestamp; + } + + public boolean isUnidentified() { + return unidentified; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java new file mode 100644 index 00000000..24f2daa6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.EventBus; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.identity.IdentityRecordList; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +public class IdentityDatabase extends Database { + + @SuppressWarnings("unused") + private static final String TAG = IdentityDatabase.class.getSimpleName(); + + static final String TABLE_NAME = "identities"; + private static final String ID = "_id"; + static final String RECIPIENT_ID = "address"; + static final String IDENTITY_KEY = "key"; + private static final String TIMESTAMP = "timestamp"; + private static final String FIRST_USE = "first_use"; + private static final String NONBLOCKING_APPROVAL = "nonblocking_approval"; + static final String VERIFIED = "verified"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + + " (" + ID + " INTEGER PRIMARY KEY, " + + RECIPIENT_ID + " INTEGER UNIQUE, " + + IDENTITY_KEY + " TEXT, " + + FIRST_USE + " INTEGER DEFAULT 0, " + + TIMESTAMP + " INTEGER DEFAULT 0, " + + VERIFIED + " INTEGER DEFAULT 0, " + + NONBLOCKING_APPROVAL + " INTEGER DEFAULT 0);"; + + public enum VerifiedStatus { + DEFAULT, VERIFIED, UNVERIFIED; + + public int toInt() { + if (this == DEFAULT) return 0; + else if (this == VERIFIED) return 1; + else if (this == UNVERIFIED) return 2; + else throw new AssertionError(); + } + + public static VerifiedStatus forState(int state) { + if (state == 0) return DEFAULT; + else if (state == 1) return VERIFIED; + else if (state == 2) return UNVERIFIED; + else throw new AssertionError("No such state: " + state); + } + } + + IdentityDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public Cursor getIdentities() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + return database.query(TABLE_NAME, null, null, null, null, null, null); + } + + public @Nullable IdentityReader readerFor(@Nullable Cursor cursor) { + if (cursor == null) return null; + return new IdentityReader(cursor); + } + + public Optional getIdentity(@NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, null, RECIPIENT_ID + " = ?", + new String[] {recipientId.serialize()}, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + return Optional.of(getIdentityRecord(cursor)); + } + } catch (InvalidKeyException | IOException e) { + throw new AssertionError(e); + } finally { + if (cursor != null) cursor.close(); + } + + return Optional.absent(); + } + + public @NonNull IdentityRecordList getIdentities(@NonNull List recipients) { + List records = new LinkedList<>(); + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String[] selectionArgs = new String[1]; + + database.beginTransaction(); + try { + for (Recipient recipient : recipients) { + selectionArgs[0] = recipient.getId().serialize(); + + try (Cursor cursor = database.query(TABLE_NAME, null, RECIPIENT_ID + " = ?", selectionArgs, null, null, null)) { + if (cursor.moveToFirst()) { + records.add(getIdentityRecord(cursor)); + } + } catch (InvalidKeyException | IOException e) { + throw new AssertionError(e); + } + } + } finally { + database.endTransaction(); + } + + return new IdentityRecordList(records); + } + + public void saveIdentity(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus, + boolean firstUse, long timestamp, boolean nonBlockingApproval) + { + saveIdentityInternal(recipientId, identityKey, verifiedStatus, firstUse, timestamp, nonBlockingApproval); + DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE); + } + + public void setApproval(@NonNull RecipientId recipientId, boolean nonBlockingApproval) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(2); + contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval); + + database.update(TABLE_NAME, contentValues, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()}); + + DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE); + } + + public void setVerified(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(1); + contentValues.put(VERIFIED, verifiedStatus.toInt()); + + int updated = database.update(TABLE_NAME, contentValues, RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ?", + new String[] {recipientId.serialize(), Base64.encodeBytes(identityKey.serialize())}); + + if (updated > 0) { + Optional record = getIdentity(recipientId); + if (record.isPresent()) EventBus.getDefault().post(record.get()); + DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE); + } + } + + public void updateIdentityAfterSync(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) { + boolean hadEntry = getIdentity(id).isPresent(); + boolean keyMatches = hasMatchingKey(id, identityKey); + boolean statusMatches = keyMatches && hasMatchingStatus(id, identityKey, verifiedStatus); + + if (!keyMatches || !statusMatches) { + saveIdentityInternal(id, identityKey, verifiedStatus, !hadEntry, System.currentTimeMillis(), true); + Optional record = getIdentity(id); + if (record.isPresent()) EventBus.getDefault().post(record.get()); + } + + if (hadEntry && !keyMatches) { + IdentityUtil.markIdentityUpdate(context, id); + } + } + + private boolean hasMatchingKey(@NonNull RecipientId id, IdentityKey identityKey) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ?"; + String[] args = new String[]{id.serialize(), Base64.encodeBytes(identityKey.serialize())}; + + try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) { + return cursor != null && cursor.moveToFirst(); + } + } + + private boolean hasMatchingStatus(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ? AND " + VERIFIED + " = ?"; + String[] args = new String[]{id.serialize(), Base64.encodeBytes(identityKey.serialize()), String.valueOf(verifiedStatus.toInt())}; + + try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) { + return cursor != null && cursor.moveToFirst(); + } + } + + private static @NonNull IdentityRecord getIdentityRecord(@NonNull Cursor cursor) throws IOException, InvalidKeyException { + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)); + String serializedIdentity = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY)); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)); + int verifiedStatus = cursor.getInt(cursor.getColumnIndexOrThrow(VERIFIED)); + boolean nonblockingApproval = cursor.getInt(cursor.getColumnIndexOrThrow(NONBLOCKING_APPROVAL)) == 1; + boolean firstUse = cursor.getInt(cursor.getColumnIndexOrThrow(FIRST_USE)) == 1; + IdentityKey identity = new IdentityKey(Base64.decode(serializedIdentity), 0); + + return new IdentityRecord(RecipientId.from(recipientId), identity, VerifiedStatus.forState(verifiedStatus), firstUse, timestamp, nonblockingApproval); + } + + private void saveIdentityInternal(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus, + boolean firstUse, long timestamp, boolean nonBlockingApproval) + { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String identityKeyString = Base64.encodeBytes(identityKey.serialize()); + + ContentValues contentValues = new ContentValues(); + contentValues.put(RECIPIENT_ID, recipientId.serialize()); + contentValues.put(IDENTITY_KEY, identityKeyString); + contentValues.put(TIMESTAMP, timestamp); + contentValues.put(VERIFIED, verifiedStatus.toInt()); + contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval ? 1 : 0); + contentValues.put(FIRST_USE, firstUse ? 1 : 0); + + database.replace(TABLE_NAME, null, contentValues); + + EventBus.getDefault().post(new IdentityRecord(recipientId, identityKey, verifiedStatus, + firstUse, timestamp, nonBlockingApproval)); + } + + public static class IdentityRecord { + + private final RecipientId recipientId; + private final IdentityKey identitykey; + private final VerifiedStatus verifiedStatus; + private final boolean firstUse; + private final long timestamp; + private final boolean nonblockingApproval; + + private IdentityRecord(@NonNull RecipientId recipientId, + IdentityKey identitykey, VerifiedStatus verifiedStatus, + boolean firstUse, long timestamp, boolean nonblockingApproval) + { + this.recipientId = recipientId; + this.identitykey = identitykey; + this.verifiedStatus = verifiedStatus; + this.firstUse = firstUse; + this.timestamp = timestamp; + this.nonblockingApproval = nonblockingApproval; + } + + public RecipientId getRecipientId() { + return recipientId; + } + + public IdentityKey getIdentityKey() { + return identitykey; + } + + public long getTimestamp() { + return timestamp; + } + + public VerifiedStatus getVerifiedStatus() { + return verifiedStatus; + } + + public boolean isApprovedNonBlocking() { + return nonblockingApproval; + } + + public boolean isFirstUse() { + return firstUse; + } + + @Override + public @NonNull String toString() { + return "{recipientId: " + recipientId + ", identityKey: " + identitykey + ", verifiedStatus: " + verifiedStatus + ", firstUse: " + firstUse + "}"; + } + + } + + public class IdentityReader { + private final Cursor cursor; + + IdentityReader(@NonNull Cursor cursor) { + this.cursor = cursor; + } + + public @Nullable IdentityRecord getNext() { + if (cursor.moveToNext()) { + try { + return getIdentityRecord(cursor); + } catch (IOException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + return null; + } + + public void close() { + cursor.close(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java new file mode 100644 index 00000000..2202e6e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java @@ -0,0 +1,432 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import net.sqlcipher.database.SQLiteOpenHelper; +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; +import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.util.CursorUtil; + +import java.util.LinkedList; +import java.util.List; + +public class JobDatabase extends SQLiteOpenHelper implements SignalDatabase { + + private static final String TAG = Log.tag(JobDatabase.class); + + private static final int DATABASE_VERSION = 1; + private static final String DATABASE_NAME = "signal-jobmanager.db"; + + private static final class Jobs { + private static final String TABLE_NAME = "job_spec"; + private static final String ID = "_id"; + private static final String JOB_SPEC_ID = "job_spec_id"; + private static final String FACTORY_KEY = "factory_key"; + private static final String QUEUE_KEY = "queue_key"; + private static final String CREATE_TIME = "create_time"; + private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time"; + private static final String RUN_ATTEMPT = "run_attempt"; + private static final String MAX_ATTEMPTS = "max_attempts"; + private static final String LIFESPAN = "lifespan"; + private static final String SERIALIZED_DATA = "serialized_data"; + private static final String SERIALIZED_INPUT_DATA = "serialized_input_data"; + private static final String IS_RUNNING = "is_running"; + + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + JOB_SPEC_ID + " TEXT UNIQUE, " + + FACTORY_KEY + " TEXT, " + + QUEUE_KEY + " TEXT, " + + CREATE_TIME + " INTEGER, " + + NEXT_RUN_ATTEMPT_TIME + " INTEGER, " + + RUN_ATTEMPT + " INTEGER, " + + MAX_ATTEMPTS + " INTEGER, " + + LIFESPAN + " INTEGER, " + + SERIALIZED_DATA + " TEXT, " + + SERIALIZED_INPUT_DATA + " TEXT DEFAULT NULL, " + + IS_RUNNING + " INTEGER)"; + } + + private static final class Constraints { + private static final String TABLE_NAME = "constraint_spec"; + private static final String ID = "_id"; + private static final String JOB_SPEC_ID = "job_spec_id"; + private static final String FACTORY_KEY = "factory_key"; + + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + JOB_SPEC_ID + " TEXT, " + + FACTORY_KEY + " TEXT, " + + "UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))"; + } + + private static final class Dependencies { + private static final String TABLE_NAME = "dependency_spec"; + private static final String ID = "_id"; + private static final String JOB_SPEC_ID = "job_spec_id"; + private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id"; + + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + JOB_SPEC_ID + " TEXT, " + + DEPENDS_ON_JOB_SPEC_ID + " TEXT, " + + "UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))"; + } + + + private static volatile JobDatabase instance; + + private final Application application; + private final DatabaseSecret databaseSecret; + + public static @NonNull JobDatabase getInstance(@NonNull Application context) { + if (instance == null) { + synchronized (JobDatabase.class) { + if (instance == null) { + instance = new JobDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context)); + } + } + } + return instance; + } + + public JobDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) { + super(application, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook()); + + this.application = application; + this.databaseSecret = databaseSecret; + } + + @Override + public void onCreate(SQLiteDatabase db) { + Log.i(TAG, "onCreate()"); + + db.execSQL(Jobs.CREATE_TABLE); + db.execSQL(Constraints.CREATE_TABLE); + db.execSQL(Dependencies.CREATE_TABLE); + + if (DatabaseFactory.getInstance(application).hasTable("job_spec")) { + Log.i(TAG, "Found old job_spec table. Migrating data."); + migrateJobSpecsFromPreviousDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), db); + } + + if (DatabaseFactory.getInstance(application).hasTable("constraint_spec")) { + Log.i(TAG, "Found old constraint_spec table. Migrating data."); + migrateConstraintSpecsFromPreviousDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), db); + } + + if (DatabaseFactory.getInstance(application).hasTable("dependency_spec")) { + Log.i(TAG, "Found old dependency_spec table. Migrating data."); + migrateDependencySpecsFromPreviousDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), db); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.i(TAG, "onUpgrade(" + oldVersion + ", " + newVersion + ")"); + } + + @Override + public void onOpen(SQLiteDatabase db) { + Log.i(TAG, "onOpen()"); + + dropTableIfPresent("job_spec"); + dropTableIfPresent("constraint_spec"); + dropTableIfPresent("dependency_spec"); + } + + public synchronized void insertJobs(@NonNull List fullSpecs) { + if (Stream.of(fullSpecs).map(FullSpec::getJobSpec).allMatch(JobSpec::isMemoryOnly)) { + return; + } + + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransaction(); + + try { + for (FullSpec fullSpec : fullSpecs) { + insertJobSpec(db, fullSpec.getJobSpec()); + insertConstraintSpecs(db, fullSpec.getConstraintSpecs()); + insertDependencySpecs(db, fullSpec.getDependencySpecs()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public synchronized @NonNull List getAllJobSpecs() { + List jobs = new LinkedList<>(); + + try (Cursor cursor = getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) { + while (cursor != null && cursor.moveToNext()) { + jobs.add(jobSpecFromCursor(cursor)); + } + } + + return jobs; + } + + public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0); + + String query = Jobs.JOB_SPEC_ID + " = ?"; + String[] args = new String[]{ id }; + + getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args); + } + + public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime, @NonNull String serializedData) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0); + contentValues.put(Jobs.RUN_ATTEMPT, runAttempt); + contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime); + contentValues.put(Jobs.SERIALIZED_DATA, serializedData); + + String query = Jobs.JOB_SPEC_ID + " = ?"; + String[] args = new String[]{ id }; + + getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args); + } + + public synchronized void updateAllJobsToBePending() { + ContentValues contentValues = new ContentValues(); + contentValues.put(Jobs.IS_RUNNING, 0); + + getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null); + } + + public synchronized void updateJobs(@NonNull List jobs) { + if (Stream.of(jobs).allMatch(JobSpec::isMemoryOnly)) { + return; + } + + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransaction(); + + try { + Stream.of(jobs) + .filterNot(JobSpec::isMemoryOnly) + .forEach(job -> { + ContentValues values = new ContentValues(); + values.put(Jobs.JOB_SPEC_ID, job.getId()); + values.put(Jobs.FACTORY_KEY, job.getFactoryKey()); + values.put(Jobs.QUEUE_KEY, job.getQueueKey()); + values.put(Jobs.CREATE_TIME, job.getCreateTime()); + values.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime()); + values.put(Jobs.RUN_ATTEMPT, job.getRunAttempt()); + values.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts()); + values.put(Jobs.LIFESPAN, job.getLifespan()); + values.put(Jobs.SERIALIZED_DATA, job.getSerializedData()); + values.put(Jobs.SERIALIZED_INPUT_DATA, job.getSerializedInputData()); + values.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0); + + String query = Jobs.JOB_SPEC_ID + " = ?"; + String[] args = new String[]{ job.getId() }; + + db.update(Jobs.TABLE_NAME, values, query, args); + }); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public synchronized void deleteJobs(@NonNull List jobIds) { + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransaction(); + + try { + for (String jobId : jobIds) { + String[] arg = new String[]{jobId}; + + db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg); + db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg); + db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg); + db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public synchronized @NonNull List getAllConstraintSpecs() { + List constraints = new LinkedList<>(); + + try (Cursor cursor = getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + constraints.add(constraintSpecFromCursor(cursor)); + } + } + + return constraints; + } + + public synchronized @NonNull List getAllDependencySpecs() { + List dependencies = new LinkedList<>(); + + try (Cursor cursor = getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + dependencies.add(dependencySpecFromCursor(cursor)); + } + } + + return dependencies; + } + + private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) { + if (job.isMemoryOnly()) { + return; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(Jobs.JOB_SPEC_ID, job.getId()); + contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey()); + contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey()); + contentValues.put(Jobs.CREATE_TIME, job.getCreateTime()); + contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime()); + contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt()); + contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts()); + contentValues.put(Jobs.LIFESPAN, job.getLifespan()); + contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData()); + contentValues.put(Jobs.SERIALIZED_INPUT_DATA, job.getSerializedInputData()); + contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0); + + db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE); + } + + private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List constraints) { + Stream.of(constraints) + .filterNot(ConstraintSpec::isMemoryOnly) + .forEach(constraintSpec -> { + ContentValues contentValues = new ContentValues(); + contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId()); + contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey()); + db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE); + }); + } + + private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List dependencies) { + Stream.of(dependencies) + .filterNot(DependencySpec::isMemoryOnly) + .forEach(dependencySpec -> { + ContentValues contentValues = new ContentValues(); + contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId()); + contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId()); + db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE); + }); + } + + private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) { + return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)), + cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)), + cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)), + cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)), + cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)), + cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)), + cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)), + cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)), + cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_INPUT_DATA)), + cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1, + false); + } + + private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) { + return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY)), + false); + } + + private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) { + return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID)), + false); + } + + private @NonNull SQLiteDatabase getReadableDatabase() { + return getWritableDatabase(databaseSecret.asString()); + } + + private @NonNull SQLiteDatabase getWritableDatabase() { + return getWritableDatabase(databaseSecret.asString()); + } + + @Override + public @NonNull SQLiteDatabase getSqlCipherDatabase() { + return getWritableDatabase(); + } + + private void dropTableIfPresent(@NonNull String table) { + if (DatabaseFactory.getInstance(application).hasTable(table)) { + Log.i(TAG, "Dropping original " + table + " table from the main database."); + DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE " + table); + } + } + + private static void migrateJobSpecsFromPreviousDatabase(@NonNull SQLiteDatabase oldDb, @NonNull SQLiteDatabase newDb) { + try (Cursor cursor = oldDb.rawQuery("SELECT * FROM job_spec", null)) { + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + + values.put(Jobs.JOB_SPEC_ID, CursorUtil.requireString(cursor, "job_spec_id")); + values.put(Jobs.FACTORY_KEY, CursorUtil.requireString(cursor, "factory_key")); + values.put(Jobs.QUEUE_KEY, CursorUtil.requireString(cursor, "queue_key")); + values.put(Jobs.CREATE_TIME, CursorUtil.requireLong(cursor, "create_time")); + values.put(Jobs.NEXT_RUN_ATTEMPT_TIME, CursorUtil.requireLong(cursor, "next_run_attempt_time")); + values.put(Jobs.RUN_ATTEMPT, CursorUtil.requireInt(cursor, "run_attempt")); + values.put(Jobs.MAX_ATTEMPTS, CursorUtil.requireInt(cursor, "max_attempts")); + values.put(Jobs.LIFESPAN, CursorUtil.requireLong(cursor, "lifespan")); + values.put(Jobs.SERIALIZED_DATA, CursorUtil.requireString(cursor, "serialized_data")); + values.put(Jobs.SERIALIZED_INPUT_DATA, CursorUtil.requireString(cursor, "serialized_input_data")); + values.put(Jobs.IS_RUNNING, CursorUtil.requireInt(cursor, "is_running")); + + newDb.insert(Jobs.TABLE_NAME, null, values); + } + } + } + + private static void migrateConstraintSpecsFromPreviousDatabase(@NonNull SQLiteDatabase oldDb, @NonNull SQLiteDatabase newDb) { + try (Cursor cursor = oldDb.rawQuery("SELECT * FROM constraint_spec", null)) { + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + + values.put(Constraints.JOB_SPEC_ID, CursorUtil.requireString(cursor, "job_spec_id")); + values.put(Constraints.FACTORY_KEY, CursorUtil.requireString(cursor, "factory_key")); + + newDb.insert(Constraints.TABLE_NAME, null, values); + } + } + } + + private static void migrateDependencySpecsFromPreviousDatabase(@NonNull SQLiteDatabase oldDb, @NonNull SQLiteDatabase newDb) { + try (Cursor cursor = oldDb.rawQuery("SELECT * FROM dependency_spec", null)) { + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + + values.put(Dependencies.JOB_SPEC_ID, CursorUtil.requireString(cursor, "job_spec_id")); + values.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, CursorUtil.requireString(cursor, "depends_on_job_spec_id")); + + newDb.insert(Dependencies.TABLE_NAME, null, values); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java new file mode 100644 index 00000000..4931a9a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/KeyValueDatabase.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteDatabaseHook; +import net.sqlcipher.database.SQLiteOpenHelper; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SqlUtil; + +import java.util.Collection; +import java.util.Map; + +/** + * Persists data for the {@link org.thoughtcrime.securesms.keyvalue.KeyValueStore}. + * + * This is it's own separate physical database, so it cannot do joins or queries with any other + * tables. + */ +public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase { + + private static final String TAG = Log.tag(KeyValueDatabase.class); + + private static final int DATABASE_VERSION = 1; + private static final String DATABASE_NAME = "signal-key-value.db"; + + private static final String TABLE_NAME = "key_value"; + private static final String ID = "_id"; + private static final String KEY = "key"; + private static final String VALUE = "value"; + private static final String TYPE = "type"; + + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY + " TEXT UNIQUE, " + + VALUE + " TEXT, " + + TYPE + " INTEGER)"; + + private static volatile KeyValueDatabase instance; + + private final Application application; + private final DatabaseSecret databaseSecret; + + public static @NonNull KeyValueDatabase getInstance(@NonNull Application context) { + if (instance == null) { + synchronized (KeyValueDatabase.class) { + if (instance == null) { + instance = new KeyValueDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context)); + } + } + } + return instance; + } + + public KeyValueDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) { + super(application, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook()); + + this.application = application; + this.databaseSecret = databaseSecret; + } + + @Override + public void onCreate(SQLiteDatabase db) { + Log.i(TAG, "onCreate()"); + + db.execSQL(CREATE_TABLE); + + if (DatabaseFactory.getInstance(application).hasTable("key_value")) { + Log.i(TAG, "Found old key_value table. Migrating data."); + migrateDataFromPreviousDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), db); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.i(TAG, "onUpgrade(" + oldVersion + ", " + newVersion + ")"); + } + + @Override + public void onOpen(SQLiteDatabase db) { + Log.i(TAG, "onOpen()"); + + if (DatabaseFactory.getInstance(application).hasTable("key_value")) { + Log.i(TAG, "Dropping original key_value table from the main database."); + DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE key_value"); + } + } + + public @NonNull KeyValueDataSet getDataSet() { + KeyValueDataSet dataSet = new KeyValueDataSet(); + + try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)){ + while (cursor != null && cursor.moveToNext()) { + Type type = Type.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(TYPE))); + String key = cursor.getString(cursor.getColumnIndexOrThrow(KEY)); + + switch (type) { + case BLOB: + dataSet.putBlob(key, cursor.getBlob(cursor.getColumnIndexOrThrow(VALUE))); + break; + case BOOLEAN: + dataSet.putBoolean(key, cursor.getInt(cursor.getColumnIndexOrThrow(VALUE)) == 1); + break; + case FLOAT: + dataSet.putFloat(key, cursor.getFloat(cursor.getColumnIndexOrThrow(VALUE))); + break; + case INTEGER: + dataSet.putInteger(key, cursor.getInt(cursor.getColumnIndexOrThrow(VALUE))); + break; + case LONG: + dataSet.putLong(key, cursor.getLong(cursor.getColumnIndexOrThrow(VALUE))); + break; + case STRING: + dataSet.putString(key, cursor.getString(cursor.getColumnIndexOrThrow(VALUE))); + break; + } + } + } + + return dataSet; + } + + public void writeDataSet(@NonNull KeyValueDataSet dataSet, @NonNull Collection removes) { + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransaction(); + try { + for (Map.Entry entry : dataSet.getValues().entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + Class type = dataSet.getType(key); + + ContentValues contentValues = new ContentValues(3); + contentValues.put(KEY, key); + + if (type == byte[].class) { + contentValues.put(VALUE, (byte[]) value); + contentValues.put(TYPE, Type.BLOB.getId()); + } else if (type == Boolean.class) { + contentValues.put(VALUE, (boolean) value); + contentValues.put(TYPE, Type.BOOLEAN.getId()); + } else if (type == Float.class) { + contentValues.put(VALUE, (float) value); + contentValues.put(TYPE, Type.FLOAT.getId()); + } else if (type == Integer.class) { + contentValues.put(VALUE, (int) value); + contentValues.put(TYPE, Type.INTEGER.getId()); + } else if (type == Long.class) { + contentValues.put(VALUE, (long) value); + contentValues.put(TYPE, Type.LONG.getId()); + } else if (type == String.class) { + contentValues.put(VALUE, (String) value); + contentValues.put(TYPE, Type.STRING.getId()); + } else { + throw new AssertionError("Unknown type: " + type); + } + + db.insertWithOnConflict(TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_REPLACE); + } + + String deleteQuery = KEY + " = ?"; + for (String remove : removes) { + db.delete(TABLE_NAME, deleteQuery, new String[] { remove }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private @NonNull SQLiteDatabase getReadableDatabase() { + return getReadableDatabase(databaseSecret.asString()); + } + + private @NonNull SQLiteDatabase getWritableDatabase() { + return getWritableDatabase(databaseSecret.asString()); + } + + @Override + public @NonNull SQLiteDatabase getSqlCipherDatabase() { + return getWritableDatabase(); + } + + private static void migrateDataFromPreviousDatabase(@NonNull SQLiteDatabase oldDb, @NonNull SQLiteDatabase newDb) { + try (Cursor cursor = oldDb.rawQuery("SELECT * FROM key_value", null)) { + while (cursor.moveToNext()) { + int type = CursorUtil.requireInt(cursor, "type"); + ContentValues values = new ContentValues(); + values.put(KEY, CursorUtil.requireString(cursor, "key")); + values.put(TYPE, type); + + switch (type) { + case 0: + values.put(VALUE, CursorUtil.requireBlob(cursor, "value")); + break; + case 1: + values.put(VALUE, CursorUtil.requireBoolean(cursor, "value")); + break; + case 2: + values.put(VALUE, CursorUtil.requireFloat(cursor, "value")); + break; + case 3: + values.put(VALUE, CursorUtil.requireInt(cursor, "value")); + break; + case 4: + values.put(VALUE, CursorUtil.requireLong(cursor, "value")); + break; + case 5: + values.put(VALUE, CursorUtil.requireString(cursor, "value")); + break; + } + + newDb.insert(TABLE_NAME, null, values); + } + } + } + + private enum Type { + BLOB(0), BOOLEAN(1), FLOAT(2), INTEGER(3), LONG(4), STRING(5); + + final int id; + + Type(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static Type fromId(int id) { + return values()[id]; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java new file mode 100644 index 00000000..5baf43a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -0,0 +1,301 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.List; + +public class MediaDatabase extends Database { + + public static final int ALL_THREADS = -1; + private static final String THREAD_RECIPIENT_ID = "THREAD_RECIPIENT_ID"; + + private static final String BASE_MEDIA_QUERY = "SELECT " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " AS " + AttachmentDatabase.ROW_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CDN_NUMBER + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DIGEST + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BORDERLESS + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_EMOJI + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UPLOAD_TIMESTAMP + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SERVER + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.THREAD_ID + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.RECIPIENT_ID + ", " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " as " + THREAD_RECIPIENT_ID + " " + + "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME + + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + + "LEFT JOIN " + ThreadDatabase.TABLE_NAME + + " ON " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.THREAD_ID + " " + + "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID + + " FROM " + MmsDatabase.TABLE_NAME + + " WHERE " + MmsDatabase.THREAD_ID + " __EQUALITY__ ?) AND (%s) AND " + + MmsDatabase.VIEW_ONCE + " = 0 AND " + + AttachmentDatabase.DATA + " IS NOT NULL AND " + + "(" + AttachmentDatabase.QUOTE + " = 0 OR (" + AttachmentDatabase.QUOTE + " = 1 AND " + AttachmentDatabase.DATA_HASH + " IS NULL)) AND " + + AttachmentDatabase.STICKER_PACK_ID + " IS NULL "; + + private static final String UNIQUE_MEDIA_QUERY = "SELECT " + + "MAX(" + AttachmentDatabase.SIZE + ") as " + AttachmentDatabase.SIZE + ", " + + AttachmentDatabase.CONTENT_TYPE + " " + + "FROM " + AttachmentDatabase.TABLE_NAME + " " + + "WHERE " + AttachmentDatabase.STICKER_PACK_ID + " IS NULL " + + "GROUP BY " + AttachmentDatabase.DATA; + + private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'"); + private static final String AUDIO_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'audio/%'"); + private static final String ALL_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain'"); + private static final String DOCUMENT_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'image/%' AND " + + AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'video/%' AND " + + AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'audio/%' AND " + + AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain'"); + + MediaDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public @NonNull Cursor getGalleryMediaForThread(long threadId, @NonNull Sorting sorting) { + return getGalleryMediaForThread(threadId, sorting, false); + } + + public @NonNull Cursor getGalleryMediaForThread(long threadId, @NonNull Sorting sorting, boolean listenToAllThreads) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY)); + String[] args = {threadId + ""}; + Cursor cursor = database.rawQuery(query, args); + if (listenToAllThreads) { + setNotifyConversationListeners(cursor); + } else { + setNotifyConversationListeners(cursor, threadId); + } + return cursor; + } + + public @NonNull Cursor getDocumentMediaForThread(long threadId, @NonNull Sorting sorting) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = sorting.applyToQuery(applyEqualityOperator(threadId, DOCUMENT_MEDIA_QUERY)); + String[] args = {threadId + ""}; + Cursor cursor = database.rawQuery(query, args); + setNotifyConversationListeners(cursor, threadId); + return cursor; + } + + public @NonNull Cursor getAudioMediaForThread(long threadId, @NonNull Sorting sorting) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = sorting.applyToQuery(applyEqualityOperator(threadId, AUDIO_MEDIA_QUERY)); + String[] args = {threadId + ""}; + Cursor cursor = database.rawQuery(query, args); + setNotifyConversationListeners(cursor, threadId); + return cursor; + } + + public @NonNull Cursor getAllMediaForThread(long threadId, @NonNull Sorting sorting) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = sorting.applyToQuery(applyEqualityOperator(threadId, ALL_MEDIA_QUERY)); + String[] args = {threadId + ""}; + Cursor cursor = database.rawQuery(query, args); + setNotifyConversationListeners(cursor, threadId); + return cursor; + } + + private static String applyEqualityOperator(long threadId, String query) { + return query.replace("__EQUALITY__", threadId == ALL_THREADS ? "!=" : "="); + } + + public void subscribeToMediaChanges(@NonNull ContentObserver observer) { + registerAttachmentListeners(observer); + } + + public void unsubscribeToMediaChanges(@NonNull ContentObserver observer) { + context.getContentResolver().unregisterContentObserver(observer); + } + + public StorageBreakdown getStorageBreakdown() { + StorageBreakdown storageBreakdown = new StorageBreakdown(); + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.rawQuery(UNIQUE_MEDIA_QUERY, new String[0])) { + int sizeColumn = cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE); + int contentTypeColumn = cursor.getColumnIndexOrThrow(AttachmentDatabase.CONTENT_TYPE); + + while (cursor.moveToNext()) { + int size = cursor.getInt(sizeColumn); + String type = cursor.getString(contentTypeColumn); + + switch (MediaUtil.getSlideTypeFromContentType(type)) { + case GIF: + case IMAGE: + case MMS: + storageBreakdown.photoSize += size; + break; + case VIDEO: + storageBreakdown.videoSize += size; + break; + case AUDIO: + storageBreakdown.audioSize += size; + break; + case LONG_TEXT: + case DOCUMENT: + storageBreakdown.documentSize += size; + break; + default: + break; + } + } + } + + return storageBreakdown; + } + + public static class MediaRecord { + + private final DatabaseAttachment attachment; + private final RecipientId recipientId; + private final RecipientId threadRecipientId; + private final long threadId; + private final long date; + private final boolean outgoing; + + private MediaRecord(@Nullable DatabaseAttachment attachment, + @NonNull RecipientId recipientId, + @NonNull RecipientId threadRecipientId, + long threadId, + long date, + boolean outgoing) + { + this.attachment = attachment; + this.recipientId = recipientId; + this.threadRecipientId = threadRecipientId; + this.threadId = threadId; + this.date = date; + this.outgoing = outgoing; + } + + public static MediaRecord from(@NonNull Context context, @NonNull Cursor cursor) { + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + List attachments = attachmentDatabase.getAttachment(cursor); + RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID))); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); + boolean outgoing = MessageDatabase.Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX))); + + long date; + + if (MmsDatabase.Types.isPushType(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)))) { + date = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.DATE_SENT)); + } else { + date = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.DATE_RECEIVED)); + } + + RecipientId threadRecipient = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_RECIPIENT_ID))); + + return new MediaRecord(attachments != null && attachments.size() > 0 ? attachments.get(0) : null, + recipientId, + threadRecipient, + threadId, + date, + outgoing); + } + + public @Nullable DatabaseAttachment getAttachment() { + return attachment; + } + + public String getContentType() { + return attachment.getContentType(); + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public @NonNull RecipientId getThreadRecipientId() { + return threadRecipientId; + } + + public long getThreadId() { + return threadId; + } + + public long getDate() { + return date; + } + + public boolean isOutgoing() { + return outgoing; + } + } + + public enum Sorting { + Newest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"), + Oldest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " ASC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " ASC"), + Largest(AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC"); + + private final String postFix; + + Sorting(@NonNull String order) { + postFix = " ORDER BY " + order; + } + + private String applyToQuery(@NonNull String query) { + return query + postFix; + } + + public boolean isRelatedToFileSize() { + return this == Largest; + } + } + + public final static class StorageBreakdown { + private long photoSize; + private long videoSize; + private long audioSize; + private long documentSize; + + public long getPhotoSize() { + return photoSize; + } + + public long getVideoSize() { + return videoSize; + } + + public long getAudioSize() { + return audioSize; + } + + public long getDocumentSize() { + return documentSize; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java new file mode 100644 index 00000000..c00919ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java @@ -0,0 +1,220 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteOpenHelper; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.database.model.MegaphoneRecord; +import org.thoughtcrime.securesms.megaphone.Megaphones.Event; +import org.thoughtcrime.securesms.util.CursorUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * IMPORTANT: Writes should only be made through {@link org.thoughtcrime.securesms.megaphone.MegaphoneRepository}. + */ +public class MegaphoneDatabase extends SQLiteOpenHelper implements SignalDatabase { + + private static final String TAG = Log.tag(MegaphoneDatabase.class); + + private static final int DATABASE_VERSION = 1; + private static final String DATABASE_NAME = "signal-megaphone.db"; + + private static final String TABLE_NAME = "megaphone"; + private static final String ID = "_id"; + private static final String EVENT = "event"; + private static final String SEEN_COUNT = "seen_count"; + private static final String LAST_SEEN = "last_seen"; + private static final String FIRST_VISIBLE = "first_visible"; + private static final String FINISHED = "finished"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + EVENT + " TEXT UNIQUE, " + + SEEN_COUNT + " INTEGER, " + + LAST_SEEN + " INTEGER, " + + FIRST_VISIBLE + " INTEGER, " + + FINISHED + " INTEGER)"; + + private static volatile MegaphoneDatabase instance; + + private final Application application; + private final DatabaseSecret databaseSecret; + + public static @NonNull MegaphoneDatabase getInstance(@NonNull Application context) { + if (instance == null) { + synchronized (MegaphoneDatabase.class) { + if (instance == null) { + instance = new MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context)); + } + } + } + return instance; + } + + public MegaphoneDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) { + super(application, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook()); + + this.application = application; + this.databaseSecret = databaseSecret; + } + + @Override + public void onCreate(SQLiteDatabase db) { + Log.i(TAG, "onCreate()"); + + db.execSQL(CREATE_TABLE); + + if (DatabaseFactory.getInstance(application).hasTable("megaphone")) { + Log.i(TAG, "Found old megaphone table. Migrating data."); + migrateDataFromPreviousDatabase(DatabaseFactory.getInstance(application).getRawDatabase(), db); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.i(TAG, "onUpgrade(" + oldVersion + ", " + newVersion + ")"); + } + + @Override + public void onOpen(SQLiteDatabase db) { + Log.i(TAG, "onOpen()"); + + if (DatabaseFactory.getInstance(application).hasTable("megaphone")) { + Log.i(TAG, "Dropping original megaphone table from the main database."); + DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE megaphone"); + } + } + + public void insert(@NonNull Collection events) { + SQLiteDatabase db = getWritableDatabase(); + + db.beginTransaction(); + try { + for (Event event : events) { + ContentValues values = new ContentValues(); + values.put(EVENT, event.getKey()); + + db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public @NonNull List getAllAndDeleteMissing() { + SQLiteDatabase db = getWritableDatabase(); + List records = new ArrayList<>(); + + db.beginTransaction(); + try { + Set missingKeys = new HashSet<>(); + + try (Cursor cursor = db.query(TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String event = cursor.getString(cursor.getColumnIndexOrThrow(EVENT)); + int seenCount = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_COUNT)); + long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN)); + long firstVisible = cursor.getLong(cursor.getColumnIndexOrThrow(FIRST_VISIBLE)); + boolean finished = cursor.getInt(cursor.getColumnIndexOrThrow(FINISHED)) == 1; + + if (Event.hasKey(event)) { + records.add(new MegaphoneRecord(Event.fromKey(event), seenCount, lastSeen, firstVisible, finished)); + } else { + Log.w(TAG, "No in-app handing for event '" + event + "'! Deleting it from the database."); + missingKeys.add(event); + } + } + } + + for (String missing : missingKeys) { + String query = EVENT + " = ?"; + String[] args = new String[]{missing}; + + db.delete(TABLE_NAME, query, args); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return records; + } + + public void markFirstVisible(@NonNull Event event, long time) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + ContentValues values = new ContentValues(); + values.put(FIRST_VISIBLE, time); + + getWritableDatabase().update(TABLE_NAME, values, query, args); + } + + public void markSeen(@NonNull Event event, int seenCount, long lastSeen) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + ContentValues values = new ContentValues(); + values.put(SEEN_COUNT, seenCount); + values.put(LAST_SEEN, lastSeen); + + getWritableDatabase().update(TABLE_NAME, values, query, args); + } + + public void markFinished(@NonNull Event event) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + ContentValues values = new ContentValues(); + values.put(FINISHED, 1); + + getWritableDatabase().update(TABLE_NAME, values, query, args); + } + + public void delete(@NonNull Event event) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + getWritableDatabase().delete(TABLE_NAME, query, args); + } + + private @NonNull SQLiteDatabase getWritableDatabase() { + return getWritableDatabase(databaseSecret.asString()); + } + + @Override + public @NonNull SQLiteDatabase getSqlCipherDatabase() { + return getWritableDatabase(); + } + + private static void migrateDataFromPreviousDatabase(@NonNull SQLiteDatabase oldDb, @NonNull SQLiteDatabase newDb) { + try (Cursor cursor = oldDb.rawQuery("SELECT * FROM megaphone", null)) { + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + + values.put(EVENT, CursorUtil.requireString(cursor, "event")); + values.put(SEEN_COUNT, CursorUtil.requireInt(cursor, "seen_count")); + values.put(LAST_SEEN, CursorUtil.requireLong(cursor, "last_seen")); + values.put(FIRST_VISIBLE, CursorUtil.requireLong(cursor, "first_visible")); + values.put(FINISHED, CursorUtil.requireInt(cursor, "finished")); + + newDb.insert(TABLE_NAME, null, values); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java new file mode 100644 index 00000000..ca3d4e67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SqlUtil; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class MentionDatabase extends Database { + + static final String TABLE_NAME = "mention"; + + private static final String ID = "_id"; + static final String THREAD_ID = "thread_id"; + private static final String MESSAGE_ID = "message_id"; + static final String RECIPIENT_ID = "recipient_id"; + private static final String RANGE_START = "range_start"; + private static final String RANGE_LENGTH = "range_length"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + THREAD_ID + " INTEGER, " + + MESSAGE_ID + " INTEGER, " + + RECIPIENT_ID + " INTEGER, " + + RANGE_START + " INTEGER, " + + RANGE_LENGTH + " INTEGER)"; + + public static final String[] CREATE_INDEXES = new String[] { + "CREATE INDEX IF NOT EXISTS mention_message_id_index ON " + TABLE_NAME + " (" + MESSAGE_ID + ");", + "CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ", " + THREAD_ID + ");" + }; + + public MentionDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void insert(long threadId, long messageId, @NonNull Collection mentions) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Mention mention : mentions) { + ContentValues values = new ContentValues(); + values.put(THREAD_ID, threadId); + values.put(MESSAGE_ID, messageId); + values.put(RECIPIENT_ID, mention.getRecipientId().toLong()); + values.put(RANGE_START, mention.getStart()); + values.put(RANGE_LENGTH, mention.getLength()); + db.insert(TABLE_NAME, null, values); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public @NonNull List getMentionsForMessage(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List mentions = new LinkedList<>(); + + try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " = ?", SqlUtil.buildArgs(messageId), null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + mentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)), + CursorUtil.requireInt(cursor, RANGE_START), + CursorUtil.requireInt(cursor, RANGE_LENGTH))); + } + } + + return mentions; + } + + public @NonNull Map> getMentionsForMessages(@NonNull Collection messageIds) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String ids = TextUtils.join(",", messageIds); + + try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " IN (" + ids + ")", null, null, null, null)) { + return readMentions(cursor); + } + } + + public @NonNull Map> getMentionsContainingRecipients(@NonNull Collection recipientIds, long limit) { + return getMentionsContainingRecipients(recipientIds, -1, limit); + } + + public @NonNull Map> getMentionsContainingRecipients(@NonNull Collection recipientIds, long threadId, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String ids = TextUtils.join(",", Stream.of(recipientIds).map(RecipientId::serialize).toList()); + + String where = " WHERE " + RECIPIENT_ID + " IN (" + ids + ")"; + if (threadId != -1) { + where += " AND " + THREAD_ID + " = " + threadId; + } + + String subSelect = "SELECT DISTINCT " + MESSAGE_ID + + " FROM " + TABLE_NAME + + where + + " ORDER BY " + ID + " DESC" + + " LIMIT " + limit; + + String query = "SELECT *" + + " FROM " + TABLE_NAME + + " WHERE " + MESSAGE_ID + + " IN (" + subSelect + ")"; + + try (Cursor cursor = db.rawQuery(query, null)) { + return readMentions(cursor); + } + } + + void deleteMentionsForMessage(long messageId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = MESSAGE_ID + " = ?"; + + db.delete(TABLE_NAME, where, SqlUtil.buildArgs(messageId)); + } + + void deleteAbandonedMentions() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = MESSAGE_ID + " NOT IN (SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME + ") OR " + THREAD_ID + " NOT IN (SELECT " + ThreadDatabase.ID + " FROM " + ThreadDatabase.TABLE_NAME + ")"; + + db.delete(TABLE_NAME, where, null); + } + + void deleteAllMentions() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, null, null); + } + + private @NonNull Map> readMentions(@Nullable Cursor cursor) { + Map> mentions = new HashMap<>(); + while (cursor != null && cursor.moveToNext()) { + long messageId = CursorUtil.requireLong(cursor, MESSAGE_ID); + List messageMentions = mentions.get(messageId); + + if (messageMentions == null) { + messageMentions = new LinkedList<>(); + mentions.put(messageId, messageMentions); + } + + messageMentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)), + CursorUtil.requireInt(cursor, RANGE_START), + CursorUtil.requireInt(cursor, RANGE_LENGTH))); + } + return mentions; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java new file mode 100644 index 00000000..d93f25cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.text.SpannableStringBuilder; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; +import com.annimon.stream.function.Function; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; + +public final class MentionUtil { + + public static final char MENTION_STARTER = '@'; + static final String MENTION_PLACEHOLDER = "\uFFFC"; + + private MentionUtil() { } + + @WorkerThread + public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) { + return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context)); + } + + @WorkerThread + public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { + if (messageRecord.isMms()) { + List mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageRecord.getId()); + CharSequence updated = updateBodyAndMentionsWithDisplayNames(context, body, mentions).getBody(); + if (updated != null) { + return updated; + } + } + return body; + } + + @WorkerThread + public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull List mentions) { + return updateBodyAndMentionsWithDisplayNames(context, messageRecord.getDisplayBody(context), mentions); + } + + @WorkerThread + public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull CharSequence body, @NonNull List mentions) { + return update(body, mentions, m -> MENTION_STARTER + Recipient.resolved(m.getRecipientId()).getMentionDisplayName(context)); + } + + public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithPlaceholders(@Nullable CharSequence body, @NonNull List mentions) { + return update(body, mentions, m -> MENTION_PLACEHOLDER); + } + + private static @NonNull UpdatedBodyAndMentions update(@Nullable CharSequence body, @NonNull List mentions, @NonNull Function replacementTextGenerator) { + if (body == null || mentions.isEmpty()) { + return new UpdatedBodyAndMentions(body, mentions); + } + + SortedSet sortedMentions = new TreeSet<>(mentions); + SpannableStringBuilder updatedBody = new SpannableStringBuilder(); + List updatedMentions = new ArrayList<>(); + + int bodyIndex = 0; + + for (Mention mention : sortedMentions) { + updatedBody.append(body.subSequence(bodyIndex, mention.getStart())); + CharSequence replaceWith = replacementTextGenerator.apply(mention); + Mention updatedMention = new Mention(mention.getRecipientId(), updatedBody.length(), replaceWith.length()); + + updatedBody.append(replaceWith); + updatedMentions.add(updatedMention); + + bodyIndex = mention.getStart() + mention.getLength(); + } + + if (bodyIndex < body.length()) { + updatedBody.append(body.subSequence(bodyIndex, body.length())); + } + + return new UpdatedBodyAndMentions(updatedBody.toString(), updatedMentions); + } + + public static @Nullable BodyRangeList mentionsToBodyRangeList(@Nullable List mentions) { + if (mentions == null || mentions.isEmpty()) { + return null; + } + + BodyRangeList.Builder builder = BodyRangeList.newBuilder(); + + for (Mention mention : mentions) { + String uuid = Recipient.resolved(mention.getRecipientId()).requireUuid().toString(); + builder.addRanges(BodyRangeList.BodyRange.newBuilder() + .setMentionUuid(uuid) + .setStart(mention.getStart()) + .setLength(mention.getLength())); + } + + return builder.build(); + } + + public static @NonNull List bodyRangeListToMentions(@NonNull Context context, @Nullable byte[] data) { + if (data != null) { + try { + return Stream.of(BodyRangeList.parseFrom(data).getRangesList()) + .filter(bodyRange -> bodyRange.getAssociatedValueCase() == BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID) + .map(mention -> { + RecipientId id = Recipient.externalPush(context, UuidUtil.parseOrThrow(mention.getMentionUuid()), null, false).getId(); + return new Mention(id, mention.getStart(), mention.getLength()); + }) + .toList(); + } catch (InvalidProtocolBufferException e) { + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + + public static @NonNull String getMentionSettingDisplayValue(@NonNull Context context, @NonNull MentionSetting mentionSetting) { + switch (mentionSetting) { + case ALWAYS_NOTIFY: + return context.getString(R.string.GroupMentionSettingDialog_always_notify_me); + case DO_NOT_NOTIFY: + return context.getString(R.string.GroupMentionSettingDialog_dont_notify_me); + } + throw new IllegalArgumentException("Unknown mention setting: " + mentionSetting); + } + + public static class UpdatedBodyAndMentions { + @Nullable private final CharSequence body; + @NonNull private final List mentions; + + public UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List mentions) { + this.body = body; + this.mentions = mentions; + } + + public @Nullable CharSequence getBody() { + return body; + } + + public @NonNull List getMentions() { + return mentions; + } + + @Nullable String getBodyAsString() { + return body != null ? body.toString() : null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java new file mode 100644 index 00000000..78615109 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -0,0 +1,729 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; +import com.google.android.mms.pdu_alt.NotificationInd; +import com.google.protobuf.InvalidProtocolBufferException; + +import net.sqlcipher.database.SQLiteStatement; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.documents.Document; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.insights.InsightsConstants; +import org.thoughtcrime.securesms.mms.IncomingMediaMessage; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +public abstract class MessageDatabase extends Database implements MmsSmsColumns { + + private static final String TAG = MessageDatabase.class.getSimpleName(); + + public MessageDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + protected abstract String getTableName(); + protected abstract String getTypeField(); + protected abstract String getDateSentColumnName(); + protected abstract String getDateReceivedColumnName(); + + public abstract @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived); + public abstract long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier); + public abstract boolean isGroupQuitMessage(long messageId); + public abstract @Nullable Pair getOldestUnreadMentionDetails(long threadId); + public abstract int getUnreadMentionCount(long threadId); + public abstract long getThreadIdForMessage(long id); + public abstract int getMessageCountForThread(long threadId); + public abstract int getMessageCountForThread(long threadId, long beforeTime); + abstract int getMessageCountForThreadSummary(long threadId); + public abstract Optional getNotification(long messageId); + + public abstract Cursor getExpirationStartedMessages(); + public abstract SmsMessageRecord getSmsMessage(long messageId) throws NoSuchMessageException; + public abstract Reader getMessages(Collection messageIds); + public abstract Cursor getMessageCursor(long messageId); + public abstract OutgoingMediaMessage getOutgoingMessage(long messageId) throws MmsException, NoSuchMessageException; + public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; + public abstract Cursor getVerboseMessageCursor(long messageId); + public abstract boolean hasReceivedAnyCallsSince(long threadId, long timestamp); + public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage(); + public abstract boolean isSent(long messageId); + public abstract List getProfileChangeDetailsRecords(long threadId, long afterTimestamp); + + public abstract void markExpireStarted(long messageId); + public abstract void markExpireStarted(long messageId, long startTime); + public abstract void markExpireStarted(Collection messageId, long startTime); + + public abstract void markAsEndSession(long id); + public abstract void markAsPreKeyBundle(long id); + public abstract void markAsInvalidVersionKeyExchange(long id); + public abstract void markAsSecure(long id); + public abstract void markAsInsecure(long id); + public abstract void markAsPush(long id); + public abstract void markAsForcedSms(long id); + public abstract void markAsDecryptFailed(long id); + public abstract void markAsDecryptDuplicate(long id); + public abstract void markAsNoSession(long id); + public abstract void markAsUnsupportedProtocolVersion(long id); + public abstract void markAsInvalidMessage(long id); + public abstract void markAsLegacyVersion(long id); + public abstract void markAsOutbox(long id); + public abstract void markAsPendingInsecureSmsFallback(long id); + public abstract void markAsSent(long messageId, boolean secure); + public abstract void markAsSentFailed(long id); + public abstract void markUnidentified(long messageId, boolean unidentified); + public abstract void markAsSending(long messageId); + public abstract void markAsRemoteDelete(long messageId); + public abstract void markAsMissedCall(long id, boolean isVideoOffer); + public abstract void markAsNotified(long id); + public abstract void markSmsStatus(long id, int status); + public abstract void markDownloadState(long messageId, long state); + public abstract void markIncomingNotificationReceived(long threadId); + + public abstract boolean incrementReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType); + public abstract List> setTimestampRead(SyncMessageId messageId, long proposedExpireStarted); + public abstract List setEntireThreadRead(long threadId); + public abstract List setMessagesReadSince(long threadId, long timestamp); + public abstract List setAllMessagesRead(); + public abstract Pair updateBundleMessageBody(long messageId, String body); + public abstract @NonNull List getViewedIncomingMessages(long threadId); + public abstract @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId); + + public abstract void addFailures(long messageId, List failure); + public abstract void removeFailure(long messageId, NetworkFailure failure); + + public abstract @NonNull Pair insertReceivedCall(@NonNull RecipientId address, boolean isVideoOffer); + public abstract @NonNull Pair insertOutgoingCall(@NonNull RecipientId address, boolean isVideoOffer); + public abstract @NonNull Pair insertMissedCall(@NonNull RecipientId address, long timestamp, boolean isVideoOffer); + public abstract void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids, + boolean isCallFull); + public abstract void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId); + public abstract boolean updatePreviousGroupCall(long threadId, @Nullable String peekGroupCallEraId, @NonNull Collection peekJoinedUuids, boolean isCallFull); + + public abstract Optional insertMessageInbox(IncomingTextMessage message, long type); + public abstract Optional insertMessageInbox(IncomingTextMessage message); + public abstract Optional insertMessageInbox(IncomingMediaMessage retrieved, String contentLocation, long threadId) throws MmsException; + public abstract Pair insertMessageInbox(@NonNull NotificationInd notification, int subscriptionId); + public abstract Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) throws MmsException; + public abstract @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp); + public abstract long insertMessageOutbox(long threadId, OutgoingTextMessage message, boolean forceSms, long date, InsertListener insertListener); + public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException; + public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, int defaultReceiptStatus, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException; + public abstract void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName); + public abstract void insertGroupV1MigrationEvents(@NonNull RecipientId recipientId, long threadId, @NonNull GroupMigrationMembershipChange membershipChange); + + public abstract boolean deleteMessage(long messageId); + abstract void deleteThread(long threadId); + abstract void deleteMessagesInThreadBeforeDate(long threadId, long date); + abstract void deleteThreads(@NonNull Set threadIds); + abstract void deleteAllThreads(); + abstract void deleteAbandonedMessages(); + + public abstract List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit); + + public abstract SQLiteDatabase beginTransaction(); + public abstract void endTransaction(SQLiteDatabase database); + public abstract void setTransactionSuccessful(); + public abstract void endTransaction(); + public abstract SQLiteStatement createInsertStatement(SQLiteDatabase database); + + public abstract void ensureMigration(); + + final @NonNull String getOutgoingTypeClause() { + List segments = new ArrayList<>(Types.OUTGOING_MESSAGE_TYPES.length); + for (long outgoingMessageType : Types.OUTGOING_MESSAGE_TYPES) { + segments.add("(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + " = " + outgoingMessageType + ")"); + } + + return Util.join(segments, " OR "); + } + + final int getInsecureMessagesSentForThread(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[]{"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + getOutgoingInsecureMessageClause() + " AND " + getDateSentColumnName() + " > ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + + final int getInsecureMessageCountForInsights() { + return getMessageCountForRecipientsAndType(getOutgoingInsecureMessageClause()); + } + + final int getSecureMessageCountForInsights() { + return getMessageCountForRecipientsAndType(getOutgoingSecureMessageClause()); + } + + final int getSecureMessageCount(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[] {"COUNT(*)"}; + String query = getSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + + final int getOutgoingSecureMessageCount(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[] {"COUNT(*)"}; + String query = getOutgoingSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ? AND" + "(" + getTypeField() + " & " + Types.GROUP_QUIT_BIT + " = 0)"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + + private int getMessageCountForRecipientsAndType(String typeClause) { + + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[] {"COUNT(*)"}; + String query = typeClause + " AND " + getDateSentColumnName() + " > ?"; + String[] args = new String[]{String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + + private String getOutgoingInsecureMessageClause() { + return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + getTypeField() + " & " + Types.SECURE_MESSAGE_BIT + ")"; + } + + private String getOutgoingSecureMessageClause() { + return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; + } + + private String getSecureMessageClause() { + String isSent = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE; + String isReceived = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE; + String isSecure = "(" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; + + return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure); + } + + public void setReactionsSeen(long threadId, long sinceTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + String whereClause = THREAD_ID + " = ? AND " + REACTIONS_UNREAD + " = ?"; + String[] whereArgs = new String[]{String.valueOf(threadId), "1"}; + + if (sinceTimestamp > -1) { + whereClause += " AND " + getDateReceivedColumnName() + " <= " + sinceTimestamp; + } + + values.put(REACTIONS_UNREAD, 0); + values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + db.update(getTableName(), values, whereClause, whereArgs); + } + + public void setAllReactionsSeen() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + String query = REACTIONS_UNREAD + " != ?"; + String[] args = new String[] { "0" }; + + values.put(REACTIONS_UNREAD, 0); + values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + db.update(getTableName(), values, query, args); + } + + public void addReaction(long messageId, @NonNull ReactionRecord reaction) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + + try { + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + ReactionList.Reaction newReaction = ReactionList.Reaction.newBuilder() + .setEmoji(reaction.getEmoji()) + .setAuthor(reaction.getAuthor().toLong()) + .setSentTime(reaction.getDateSent()) + .setReceivedTime(reaction.getDateReceived()) + .build(); + + ReactionList updatedList = pruneByAuthor(reactions, reaction.getAuthor()).toBuilder() + .addReactions(newReaction) + .build(); + + setReactions(db, messageId, updatedList); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(getThreadId(db, messageId)); + } + + public void deleteReaction(long messageId, @NonNull RecipientId author) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + + try { + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + ReactionList updatedList = pruneByAuthor(reactions, author); + + setReactions(db, messageId, updatedList); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(getThreadId(db, messageId)); + } + + public boolean hasReaction(long messageId, @NonNull ReactionRecord reactionRecord) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + + for (ReactionList.Reaction reaction : reactions.getReactionsList()) { + if (reactionRecord.getAuthor().toLong() == reaction.getAuthor() && + reactionRecord.getEmoji().equals(reaction.getEmoji())) + { + return true; + } + } + + return false; + } + + public void setNotifiedTimestamp(long timestamp, @NonNull List ids) { + if (ids.isEmpty()) { + return; + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SqlUtil.Query where = SqlUtil.buildCollectionQuery(ID, ids); + ContentValues values = new ContentValues(); + + values.put(NOTIFIED_TIMESTAMP, timestamp); + + db.update(getTableName(), values, where.getWhere(), where.getWhereArgs()); + } + + public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) { + try { + addToDocument(messageId, MISMATCHED_IDENTITIES, + new IdentityKeyMismatch(recipientId, identityKey), + IdentityKeyMismatchList.class); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + public void removeMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) { + try { + removeFromDocument(messageId, MISMATCHED_IDENTITIES, + new IdentityKeyMismatch(recipientId, identityKey), + IdentityKeyMismatchList.class); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + protected static List parseReactions(@NonNull Cursor cursor) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS)); + + if (raw != null) { + try { + return Stream.of(ReactionList.parseFrom(raw).getReactionsList()) + .map(r -> { + return new ReactionRecord(r.getEmoji(), + RecipientId.from(r.getAuthor()), + r.getSentTime(), + r.getReceivedTime()); + }) + .toList(); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "[parseReactions] Failed to parse reaction list!", e); + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + + protected , I> void removeFromDocument(long messageId, String column, I object, Class clazz) throws IOException { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.beginTransaction(); + + try { + D document = getDocument(database, messageId, column, clazz); + Iterator iterator = document.getList().iterator(); + + while (iterator.hasNext()) { + I item = iterator.next(); + + if (item.equals(object)) { + iterator.remove(); + break; + } + } + + setDocument(database, messageId, column, document); + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + } + + protected , I> void addToDocument(long messageId, String column, final I object, Class clazz) throws IOException { + List list = new ArrayList() {{ + add(object); + }}; + + addToDocument(messageId, column, list, clazz); + } + + protected , I> void addToDocument(long messageId, String column, List objects, Class clazz) throws IOException { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.beginTransaction(); + + try { + T document = getDocument(database, messageId, column, clazz); + document.getList().addAll(objects); + setDocument(database, messageId, column, document); + + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + } + + private void setDocument(SQLiteDatabase database, long messageId, String column, Document document) throws IOException { + ContentValues contentValues = new ContentValues(); + + if (document == null || document.size() == 0) { + contentValues.put(column, (String)null); + } else { + contentValues.put(column, JsonUtils.toJson(document)); + } + + database.update(getTableName(), contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); + } + + private D getDocument(SQLiteDatabase database, long messageId, + String column, Class clazz) + { + Cursor cursor = null; + + try { + cursor = database.query(getTableName(), new String[] {column}, + ID_WHERE, new String[] {String.valueOf(messageId)}, + null, null, null); + + if (cursor != null && cursor.moveToNext()) { + String document = cursor.getString(cursor.getColumnIndexOrThrow(column)); + + try { + if (!TextUtils.isEmpty(document)) { + return JsonUtils.fromJson(document, clazz); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + + try { + return clazz.newInstance(); + } catch (InstantiationException e) { + throw new AssertionError(e); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + + } finally { + if (cursor != null) + cursor.close(); + } + } + + private static @NonNull ReactionList pruneByAuthor(@NonNull ReactionList reactionList, @NonNull RecipientId recipientId) { + List pruned = Stream.of(reactionList.getReactionsList()) + .filterNot(r -> r.getAuthor() == recipientId.toLong()) + .toList(); + + return reactionList.toBuilder() + .clearReactions() + .addAllReactions(pruned) + .build(); + } + + private @NonNull Optional getReactions(SQLiteDatabase db, long messageId) { + String[] projection = new String[]{ REACTIONS }; + String query = ID + " = ?"; + String[] args = new String[]{String.valueOf(messageId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS)); + + if (raw != null) { + return Optional.of(ReactionList.parseFrom(raw)); + } + } + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "[getRecipients] Failed to parse reaction list!", e); + } + + return Optional.absent(); + } + + private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) { + ContentValues values = new ContentValues(1); + boolean hasReactions = reactionList.getReactionsCount() != 0; + + values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray()); + values.put(REACTIONS_UNREAD, hasReactions ? 1 : 0); + + if (hasReactions) { + values.put(NOTIFIED, 0); + } + + String query = ID + " = ?"; + String[] args = new String[] { String.valueOf(messageId) }; + + db.update(getTableName(), values, query, args); + } + + private long getThreadId(@NonNull SQLiteDatabase db, long messageId) { + String[] projection = new String[]{ THREAD_ID }; + String query = ID + " = ?"; + String[] args = new String[]{ String.valueOf(messageId) }; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + } + } + + return -1; + } + + protected enum ReceiptType { + READ(READ_RECEIPT_COUNT, GroupReceiptDatabase.STATUS_READ), + DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptDatabase.STATUS_DELIVERED), + VIEWED(VIEWED_RECEIPT_COUNT, GroupReceiptDatabase.STATUS_VIEWED); + + private final String columnName; + private final int groupStatus; + + ReceiptType(String columnName, int groupStatus) { + this.columnName = columnName; + this.groupStatus = groupStatus; + } + + public String getColumnName() { + return columnName; + } + + public int getGroupStatus() { + return groupStatus; + } + } + + public static class SyncMessageId { + + private final RecipientId recipientId; + private final long timetamp; + + public SyncMessageId(@NonNull RecipientId recipientId, long timetamp) { + this.recipientId = recipientId; + this.timetamp = timetamp; + } + + public RecipientId getRecipientId() { + return recipientId; + } + + public long getTimetamp() { + return timetamp; + } + } + + public static class ExpirationInfo { + + private final long id; + private final long expiresIn; + private final long expireStarted; + private final boolean mms; + + public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) { + this.id = id; + this.expiresIn = expiresIn; + this.expireStarted = expireStarted; + this.mms = mms; + } + + public long getId() { + return id; + } + + public long getExpiresIn() { + return expiresIn; + } + + public long getExpireStarted() { + return expireStarted; + } + + public boolean isMms() { + return mms; + } + } + + public static class MarkedMessageInfo { + + private final long threadId; + private final SyncMessageId syncMessageId; + private final ExpirationInfo expirationInfo; + + public MarkedMessageInfo(long threadId, SyncMessageId syncMessageId, ExpirationInfo expirationInfo) { + this.threadId = threadId; + this.syncMessageId = syncMessageId; + this.expirationInfo = expirationInfo; + } + + public long getThreadId() { + return threadId; + } + + public SyncMessageId getSyncMessageId() { + return syncMessageId; + } + + public ExpirationInfo getExpirationInfo() { + return expirationInfo; + } + } + + public static class InsertResult { + private final long messageId; + private final long threadId; + + public InsertResult(long messageId, long threadId) { + this.messageId = messageId; + this.threadId = threadId; + } + + public long getMessageId() { + return messageId; + } + + public long getThreadId() { + return threadId; + } + } + + public static class MmsNotificationInfo { + private final RecipientId from; + private final String contentLocation; + private final String transactionId; + private final int subscriptionId; + + MmsNotificationInfo(@NonNull RecipientId from, String contentLocation, String transactionId, int subscriptionId) { + this.from = from; + this.contentLocation = contentLocation; + this.transactionId = transactionId; + this.subscriptionId = subscriptionId; + } + + public String getContentLocation() { + return contentLocation; + } + + public String getTransactionId() { + return transactionId; + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public @NonNull RecipientId getFrom() { + return from; + } + } + + public interface InsertListener { + void onComplete(); + } + + public interface Reader extends Closeable { + MessageRecord getNext(); + MessageRecord getCurrent(); + void close(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java new file mode 100644 index 00000000..bc995651 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -0,0 +1,2119 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.android.mms.pdu_alt.NotificationInd; +import com.google.android.mms.pdu_alt.PduHeaders; +import com.tm.androidcopysdk.DataGrabber; + +import net.sqlcipher.database.SQLiteStatement; + +import org.archiver.ArchiveConstants; +import org.archiver.ArchiveSender; +import org.archiver.ArchiveUtil; +import org.archiver.FileUtilTestMoti; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.documents.NetworkFailureList; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.Quote; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.IncomingMediaMessage; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; +import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; + +public class MmsDatabase extends MessageDatabase { + + private static final String TAG = MmsDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "mms"; + static final String DATE_SENT = "date"; + static final String DATE_RECEIVED = "date_received"; + public static final String MESSAGE_BOX = "msg_box"; + static final String CONTENT_LOCATION = "ct_l"; + static final String EXPIRY = "exp"; + public static final String MESSAGE_TYPE = "m_type"; + static final String MESSAGE_SIZE = "m_size"; + static final String STATUS = "st"; + static final String TRANSACTION_ID = "tr_id"; + static final String PART_COUNT = "part_count"; + static final String NETWORK_FAILURE = "network_failures"; + + static final String QUOTE_ID = "quote_id"; + static final String QUOTE_AUTHOR = "quote_author"; + static final String QUOTE_BODY = "quote_body"; + static final String QUOTE_ATTACHMENT = "quote_attachment"; + static final String QUOTE_MISSING = "quote_missing"; + static final String QUOTE_MENTIONS = "quote_mentions"; + + static final String SHARED_CONTACTS = "shared_contacts"; + static final String LINK_PREVIEWS = "previews"; + static final String MENTIONS_SELF = "mentions_self"; + + public static final String VIEW_ONCE = "reveal_duration"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + + DATE_SENT + " INTEGER, " + + DATE_RECEIVED + " INTEGER, " + + DATE_SERVER + " INTEGER DEFAULT -1, " + + MESSAGE_BOX + " INTEGER, " + + READ + " INTEGER DEFAULT 0, " + + "m_id" + " TEXT, " + + "sub" + " TEXT, " + + "sub_cs" + " INTEGER, " + + BODY + " TEXT, " + + PART_COUNT + " INTEGER, " + + "ct_t" + " TEXT, " + + CONTENT_LOCATION + " TEXT, " + + RECIPIENT_ID + " INTEGER, " + + ADDRESS_DEVICE_ID + " INTEGER, " + + EXPIRY + " INTEGER, " + + "m_cls" + " TEXT, " + + MESSAGE_TYPE + " INTEGER, " + + "v" + " INTEGER, " + + MESSAGE_SIZE + " INTEGER, " + + "pri" + " INTEGER, " + + "rr" + " INTEGER, " + + "rpt_a" + " INTEGER, " + + "resp_st" + " INTEGER, " + + STATUS + " INTEGER, " + + TRANSACTION_ID + " TEXT, " + + "retr_st" + " INTEGER, " + + "retr_txt" + " TEXT, " + + "retr_txt_cs" + " INTEGER, " + + "read_status" + " INTEGER, " + + "ct_cls" + " INTEGER, " + + "resp_txt" + " TEXT, " + + "d_tm" + " INTEGER, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + + NETWORK_FAILURE + " TEXT DEFAULT NULL," + + "d_rpt" + " INTEGER, " + + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + EXPIRES_IN + " INTEGER DEFAULT 0, " + + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + + NOTIFIED + " INTEGER DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + QUOTE_ID + " INTEGER DEFAULT 0, " + + QUOTE_AUTHOR + " TEXT, " + + QUOTE_BODY + " TEXT, " + + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + + QUOTE_MISSING + " INTEGER DEFAULT 0, " + + QUOTE_MENTIONS + " BLOB DEFAULT NULL," + + SHARED_CONTACTS + " TEXT, " + + UNIDENTIFIED + " INTEGER DEFAULT 0, " + + LINK_PREVIEWS + " TEXT, " + + VIEW_ONCE + " INTEGER DEFAULT 0, " + + REACTIONS + " BLOB DEFAULT NULL, " + + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + + REMOTE_DELETED + " INTEGER DEFAULT 0, " + + MENTIONS_SELF + " INTEGER DEFAULT 0, " + + NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0, " + + VIEWED_RECEIPT_COUNT + " INTEGER DEFAULT 0);"; + + public static final String[] CREATE_INDEXS = { + "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", + "CREATE INDEX IF NOT EXISTS mms_read_index ON " + TABLE_NAME + " (" + READ + ");", + "CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");", + "CREATE INDEX IF NOT EXISTS mms_message_box_index ON " + TABLE_NAME + " (" + MESSAGE_BOX + ");", + "CREATE INDEX IF NOT EXISTS mms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ");", + "CREATE INDEX IF NOT EXISTS mms_date_server_index ON " + TABLE_NAME + " (" + DATE_SERVER + ");", + "CREATE INDEX IF NOT EXISTS mms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");", + "CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");" + }; + + private static final String[] MMS_PROJECTION = new String[] { + MmsDatabase.TABLE_NAME + "." + ID + " AS " + ID, + THREAD_ID, DATE_SENT + " AS " + NORMALIZED_DATE_SENT, + DATE_RECEIVED + " AS " + NORMALIZED_DATE_RECEIVED, + DATE_SERVER, + MESSAGE_BOX, READ, + CONTENT_LOCATION, EXPIRY, MESSAGE_TYPE, + MESSAGE_SIZE, STATUS, TRANSACTION_ID, + BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID, + DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, + EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, QUOTE_MENTIONS, + SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, + REMOTE_DELETED, MENTIONS_SELF, NOTIFIED_TIMESTAMP, VIEWED_RECEIPT_COUNT, + "json_group_array(json_object(" + + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + + "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", " + + "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + + "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + + "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + + "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + + "'" + AttachmentDatabase.CDN_NUMBER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CDN_NUMBER + ", " + + "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + + "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + "," + + "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + "," + + "'" + AttachmentDatabase.BORDERLESS + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BORDERLESS + "," + + "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + "," + + "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + "," + + "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + + "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + + "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + + "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID+ ", " + + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " + + "'" + AttachmentDatabase.STICKER_EMOJI + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_EMOJI + ", " + + "'" + AttachmentDatabase.VISUAL_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", " + + "'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " + + "'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", " + + "'" + AttachmentDatabase.UPLOAD_TIMESTAMP + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UPLOAD_TIMESTAMP + + ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, + }; + + private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?"; + + private static final String OUTGOING_INSECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + MESSAGE_BOX + " & " + Types.SECURE_MESSAGE_BIT + ")"; + private static final String OUTGOING_SECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + MESSAGE_BOX + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; + + private final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("MmsDelivery"); + + public MmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + @Override + protected String getTableName() { + return TABLE_NAME; + } + + @Override + protected String getDateSentColumnName() { + return DATE_SENT; + } + + @Override + protected String getDateReceivedColumnName() { + return DATE_RECEIVED; + } + + @Override + protected String getTypeField() { + return MESSAGE_BOX; + } + + @Override + public @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived) { + throw new UnsupportedOperationException(); + } + + @Override + int getMessageCountForThreadSummary(long threadId) { + return getMessageCountForThread(threadId); + } + + @Override + public Cursor getExpirationStartedMessages() { + String where = EXPIRE_STARTED + " > 0"; + return rawQuery(where, null); + } + + @Override + public SmsMessageRecord getSmsMessage(long messageId) { + throw new UnsupportedOperationException(); + } + + @Override + public Cursor getMessageCursor(long messageId) { + Cursor cursor = internalGetMessage(messageId); + setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)); + return cursor; + } + + @Override + public Cursor getVerboseMessageCursor(long messageId) { + Cursor cursor = internalGetMessage(messageId); + setNotifyVerboseConversationListeners(cursor, getThreadIdForMessage(messageId)); + return cursor; + } + + @Override + public boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsEndSession(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsPreKeyBundle(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsInvalidVersionKeyExchange(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsSecure(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsPush(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsDecryptFailed(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsDecryptDuplicate(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsNoSession(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsUnsupportedProtocolVersion(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsInvalidMessage(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsLegacyVersion(long id) { + throw new UnsupportedOperationException(); + } + + @Override + public void markAsMissedCall(long id, boolean isVideoOffer) { + throw new UnsupportedOperationException(); + } + + @Override + public void markSmsStatus(long id, int status) { + throw new UnsupportedOperationException(); + } + + @Override + public Pair updateBundleMessageBody(long messageId, String body) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull List getViewedIncomingMessages(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[]{ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, THREAD_ID}; + String where = THREAD_ID + " = ? AND " + VIEWED_RECEIPT_COUNT + " > 0 AND " + MESSAGE_BOX + " & " + Types.BASE_INBOX_TYPE + " = " + Types.BASE_INBOX_TYPE; + String[] args = SqlUtil.buildArgs(threadId); + + + try (Cursor cursor = db.query(getTableName(), columns, where, args, null, null, null, null)) { + if (cursor == null) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + + results.add(new MarkedMessageInfo(threadId, syncMessageId, null)); + } + + return results; + } + } + + @Override + public @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String[] columns = new String[]{ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, THREAD_ID}; + String where = ID_WHERE + " AND " + VIEWED_RECEIPT_COUNT + " = 0"; + String[] args = SqlUtil.buildArgs(messageId); + + database.beginTransaction(); + try (Cursor cursor = database.query(TABLE_NAME, columns, where, args, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return null; + } + + long type = CursorUtil.requireLong(cursor, MESSAGE_BOX); + if (Types.isSecureType(type) && Types.isInboxType(type)) { + long threadId = CursorUtil.requireLong(cursor, THREAD_ID); + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + + MarkedMessageInfo result = new MarkedMessageInfo(threadId, syncMessageId, null); + + ContentValues contentValues = new ContentValues(); + contentValues.put(VIEWED_RECEIPT_COUNT, 1); + + database.update(TABLE_NAME, contentValues, where, args); + database.setTransactionSuccessful(); + + return result; + } else { + return null; + } + } finally { + database.endTransaction(); + } + } + + @Override + public @NonNull Pair insertReceivedCall(@NonNull RecipientId address, boolean isVideoOffer) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull Pair insertOutgoingCall(@NonNull RecipientId address, boolean isVideoOffer) { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull Pair insertMissedCall(@NonNull RecipientId address, long timestamp, boolean isVideoOffer) { + throw new UnsupportedOperationException(); + } + + @Override + public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids, + boolean isCallFull) + { + throw new UnsupportedOperationException(); + } + + @Override + public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean updatePreviousGroupCall(long threadId, @Nullable String peekGroupCallEraId, @NonNull Collection peekJoinedUuids, boolean isCallFull) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional insertMessageInbox(IncomingTextMessage message, long type) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional insertMessageInbox(IncomingTextMessage message) { + throw new UnsupportedOperationException(); + } + + @Override + public long insertMessageOutbox(long threadId, OutgoingTextMessage message, boolean forceSms, long date, InsertListener insertListener) { + throw new UnsupportedOperationException(); + } + + @Override + public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) { + throw new UnsupportedOperationException(); + } + + @Override + public void insertGroupV1MigrationEvents(@NonNull RecipientId recipientId, + long threadId, + @NonNull GroupMigrationMembershipChange membershipChange) + { + throw new UnsupportedOperationException(); + } + + @Override + public void endTransaction(SQLiteDatabase database) { + database.endTransaction(); + } + + @Override + public SQLiteStatement createInsertStatement(SQLiteDatabase database) { + throw new UnsupportedOperationException(); + } + + @Override + public void ensureMigration() { + databaseHelper.getWritableDatabase(); + } + + @Override + public boolean isGroupQuitMessage(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{ID}; + String query = ID + " = ? AND " + MESSAGE_BOX + " & ?"; + long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT; + String[] args = new String[]{String.valueOf(messageId), String.valueOf(type)}; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null, null)) { + if (cursor.getCount() == 1) { + return true; + } + } + + return false; + } + + @Override + public long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{DATE_SENT}; + String query = THREAD_ID + " = ? AND " + MESSAGE_BOX + " & ? AND " + DATE_SENT + " < ?"; + long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(quitTimeBarrier)}; + String orderBy = DATE_SENT + " DESC"; + String limit = "1"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, orderBy, limit)) { + if (cursor.moveToFirst()) { + return CursorUtil.requireLong(cursor, DATE_SENT); + } + } + + return -1; + } + + @Override + public int getMessageCountForThread(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + @Override + public int getMessageCountForThread(long threadId, long beforeTime) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + @Override + public void addFailures(long messageId, List failure) { + try { + addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList.class); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + @Override + public void removeFailure(long messageId, NetworkFailure failure) { + try { + removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList.class); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + @Override + public boolean incrementReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + boolean found = false; + + try (Cursor cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, MESSAGE_BOX, RECIPIENT_ID, receiptType.getColumnName()}, + DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())}, + null, null, null, null)) { + while (cursor.moveToNext()) { + if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)))) { + RecipientId theirRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); + RecipientId ourRecipientId = messageId.getRecipientId(); + String columnName = receiptType.getColumnName(); + + if (ourRecipientId.equals(theirRecipientId) || Recipient.resolved(theirRecipientId).isGroup()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + int status = receiptType.getGroupStatus(); + boolean isFirstIncrement = cursor.getLong(cursor.getColumnIndexOrThrow(columnName)) == 0; + + found = true; + + database.execSQL("UPDATE " + TABLE_NAME + " SET " + + columnName + " = " + columnName + " + 1 WHERE " + ID + " = ?", + new String[] {String.valueOf(id)}); + + DatabaseFactory.getGroupReceiptDatabase(context).update(ourRecipientId, id, status, timestamp); + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + + if (isFirstIncrement) { + notifyConversationListeners(threadId); + } else { + notifyVerboseConversationListeners(threadId); + } + } + } + } + + if (!found && receiptType == ReceiptType.DELIVERY) { + earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getRecipientId()); + return true; + } + + return found; + } + } + + @Override + public long getThreadIdForMessage(long id) { + String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; + String[] sqlArgs = new String[] {id+""}; + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + Cursor cursor = null; + + try { + cursor = db.rawQuery(sql, sqlArgs); + if (cursor != null && cursor.moveToFirst()) + return cursor.getLong(0); + else + return -1; + } finally { + if (cursor != null) + cursor.close(); + } + } + + private long getThreadIdFor(@NonNull IncomingMediaMessage retrieved) { + if (retrieved.getGroupId() != null) { + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(retrieved.getGroupId()); + Recipient groupRecipients = Recipient.resolved(groupRecipientId); + return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipients); + } else { + Recipient sender = Recipient.resolved(retrieved.getFrom()); + return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(sender); + } + } + + private long getThreadIdFor(@NonNull NotificationInd notification) { + String fromString = notification.getFrom() != null && notification.getFrom().getTextString() != null + ? Util.toIsoString(notification.getFrom().getTextString()) + : ""; + Recipient recipient = Recipient.external(context, fromString); + return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + } + + private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { + return rawQuery(where, arguments, false, 0); + } + + private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String rawQueryString = "SELECT " + Util.join(MMS_PROJECTION, ",") + + " FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + + " ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + + " WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID; + + if (reverse) { + rawQueryString += " ORDER BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " DESC"; + } + + if (limit > 0) { + rawQueryString += " LIMIT " + limit; + } + + return database.rawQuery(rawQueryString, arguments); + } + + private Cursor internalGetMessage(long messageId) { + return rawQuery(RAW_ID_WHERE, new String[] {messageId + ""}); + } + + @Override + public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { + try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) { + MessageRecord record = new Reader(cursor).getNext(); + + if (record == null) { + throw new NoSuchMessageException("No message for ID: " + messageId); + } + + return record; + } + } + + @Override + public Reader getMessages(Collection messageIds) { + String ids = TextUtils.join(",", messageIds); + return readerFor(rawQuery(MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " IN (" + ids + ")", null)); + } + + private void updateMailboxBitmask(long id, long maskOff, long maskOn, Optional threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.execSQL("UPDATE " + TABLE_NAME + + " SET " + MESSAGE_BOX + " = (" + MESSAGE_BOX + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + + " WHERE " + ID + " = ?", new String[] {id + ""}); + + if (threadId.isPresent()) { + DatabaseFactory.getThreadDatabase(context).update(threadId.get(), false); + } + } + + @Override + public void markAsOutbox(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_OUTBOX_TYPE, Optional.of(threadId)); + } + + @Override + public void markAsForcedSms(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT, Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void markAsPendingInsecureSmsFallback(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_PENDING_INSECURE_SMS_FALLBACK, Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void markAsSending(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE, Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void markAsSentFailed(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE, Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void markAsSent(long messageId, boolean secure) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (secure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0), Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void markAsRemoteDelete(long messageId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + values.put(REMOTE_DELETED, 1); + values.putNull(BODY); + values.putNull(QUOTE_BODY); + values.putNull(QUOTE_AUTHOR); + values.putNull(QUOTE_ATTACHMENT); + values.putNull(QUOTE_ID); + values.putNull(LINK_PREVIEWS); + values.putNull(SHARED_CONTACTS); + values.putNull(REACTIONS); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(messageId) }); + + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentsForMessage(messageId); + DatabaseFactory.getMentionDatabase(context).deleteMentionsForMessage(messageId); + + long threadId = getThreadIdForMessage(messageId); + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public void markDownloadState(long messageId, long state) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(); + contentValues.put(STATUS, state); + + database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {messageId + ""}); + notifyConversationListeners(getThreadIdForMessage(messageId)); + } + + @Override + public void markAsInsecure(long messageId) { + updateMailboxBitmask(messageId, Types.SECURE_MESSAGE_BIT, 0, Optional.absent()); + } + + @Override + public void markUnidentified(long messageId, boolean unidentified) { + ContentValues contentValues = new ContentValues(); + contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); + } + + @Override + public void markExpireStarted(long id) { + markExpireStarted(id, System.currentTimeMillis()); + } + + @Override + public void markExpireStarted(long id, long startedTimestamp) { + markExpireStarted(Collections.singleton(id), startedTimestamp); + } + + @Override + public void markExpireStarted(Collection ids, long startedAtTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long threadId = -1; + + db.beginTransaction(); + try { + String query = ID + " = ? AND (" + EXPIRE_STARTED + " = 0 OR " + EXPIRE_STARTED + " > ?)"; + + for (long id : ids) { + ContentValues contentValues = new ContentValues(); + contentValues.put(EXPIRE_STARTED, startedAtTimestamp); + + db.update(TABLE_NAME, contentValues, query, new String[]{String.valueOf(id), String.valueOf(startedAtTimestamp)}); + + if (threadId < 0) { + threadId = getThreadIdForMessage(id); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public void markAsNotified(long id) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(); + + contentValues.put(NOTIFIED, 1); + + database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); + } + + @Override + public List setMessagesReadSince(long threadId, long sinceTimestamp) { + if (sinceTimestamp == -1) { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + ")))", new String[] {String.valueOf(threadId)}); + } else { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND ( " + getOutgoingTypeClause() + " ))) AND " + DATE_RECEIVED + " <= ?", new String[]{String.valueOf(threadId), String.valueOf(sinceTimestamp)}); + } + } + + @Override + public List setEntireThreadRead(long threadId) { + return setMessagesRead(THREAD_ID + " = ?", new String[] {String.valueOf(threadId)}); + } + + @Override + public List setAllMessagesRead() { + return setMessagesRead(READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + "))", null); + } + + private List setMessagesRead(String where, String[] arguments) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + List result = new LinkedList<>(); + Cursor cursor = null; + + database.beginTransaction(); + + try { + cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID }, where, arguments, null, null, null); + + while(cursor != null && cursor.moveToNext()) { + if (Types.isSecureType(CursorUtil.requireLong(cursor, MESSAGE_BOX))) { + long threadId = CursorUtil.requireLong(cursor, THREAD_ID); + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); + long messageId = CursorUtil.requireLong(cursor, ID); + long expiresIn = CursorUtil.requireLong(cursor, EXPIRES_IN); + long expireStarted = CursorUtil.requireLong(cursor, EXPIRE_STARTED); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, true); + + result.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo)); + } + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(READ, 1); + contentValues.put(REACTIONS_UNREAD, 0); + contentValues.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + database.update(TABLE_NAME, contentValues, where, arguments); + database.setTransactionSuccessful(); + } finally { + if (cursor != null) cursor.close(); + database.endTransaction(); + } + + return result; + } + + @Override + public List> setTimestampRead(SyncMessageId messageId, long proposedExpireStarted) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + List> expiring = new LinkedList<>(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED, RECIPIENT_ID}, DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())}, null, null, null, null); + + while (cursor.moveToNext()) { + RecipientId theirRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); + RecipientId ourRecipientId = messageId.getRecipientId(); + + if (ourRecipientId.equals(theirRecipientId) || Recipient.resolved(theirRecipientId).isGroup() || ourRecipientId.equals(Recipient.self().getId())) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)); + + expireStarted = expireStarted > 0 ? Math.min(proposedExpireStarted, expireStarted) : proposedExpireStarted; + + ContentValues values = new ContentValues(); + values.put(READ, 1); + values.put(REACTIONS_UNREAD, 0); + values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + if (expiresIn > 0) { + values.put(EXPIRE_STARTED, expireStarted); + expiring.add(new Pair<>(id, expiresIn)); + } + + database.update(TABLE_NAME, values, ID_WHERE, new String[]{String.valueOf(id)}); + + DatabaseFactory.getThreadDatabase(context).updateReadState(threadId); + DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId); + notifyConversationListeners(threadId); + } + } + } finally { + if (cursor != null) + cursor.close(); + } + + return expiring; + } + + @Override + public @Nullable Pair getOldestUnreadMentionDetails(long threadId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String[] projection = new String[]{RECIPIENT_ID,DATE_RECEIVED}; + String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1"; + String[] args = SqlUtil.buildArgs(threadId); + + try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, DATE_RECEIVED + " ASC", "1")) { + if (cursor != null && cursor.moveToFirst()) { + return new Pair<>(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, DATE_RECEIVED)); + } + } + + return null; + } + + @Override + public int getUnreadMentionCount(long threadId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String[] projection = new String[]{"COUNT(*)"}; + String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1"; + String[] args = SqlUtil.buildArgs(threadId); + + try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + /** + * Trims data related to expired messages. Only intended to be run after a backup restore. + */ + void trimEntriesForExpiredMessages() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String trimmedCondition = " NOT IN (SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME + ")"; + + database.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null); + + String[] columns = new String[] { AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID }; + String where = AttachmentDatabase.MMS_ID + trimmedCondition; + + try (Cursor cursor = database.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(new AttachmentId(cursor.getLong(0), cursor.getLong(1))); + } + } + + DatabaseFactory.getMentionDatabase(context).deleteAbandonedMentions(); + + try (Cursor cursor = database.query(ThreadDatabase.TABLE_NAME, new String[] { ThreadDatabase.ID }, ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + DatabaseFactory.getThreadDatabase(context).update(cursor.getLong(0), false); + } + } + } + + @Override + public Optional getNotification(long messageId) { + Cursor cursor = null; + + try { + cursor = rawQuery(RAW_ID_WHERE, new String[] {String.valueOf(messageId)}); + + if (cursor != null && cursor.moveToNext()) { + return Optional.of(new MmsNotificationInfo(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), + cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION)), + cursor.getString(cursor.getColumnIndexOrThrow(TRANSACTION_ID)), + cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)))); + } else { + return Optional.absent(); + } + } finally { + if (cursor != null) + cursor.close(); + } + } + + @Override + public OutgoingMediaMessage getOutgoingMessage(long messageId) + throws MmsException, NoSuchMessageException + { + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + Cursor cursor = null; + + try { + cursor = rawQuery(RAW_ID_WHERE, new String[] {String.valueOf(messageId)}); + + if (cursor != null && cursor.moveToNext()) { + List associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId); + List mentions = mentionDatabase.getMentionsForMessage(messageId); + + long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); + String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); + boolean viewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(VIEW_ONCE)) == 1; + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + int distributionType = DatabaseFactory.getThreadDatabase(context).getDistributionType(threadId); + String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); + String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); + + long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); + long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); + String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); + boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; + List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); + List quoteMentions = parseQuoteMentions(context, cursor); + List contacts = getSharedContacts(cursor, associatedAttachments); + Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); + List previews = getLinkPreviews(cursor, associatedAttachments); + Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); + List attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote) + .filterNot(contactAttachments::contains) + .filterNot(previewAttachments::contains) + .sorted(new DatabaseAttachment.DisplayOrderComparator()) + .map(a -> (Attachment)a).toList(); + + Recipient recipient = Recipient.resolved(RecipientId.from(recipientId)); + List networkFailures = new LinkedList<>(); + List mismatches = new LinkedList<>(); + QuoteModel quote = null; + + if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { + quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions); + } + + if (!TextUtils.isEmpty(mismatchDocument)) { + try { + mismatches = JsonUtils.fromJson(mismatchDocument, IdentityKeyMismatchList.class).getList(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + if (!TextUtils.isEmpty(networkDocument)) { + try { + networkFailures = JsonUtils.fromJson(networkDocument, NetworkFailureList.class).getList(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) { + return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews, mentions); + } else if (Types.isExpirationTimerUpdate(outboxType)) { + return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); + } + + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, networkFailures, mismatches); + + if (Types.isSecureType(outboxType)) { + return new OutgoingSecureMediaMessage(message); + } + + return message; + } + + throw new NoSuchMessageException("No record found for id: " + messageId); + } catch (IOException e) { + throw new MmsException(e); + } finally { + if (cursor != null) + cursor.close(); + } + } + + private static List getSharedContacts(@NonNull Cursor cursor, @NonNull List attachments) { + String serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS)); + + if (TextUtils.isEmpty(serializedContacts)) { + return Collections.emptyList(); + } + + Map attachmentIdMap = new HashMap<>(); + for (DatabaseAttachment attachment : attachments) { + attachmentIdMap.put(attachment.getAttachmentId(), attachment); + } + + try { + List contacts = new LinkedList<>(); + JSONArray jsonContacts = new JSONArray(serializedContacts); + + for (int i = 0; i < jsonContacts.length(); i++) { + Contact contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()); + + if (contact.getAvatar() != null && contact.getAvatar().getAttachmentId() != null) { + DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId()); + Avatar updatedAvatar = new Avatar(contact.getAvatar().getAttachmentId(), + attachment, + contact.getAvatar().isProfile()); + contacts.add(new Contact(contact, updatedAvatar)); + } else { + contacts.add(contact); + } + } + + return contacts; + } catch (JSONException | IOException e) { + Log.w(TAG, "Failed to parse shared contacts.", e); + } + + return Collections.emptyList(); + } + + private static List getLinkPreviews(@NonNull Cursor cursor, @NonNull List attachments) { + String serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS)); + + if (TextUtils.isEmpty(serializedPreviews)) { + return Collections.emptyList(); + } + + Map attachmentIdMap = new HashMap<>(); + for (DatabaseAttachment attachment : attachments) { + attachmentIdMap.put(attachment.getAttachmentId(), attachment); + } + + try { + List previews = new LinkedList<>(); + JSONArray jsonPreviews = new JSONArray(serializedPreviews); + + for (int i = 0; i < jsonPreviews.length(); i++) { + LinkPreview preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()); + + if (preview.getAttachmentId() != null) { + DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId()); + if (attachment != null) { + previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachment)); + } + } else { + previews.add(preview); + } + } + + return previews; + } catch (JSONException | IOException e) { + Log.w(TAG, "Failed to parse shared contacts.", e); + } + + return Collections.emptyList(); + } + + private Optional insertMessageInbox(IncomingMediaMessage retrieved, + String contentLocation, + long threadId, long mailbox) + throws MmsException + { + if (threadId == -1 || retrieved.isGroupMessage()) { + threadId = getThreadIdFor(retrieved); + }//new2 + + ContentValues contentValues = new ContentValues(); + + contentValues.put(DATE_SENT, retrieved.getSentTimeMillis()); + contentValues.put(DATE_SERVER, retrieved.getServerTimeMillis()); + contentValues.put(RECIPIENT_ID, retrieved.getFrom().serialize()); + + contentValues.put(MESSAGE_BOX, mailbox); + contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); + contentValues.put(THREAD_ID, threadId); + contentValues.put(CONTENT_LOCATION, contentLocation); + contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED); + contentValues.put(DATE_RECEIVED, retrieved.isPushMessage() ? System.currentTimeMillis() : generatePduCompatTimestamp()); + contentValues.put(PART_COUNT, retrieved.getAttachments().size()); + contentValues.put(SUBSCRIPTION_ID, retrieved.getSubscriptionId()); + contentValues.put(EXPIRES_IN, retrieved.getExpiresIn()); + contentValues.put(VIEW_ONCE, retrieved.isViewOnce() ? 1 : 0); + contentValues.put(READ, retrieved.isExpirationUpdate() ? 1 : 0); + contentValues.put(UNIDENTIFIED, retrieved.isUnidentified()); + + if (!contentValues.containsKey(DATE_SENT)) { + contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)); + } + + List quoteAttachments = new LinkedList<>(); + + if (retrieved.getQuote() != null) { + contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); + contentValues.put(QUOTE_BODY, retrieved.getQuote().getText().toString()); + contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize()); + contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0); + + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.getQuote().getMentions()); + if (mentionsList != null) { + contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + } + + quoteAttachments = retrieved.getQuote().getAttachments(); + } + + if (retrieved.isPushMessage() && isDuplicate(retrieved, threadId)) { + Log.w(TAG, "Ignoring duplicate media message (" + retrieved.getSentTimeMillis() + ")"); + return Optional.absent(); + } + + long messageId = insertMediaMessage(threadId, retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), retrieved.getMentions(), contentValues, null); + + if (!Types.isExpirationTimerUpdate(mailbox)) { + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + } + + notifyConversationListeners(threadId); + + return Optional.of(new InsertResult(messageId, threadId)); + } + + @Override + public Optional insertMessageInbox(IncomingMediaMessage retrieved, + String contentLocation, long threadId) + throws MmsException + { + long type = Types.BASE_INBOX_TYPE; + + if (retrieved.isPushMessage()) { + type |= Types.PUSH_MESSAGE_BIT; + } + + if (retrieved.isExpirationUpdate()) { + type |= Types.EXPIRATION_TIMER_UPDATE_BIT; + } + + return insertMessageInbox(retrieved, contentLocation, threadId, type); + } + + @Override + public Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) + throws MmsException + { + long type = Types.BASE_INBOX_TYPE | Types.SECURE_MESSAGE_BIT; + + if (retrieved.isPushMessage()) { + type |= Types.PUSH_MESSAGE_BIT; + } + + if (retrieved.isExpirationUpdate()) { + type |= Types.EXPIRATION_TIMER_UPDATE_BIT; + } + + return insertMessageInbox(retrieved, "", threadId, type); + } + + public Pair insertMessageInbox(@NonNull NotificationInd notification, int subscriptionId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long threadId = getThreadIdFor(notification); + ContentValues contentValues = new ContentValues(); + ContentValuesBuilder contentBuilder = new ContentValuesBuilder(contentValues); + + Log.i(TAG, "Message received type: " + notification.getMessageType()); + + contentBuilder.add(CONTENT_LOCATION, notification.getContentLocation()); + contentBuilder.add(DATE_SENT, System.currentTimeMillis()); + contentBuilder.add(EXPIRY, notification.getExpiry()); + contentBuilder.add(MESSAGE_SIZE, notification.getMessageSize()); + contentBuilder.add(TRANSACTION_ID, notification.getTransactionId()); + contentBuilder.add(MESSAGE_TYPE, notification.getMessageType()); + + if (notification.getFrom() != null) { + Recipient recipient = Recipient.external(context, Util.toIsoString(notification.getFrom().getTextString())); + contentValues.put(RECIPIENT_ID, recipient.getId().serialize()); + } else { + contentValues.put(RECIPIENT_ID, RecipientId.UNKNOWN.serialize()); + } + + contentValues.put(MESSAGE_BOX, Types.BASE_INBOX_TYPE); + contentValues.put(THREAD_ID, threadId); + contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED); + contentValues.put(DATE_RECEIVED, generatePduCompatTimestamp()); + contentValues.put(READ, Util.isDefaultSmsProvider(context) ? 0 : 1); + contentValues.put(SUBSCRIPTION_ID, subscriptionId); + + if (!contentValues.containsKey(DATE_SENT)) + contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)); + + long messageId = db.insert(TABLE_NAME, null, contentValues); + + return new Pair<>(messageId, threadId); + } + + @Override + public @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp) { + throw new UnsupportedOperationException(); + } + + @Override + public void markIncomingNotificationReceived(long threadId) { + notifyConversationListeners(threadId); + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + + if (org.thoughtcrime.securesms.util.Util.isDefaultSmsProvider(context)) { + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + @Override + public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, + long threadId, + boolean forceSms, + @Nullable SmsDatabase.InsertListener insertListener) + throws MmsException + { + return insertMessageOutbox(message, threadId, forceSms, GroupReceiptDatabase.STATUS_UNDELIVERED, insertListener); + } + + @Override + public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, + long threadId, boolean forceSms, int defaultReceiptStatus, + @Nullable SmsDatabase.InsertListener insertListener) + throws MmsException + { + long type = Types.BASE_SENDING_TYPE; + Log.d("MNMN", "insertMessageOutbox " ); + if (message.isSecure()) type |= (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT); + if (forceSms) type |= Types.MESSAGE_FORCE_SMS_BIT; + + if (message.isGroup()) { + OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (OutgoingGroupUpdateMessage) message; + if (outgoingGroupUpdateMessage.isV2Group()) { + type |= Types.GROUP_V2_BIT | Types.GROUP_UPDATE_BIT; + } else { + MessageGroupContext.GroupV1Properties properties = outgoingGroupUpdateMessage.requireGroupV1Properties(); + if (properties.isUpdate()) type |= Types.GROUP_UPDATE_BIT; + else if (properties.isQuit()) type |= Types.GROUP_QUIT_BIT; + } + } + + if (message.isExpirationUpdate()) { + type |= Types.EXPIRATION_TIMER_UPDATE_BIT; + } + + Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis()); + + ContentValues contentValues = new ContentValues(); + contentValues.put(DATE_SENT, message.getSentTimeMillis()); + contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ); + + contentValues.put(MESSAGE_BOX, type); + contentValues.put(THREAD_ID, threadId); + contentValues.put(READ, 1); + contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); + contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); + contentValues.put(EXPIRES_IN, message.getExpiresIn()); + contentValues.put(VIEW_ONCE, message.isViewOnce()); + contentValues.put(RECIPIENT_ID, message.getRecipient().getId().serialize()); + contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); + + List quoteAttachments = new LinkedList<>(); + + if (message.getOutgoingQuote() != null) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getOutgoingQuote().getText(), message.getOutgoingQuote().getMentions()); + + contentValues.put(QUOTE_ID, message.getOutgoingQuote().getId()); + contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize()); + contentValues.put(QUOTE_BODY, updated.getBodyAsString()); + contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().isOriginalMissing() ? 1 : 0); + + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(updated.getMentions()); + if (mentionsList != null) { + contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + } + + quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); + } + + MentionUtil.UpdatedBodyAndMentions updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getBody(), message.getMentions()); + long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), contentValues, insertListener); + + if (message.getRecipient().isGroup()) { + OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null; + + GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); + Set members = new HashSet<>(); + + if (outgoingGroupUpdateMessage != null && outgoingGroupUpdateMessage.isV2Group()) { + MessageGroupContext.GroupV2Properties groupV2Properties = outgoingGroupUpdateMessage.requireGroupV2Properties(); + members.addAll(Stream.of(groupV2Properties.getAllActivePendingAndRemovedMembers()) + .distinct() + .map(uuid -> RecipientId.from(uuid, null)) + .toList()); + members.remove(Recipient.self().getId()); + } else { + members.addAll(Stream.of(DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF)).map(Recipient::getId).toList()); + } + + receiptDatabase.insert(members, messageId, defaultReceiptStatus, message.getSentTimeMillis()); + + for (RecipientId recipientId : earlyDeliveryReceipts.keySet()) receiptDatabase.update(recipientId, messageId, GroupReceiptDatabase.STATUS_DELIVERED, -1); + } + + DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId); + DatabaseFactory.getThreadDatabase(context).setHasSent(threadId, true); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + + //Moti Amar + if(message.getAttachments() != null && message.getBody() != null && message.getAttachments().size() == 0 && !message.getBody().isEmpty() ) { + ArchiveSender.Companion.archiveMessageOutboxMMS(context,ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_SEND, message.getRecipient(), message, messageId, null); + }else if(message.getAttachments() != null && message.getAttachments().size() > 0){ + + for (int i = 0; i < message.getAttachments().size(); i++) { + + File tempFileForArchiving = new File(URI.create(FileUtilTestMoti.getUriRealPath(context, message.getAttachments().get(i).getUri())).getPath()); + ArchiveSender.Companion.archiveMessageOutboxMMS(context,ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_SEND, message.getRecipient(), message, messageId, tempFileForArchiving); + ArchiveSender.Companion.updateArchiveSDKToSendMMSMessage(context, tempFileForArchiving.getName(), false); + + } + } + + + + + return messageId; + } + + private long insertMediaMessage(long threadId, + @Nullable String body, + @NonNull List attachments, + @NonNull List quoteAttachments, + @NonNull List sharedContacts, + @NonNull List linkPreviews, + @NonNull List mentions, + @NonNull ContentValues contentValues, + @Nullable SmsDatabase.InsertListener insertListener) + throws MmsException + {//Insert new MMS message - part 1 + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + + boolean mentionsSelf = Stream.of(mentions).filter(m -> Recipient.resolved(m.getRecipientId()).isSelf()).findFirst().isPresent(); + + List allAttachments = new LinkedList<>(); + List contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); + List previewAttachments = Stream.of(linkPreviews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).toList(); + + allAttachments.addAll(attachments); + allAttachments.addAll(contactAttachments); + allAttachments.addAll(previewAttachments); + + contentValues.put(BODY, body); + contentValues.put(PART_COUNT, allAttachments.size()); + contentValues.put(MENTIONS_SELF, mentionsSelf ? 1 : 0); + + db.beginTransaction(); + try { + long messageId = db.insert(TABLE_NAME, null, contentValues); + //Moti Amar - Insert message to DB - (send) + + mentionDatabase.insert(threadId, messageId, mentions); + + Map insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); + String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts); + String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews); + + if (!TextUtils.isEmpty(serializedContacts)) { + ContentValues contactValues = new ContentValues(); + contactValues.put(SHARED_CONTACTS, serializedContacts); + + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); + + if (rows <= 0) { + Log.w(TAG, "Failed to update message with shared contact data."); + } + } + + if (!TextUtils.isEmpty(serializedPreviews)) { + ContentValues contactValues = new ContentValues(); + contactValues.put(LINK_PREVIEWS, serializedPreviews); + + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); + + if (rows <= 0) { + Log.w(TAG, "Failed to update message with link preview data."); + } + } + + db.setTransactionSuccessful(); + return messageId; + } finally { + db.endTransaction(); + + if (insertListener != null) { + insertListener.onComplete(); + } + + notifyConversationListeners(contentValues.getAsLong(THREAD_ID)); + DatabaseFactory.getThreadDatabase(context).update(contentValues.getAsLong(THREAD_ID), true); + } + } + + @Override + public boolean deleteMessage(long messageId) { + Log.d(TAG, "deleteMessage(" + messageId + ")"); + + long threadId = getThreadIdForMessage(messageId); + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + attachmentDatabase.deleteAttachmentsForMessage(messageId); + + GroupReceiptDatabase groupReceiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); + groupReceiptDatabase.deleteRowsForMessage(messageId); + + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + mentionDatabase.deleteMentionsForMessage(messageId); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); + boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + notifyStickerListeners(); + notifyStickerPackListeners(); + return threadDeleted; + } + + @Override + public void deleteThread(long threadId) { + Log.d(TAG, "deleteThread(" + threadId + ")"); + Set singleThreadSet = new HashSet<>(); + singleThreadSet.add(threadId); + deleteThreads(singleThreadSet); + } + + private @Nullable String getSerializedSharedContacts(@NonNull Map insertedAttachmentIds, @NonNull List contacts) { + if (contacts.isEmpty()) return null; + + JSONArray sharedContactJson = new JSONArray(); + + for (Contact contact : contacts) { + try { + AttachmentId attachmentId = null; + + if (contact.getAvatarAttachment() != null) { + attachmentId = insertedAttachmentIds.get(contact.getAvatarAttachment()); + } + + Avatar updatedAvatar = new Avatar(attachmentId, + contact.getAvatarAttachment(), + contact.getAvatar() != null && contact.getAvatar().isProfile()); + Contact updatedContact = new Contact(contact, updatedAvatar); + + sharedContactJson.put(new JSONObject(updatedContact.serialize())); + } catch (JSONException | IOException e) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); + } + } + return sharedContactJson.toString(); + } + + private @Nullable String getSerializedLinkPreviews(@NonNull Map insertedAttachmentIds, @NonNull List previews) { + if (previews.isEmpty()) return null; + + JSONArray linkPreviewJson = new JSONArray(); + + for (LinkPreview preview : previews) { + try { + AttachmentId attachmentId = null; + + if (preview.getThumbnail().isPresent()) { + attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get()); + } + + LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachmentId); + linkPreviewJson.put(new JSONObject(updatedPreview.serialize())); + } catch (JSONException | IOException e) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); + } + } + return linkPreviewJson.toString(); + } + + private boolean isDuplicate(IncomingMediaMessage message, long threadId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + RECIPIENT_ID + " = ? AND " + THREAD_ID + " = ?", + new String[]{String.valueOf(message.getSentTimeMillis()), message.getFrom().serialize(), String.valueOf(threadId)}, + null, null, null, "1"); + + try { + return cursor != null && cursor.moveToFirst(); + } finally { + if (cursor != null) cursor.close(); + } + } + + @Override + public boolean isSent(long messageId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + try (Cursor cursor = database.query(TABLE_NAME, new String[] { MESSAGE_BOX }, ID + " = ?", new String[] { String.valueOf(messageId)}, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + long type = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); + return Types.isSentType(type); + } + } + return false; + } + + @Override + public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) { + throw new UnsupportedOperationException(); + } + + @Override + void deleteThreads(@NonNull Set threadIds) { + Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")"); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = ""; + Cursor cursor = null; + + for (long threadId : threadIds) { + where += THREAD_ID + " = '" + threadId + "' OR "; + } + + where = where.substring(0, where.length() - 4); + + try { + cursor = db.query(TABLE_NAME, new String[] {ID}, where, null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + deleteMessage(cursor.getLong(0)); + } + + } finally { + if (cursor != null) + cursor.close(); + } + } + + @Override + void deleteMessagesInThreadBeforeDate(long threadId, long date) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < " + date; + + db.delete(TABLE_NAME, where, SqlUtil.buildArgs(threadId)); + } + + @Override + void deleteAbandonedMessages() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " NOT IN (SELECT _id FROM " + ThreadDatabase.TABLE_NAME + ")"; + + db.delete(TABLE_NAME, where, null); + } + + @Override + public List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " >= ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(rawQuery(where, args, false, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + @Override + public void deleteAllThreads() { + Log.d(TAG, "deleteAllThreads()"); + DatabaseFactory.getAttachmentDatabase(context).deleteAllAttachments(); + DatabaseFactory.getGroupReceiptDatabase(context).deleteAllRows(); + DatabaseFactory.getMentionDatabase(context).deleteAllMentions(); + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, null, null); + } + + @Override + public @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + ViewOnceExpirationInfo info = null; + long nearestExpiration = Long.MAX_VALUE; + + String query = "SELECT " + + TABLE_NAME + "." + ID + ", " + + VIEW_ONCE + ", " + + DATE_RECEIVED + " " + + "FROM " + TABLE_NAME + " INNER JOIN " + AttachmentDatabase.TABLE_NAME + " " + + "ON " + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " " + + "WHERE " + + VIEW_ONCE + " > 0 AND " + + "(" + AttachmentDatabase.DATA + " NOT NULL OR " + AttachmentDatabase.TRANSFER_STATE + " != ?)"; + String[] args = new String[] { String.valueOf(AttachmentDatabase.TRANSFER_PROGRESS_DONE) }; + + try (Cursor cursor = db.rawQuery(query, args)) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)); + long expiresAt = dateReceived + ViewOnceUtil.MAX_LIFESPAN; + + if (info == null || expiresAt < nearestExpiration) { + info = new ViewOnceExpirationInfo(id, dateReceived); + nearestExpiration = expiresAt; + } + } + } + + return info; + } + + private static @NonNull List parseQuoteMentions(@NonNull Context context, Cursor cursor) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_MENTIONS)); + + return MentionUtil.bodyRangeListToMentions(context, raw); + } + + @Override + public SQLiteDatabase beginTransaction() { + databaseHelper.getWritableDatabase().beginTransaction(); + return databaseHelper.getWritableDatabase(); + } + + @Override + public void setTransactionSuccessful() { + databaseHelper.getWritableDatabase().setTransactionSuccessful(); + } + + @Override + public void endTransaction() { + databaseHelper.getWritableDatabase().endTransaction(); + } + + public static Reader readerFor(Cursor cursor) { + return new Reader(cursor); + } + + public static OutgoingMessageReader readerFor(OutgoingMediaMessage message, long threadId) { + return new OutgoingMessageReader(message, threadId); + } + + public static class Status { + public static final int DOWNLOAD_INITIALIZED = 1; + public static final int DOWNLOAD_NO_CONNECTIVITY = 2; + public static final int DOWNLOAD_CONNECTING = 3; + public static final int DOWNLOAD_SOFT_FAILURE = 4; + public static final int DOWNLOAD_HARD_FAILURE = 5; + public static final int DOWNLOAD_APN_UNAVAILABLE = 6; + } + + public static class OutgoingMessageReader { + + private final Context context; + private final OutgoingMediaMessage message; + private final long id; + private final long threadId; + + public OutgoingMessageReader(OutgoingMediaMessage message, long threadId) { + this.context = ApplicationDependencies.getApplication(); + this.message = message; + this.id = new SecureRandom().nextLong(); + this.threadId = threadId; + } + + public MessageRecord getCurrent() { + SlideDeck slideDeck = new SlideDeck(context, message.getAttachments()); + + CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null; + List quoteMentions = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getMentions() : Collections.emptyList(); + + if (quoteText != null && !quoteMentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); + + quoteText = updated.getBody(); + quoteMentions = updated.getMentions(); + } + + return new MediaMmsMessageRecord(id, + message.getRecipient(), + message.getRecipient(), + 1, + System.currentTimeMillis(), + System.currentTimeMillis(), + -1, + 0, + threadId, message.getBody(), + slideDeck, + slideDeck.getSlides().size(), + message.isSecure() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), + new LinkedList<>(), + new LinkedList<>(), + message.getSubscriptionId(), + message.getExpiresIn(), + System.currentTimeMillis(), + message.isViewOnce(), + 0, + message.getOutgoingQuote() != null ? + new Quote(message.getOutgoingQuote().getId(), + message.getOutgoingQuote().getAuthor(), + quoteText, + message.getOutgoingQuote().isOriginalMissing(), + new SlideDeck(context, message.getOutgoingQuote().getAttachments()), + quoteMentions) : + null, + message.getSharedContacts(), + message.getLinkPreviews(), + false, + Collections.emptyList(), + false, + false, + 0, + 0); + } + } + + public static class Reader implements MessageDatabase.Reader { + + private final Cursor cursor; + private final Context context; + + public Reader(Cursor cursor) { + this.cursor = cursor; + this.context = ApplicationDependencies.getApplication(); + } + + @Override + public MessageRecord getNext() { + if (cursor == null || !cursor.moveToNext()) + return null; + + return getCurrent(); + } + + @Override + public MessageRecord getCurrent() { + long mmsType = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_TYPE)); + + if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) { + return getNotificationMmsMessageRecord(cursor); + } else { + return getMediaMmsMessageRecord(cursor); + } + } + + private NotificationMmsMessageRecord getNotificationMmsMessageRecord(Cursor cursor) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); + long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT)); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); + long mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)); + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)); + int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID)); + Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get(); + + String contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.CONTENT_LOCATION)); + String transactionId = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.TRANSACTION_ID)); + long messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_SIZE)); + long expiry = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRY)); + int status = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.STATUS)); + int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); + int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT)); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID)); + int viewedReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.VIEWED_RECEIPT_COUNT)); + + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + readReceiptCount = 0; + } + + byte[]contentLocationBytes = null; + byte[]transactionIdBytes = null; + + if (!TextUtils.isEmpty(contentLocation)) + contentLocationBytes = org.thoughtcrime.securesms.util.Util.toIsoBytes(contentLocation); + + if (!TextUtils.isEmpty(transactionId)) + transactionIdBytes = org.thoughtcrime.securesms.util.Util.toIsoBytes(transactionId); + + SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize)); + + + return new NotificationMmsMessageRecord(id, recipient, recipient, + addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, + contentLocationBytes, messageSize, expiry, status, + transactionIdBytes, mailbox, subscriptionId, slideDeck, + readReceiptCount, viewedReceiptCount); + } + + private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); + long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT)); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); + long dateServer = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.DATE_SERVER)); + long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)); + int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID)); + int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); + int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT)); + String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY)); + int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT)); + String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); + String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED)); + boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; + boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; + boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1; + List reactions = parseReactions(cursor); + boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF); + long notifiedTimestamp = CursorUtil.requireLong(cursor, NOTIFIED_TIMESTAMP); + int viewedReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.VIEWED_RECEIPT_COUNT)); + + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + readReceiptCount = 0; + viewedReceiptCount = 0; + } + + Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get(); + List mismatches = getMismatchedIdentities(mismatchDocument); + List networkFailures = getFailures(networkDocument); + List attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); + List contacts = getSharedContacts(cursor, attachments); + Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).withoutNulls().collect(Collectors.toSet()); + List previews = getLinkPreviews(cursor, attachments); + Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); + SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).filterNot(previewAttachments::contains).toList()); + Quote quote = getQuote(cursor); + + return new MediaMmsMessageRecord(id, recipient, recipient, + addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount, + threadId, body, slideDeck, partCount, box, mismatches, + networkFailures, subscriptionId, expiresIn, expireStarted, + isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions, + remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount); + } + + private List getMismatchedIdentities(String document) { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtils.fromJson(document, IdentityKeyMismatchList.class).getList(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + return new LinkedList<>(); + } + + private List getFailures(String document) { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtils.fromJson(document, NetworkFailureList.class).getList(); + } catch (IOException ioe) { + Log.w(TAG, ioe); + } + } + + return new LinkedList<>(); + } + + private SlideDeck getSlideDeck(@NonNull List attachments) { + List messageAttachments = Stream.of(attachments) + .filterNot(Attachment::isQuote) + .sorted(new DatabaseAttachment.DisplayOrderComparator()) + .toList(); + return new SlideDeck(context, messageAttachments); + } + + private @Nullable Quote getQuote(@NonNull Cursor cursor) { + long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID)); + long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_AUTHOR)); + CharSequence quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY)); + boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_MISSING)) == 1; + List quoteMentions = parseQuoteMentions(context, cursor); + List attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); + List quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); + SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); + + if (quoteId > 0 && quoteAuthor > 0) { + if (quoteText != null && !quoteMentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); + + quoteText = updated.getBody(); + quoteMentions = updated.getMentions(); + } + + return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck, quoteMentions); + } else { + return null; + } + } + + @Override + public void close() { + if (cursor != null) { + cursor.close(); + } + } + } + + private long generatePduCompatTimestamp() { + final long time = System.currentTimeMillis(); + return time - (time % 1000); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java new file mode 100644 index 00000000..8e657ee2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -0,0 +1,381 @@ +package org.thoughtcrime.securesms.database; + +@SuppressWarnings("UnnecessaryInterfaceModifier") +public interface MmsSmsColumns { + + public static final String ID = "_id"; + public static final String NORMALIZED_DATE_SENT = "date_sent"; + public static final String NORMALIZED_DATE_RECEIVED = "date_received"; + public static final String DATE_SERVER = "date_server"; + public static final String THREAD_ID = "thread_id"; + public static final String READ = "read"; + public static final String BODY = "body"; + public static final String RECIPIENT_ID = "address"; + public static final String ADDRESS_DEVICE_ID = "address_device_id"; + public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; + public static final String READ_RECEIPT_COUNT = "read_receipt_count"; + public static final String VIEWED_RECEIPT_COUNT = "viewed_receipt_count"; + public static final String MISMATCHED_IDENTITIES = "mismatched_identities"; + public static final String UNIQUE_ROW_ID = "unique_row_id"; + public static final String SUBSCRIPTION_ID = "subscription_id"; + public static final String EXPIRES_IN = "expires_in"; + public static final String EXPIRE_STARTED = "expire_started"; + public static final String NOTIFIED = "notified"; + public static final String NOTIFIED_TIMESTAMP = "notified_timestamp"; + public static final String UNIDENTIFIED = "unidentified"; + public static final String REACTIONS = "reactions"; + public static final String REACTIONS_UNREAD = "reactions_unread"; + public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; + public static final String REMOTE_DELETED = "remote_deleted"; + + public static class Types { + protected static final long TOTAL_MASK = 0xFFFFFFFF; + + // Base Types + protected static final long BASE_TYPE_MASK = 0x1F; + + protected static final long INCOMING_AUDIO_CALL_TYPE = 1; + protected static final long OUTGOING_AUDIO_CALL_TYPE = 2; + protected static final long MISSED_AUDIO_CALL_TYPE = 3; + protected static final long JOINED_TYPE = 4; + protected static final long UNSUPPORTED_MESSAGE_TYPE = 5; + protected static final long INVALID_MESSAGE_TYPE = 6; + protected static final long PROFILE_CHANGE_TYPE = 7; + protected static final long MISSED_VIDEO_CALL_TYPE = 8; + protected static final long GV1_MIGRATION_TYPE = 9; + protected static final long INCOMING_VIDEO_CALL_TYPE = 10; + protected static final long OUTGOING_VIDEO_CALL_TYPE = 11; + protected static final long GROUP_CALL_TYPE = 12; + + protected static final long BASE_INBOX_TYPE = 20; + protected static final long BASE_OUTBOX_TYPE = 21; + protected static final long BASE_SENDING_TYPE = 22; + protected static final long BASE_SENT_TYPE = 23; + protected static final long BASE_SENT_FAILED_TYPE = 24; + protected static final long BASE_PENDING_SECURE_SMS_FALLBACK = 25; + protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; + public static final long BASE_DRAFT_TYPE = 27; + + protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE, + BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, + BASE_PENDING_SECURE_SMS_FALLBACK, + BASE_PENDING_INSECURE_SMS_FALLBACK, + OUTGOING_AUDIO_CALL_TYPE, OUTGOING_VIDEO_CALL_TYPE}; + + // Message attributes + protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0; + protected static final long MESSAGE_FORCE_SMS_BIT = 0x40; + + // Key Exchange Information + protected static final long KEY_EXCHANGE_MASK = 0xFF00; + protected static final long KEY_EXCHANGE_BIT = 0x8000; + protected static final long KEY_EXCHANGE_IDENTITY_VERIFIED_BIT = 0x4000; + protected static final long KEY_EXCHANGE_IDENTITY_DEFAULT_BIT = 0x2000; + protected static final long KEY_EXCHANGE_CORRUPTED_BIT = 0x1000; + protected static final long KEY_EXCHANGE_INVALID_VERSION_BIT = 0x800; + protected static final long KEY_EXCHANGE_BUNDLE_BIT = 0x400; + protected static final long KEY_EXCHANGE_IDENTITY_UPDATE_BIT = 0x200; + protected static final long KEY_EXCHANGE_CONTENT_FORMAT = 0x100; + + // Secure Message Information + protected static final long SECURE_MESSAGE_BIT = 0x800000; + protected static final long END_SESSION_BIT = 0x400000; + protected static final long PUSH_MESSAGE_BIT = 0x200000; + + // Group Message Information + protected static final long GROUP_UPDATE_BIT = 0x10000; + protected static final long GROUP_QUIT_BIT = 0x20000; + protected static final long EXPIRATION_TIMER_UPDATE_BIT = 0x40000; + protected static final long GROUP_V2_BIT = 0x80000; + + // Encrypted Storage Information XXX + public static final long ENCRYPTION_MASK = 0xFF000000; + // public static final long ENCRYPTION_SYMMETRIC_BIT = 0x80000000; Deprecated + // protected static final long ENCRYPTION_ASYMMETRIC_BIT = 0x40000000; Deprecated + protected static final long ENCRYPTION_REMOTE_BIT = 0x20000000; + protected static final long ENCRYPTION_REMOTE_FAILED_BIT = 0x10000000; + protected static final long ENCRYPTION_REMOTE_NO_SESSION_BIT = 0x08000000; + protected static final long ENCRYPTION_REMOTE_DUPLICATE_BIT = 0x04000000; + protected static final long ENCRYPTION_REMOTE_LEGACY_BIT = 0x02000000; + + public static boolean isDraftMessageType(long type) { + return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; + } + + public static boolean isFailedMessageType(long type) { + return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE; + } + + public static boolean isOutgoingMessageType(long type) { + for (long outgoingType : OUTGOING_MESSAGE_TYPES) { + if ((type & BASE_TYPE_MASK) == outgoingType) + return true; + } + + return false; + } + + public static long getOutgoingEncryptedMessageType() { + return Types.BASE_SENDING_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; + } + + public static long getOutgoingSmsMessageType() { + return Types.BASE_SENDING_TYPE; + } + + public static boolean isForcedSms(long type) { + return (type & MESSAGE_FORCE_SMS_BIT) != 0; + } + + public static boolean isPendingMessageType(long type) { + return + (type & BASE_TYPE_MASK) == BASE_OUTBOX_TYPE || + (type & BASE_TYPE_MASK) == BASE_SENDING_TYPE; + } + + public static boolean isSentType(long type) { + return (type & BASE_TYPE_MASK) == BASE_SENT_TYPE; + } + + public static boolean isPendingSmsFallbackType(long type) { + return (type & BASE_TYPE_MASK) == BASE_PENDING_INSECURE_SMS_FALLBACK || + (type & BASE_TYPE_MASK) == BASE_PENDING_SECURE_SMS_FALLBACK; + } + + public static boolean isPendingSecureSmsFallbackType(long type) { + return (type & BASE_TYPE_MASK) == BASE_PENDING_SECURE_SMS_FALLBACK; + } + + public static boolean isPendingInsecureSmsFallbackType(long type) { + return (type & BASE_TYPE_MASK) == BASE_PENDING_INSECURE_SMS_FALLBACK; + } + + public static boolean isInboxType(long type) { + return (type & BASE_TYPE_MASK) == BASE_INBOX_TYPE; + } + + public static boolean isJoinedType(long type) { + return (type & BASE_TYPE_MASK) == JOINED_TYPE; + } + + public static boolean isUnsupportedMessageType(long type) { + return (type & BASE_TYPE_MASK) == UNSUPPORTED_MESSAGE_TYPE; + } + + public static boolean isInvalidMessageType(long type) { + return (type & BASE_TYPE_MASK) == INVALID_MESSAGE_TYPE; + } + + public static boolean isSecureType(long type) { + return (type & SECURE_MESSAGE_BIT) != 0; + } + + public static boolean isPushType(long type) { + return (type & PUSH_MESSAGE_BIT) != 0; + } + + public static boolean isEndSessionType(long type) { + return (type & END_SESSION_BIT) != 0; + } + + public static boolean isKeyExchangeType(long type) { + return (type & KEY_EXCHANGE_BIT) != 0; + } + + public static boolean isIdentityVerified(long type) { + return (type & KEY_EXCHANGE_IDENTITY_VERIFIED_BIT) != 0; + } + + public static boolean isIdentityDefault(long type) { + return (type & KEY_EXCHANGE_IDENTITY_DEFAULT_BIT) != 0; + } + + public static boolean isCorruptedKeyExchange(long type) { + return (type & KEY_EXCHANGE_CORRUPTED_BIT) != 0; + } + + public static boolean isInvalidVersionKeyExchange(long type) { + return (type & KEY_EXCHANGE_INVALID_VERSION_BIT) != 0; + } + + public static boolean isBundleKeyExchange(long type) { + return (type & KEY_EXCHANGE_BUNDLE_BIT) != 0; + } + + public static boolean isContentBundleKeyExchange(long type) { + return (type & KEY_EXCHANGE_CONTENT_FORMAT) != 0; + } + + public static boolean isIdentityUpdate(long type) { + return (type & KEY_EXCHANGE_IDENTITY_UPDATE_BIT) != 0; + } + + public static boolean isCallLog(long type) { + return isIncomingAudioCall(type) || + isIncomingVideoCall(type) || + isOutgoingAudioCall(type) || + isOutgoingVideoCall(type) || + isMissedAudioCall(type) || + isMissedVideoCall(type) || + isGroupCall(type); + } + + public static boolean isExpirationTimerUpdate(long type) { + return (type & EXPIRATION_TIMER_UPDATE_BIT) != 0; + } + + public static boolean isIncomingAudioCall(long type) { + return type == INCOMING_AUDIO_CALL_TYPE; + } + + public static boolean isIncomingVideoCall(long type) { + return type == INCOMING_VIDEO_CALL_TYPE; + } + + public static boolean isOutgoingAudioCall(long type) { + return type == OUTGOING_AUDIO_CALL_TYPE; + } + + public static boolean isOutgoingVideoCall(long type) { + return type == OUTGOING_VIDEO_CALL_TYPE; + } + + public static boolean isMissedAudioCall(long type) { + return type == MISSED_AUDIO_CALL_TYPE; + } + + public static boolean isMissedVideoCall(long type) { + return type == MISSED_VIDEO_CALL_TYPE; + } + + public static boolean isGroupCall(long type) { + return type == GROUP_CALL_TYPE; + } + + public static boolean isGroupUpdate(long type) { + return (type & GROUP_UPDATE_BIT) != 0; + } + + public static boolean isGroupV2(long type) { + return (type & GROUP_V2_BIT) != 0; + } + + public static boolean isGroupQuit(long type) { + return (type & GROUP_QUIT_BIT) != 0; + } + + public static boolean isFailedDecryptType(long type) { + return (type & ENCRYPTION_REMOTE_FAILED_BIT) != 0; + } + + public static boolean isDuplicateMessageType(long type) { + return (type & ENCRYPTION_REMOTE_DUPLICATE_BIT) != 0; + } + + public static boolean isDecryptInProgressType(long type) { + return (type & 0x40000000) != 0; // Inline deprecated asymmetric encryption type + } + + public static boolean isNoRemoteSessionType(long type) { + return (type & ENCRYPTION_REMOTE_NO_SESSION_BIT) != 0; + } + + public static boolean isLegacyType(long type) { + return (type & ENCRYPTION_REMOTE_LEGACY_BIT) != 0 || + (type & ENCRYPTION_REMOTE_BIT) != 0; + } + + public static boolean isProfileChange(long type) { + return type == PROFILE_CHANGE_TYPE; + } + + public static boolean isGroupV1MigrationEvent(long type) { + return type == GV1_MIGRATION_TYPE; + } + + public static long translateFromSystemBaseType(long theirType) { +// public static final int NONE_TYPE = 0; +// public static final int INBOX_TYPE = 1; +// public static final int SENT_TYPE = 2; +// public static final int SENT_PENDING = 4; +// public static final int FAILED_TYPE = 5; + + switch ((int)theirType) { + case 1: return BASE_INBOX_TYPE; + case 2: return BASE_SENT_TYPE; + case 3: return BASE_DRAFT_TYPE; + case 4: return BASE_OUTBOX_TYPE; + case 5: return BASE_SENT_FAILED_TYPE; + case 6: return BASE_OUTBOX_TYPE; + } + + return BASE_INBOX_TYPE; + } + + public static int translateToSystemBaseType(long type) { + if (isInboxType(type)) return 1; + else if (isOutgoingMessageType(type)) return 2; + else if (isFailedMessageType(type)) return 5; + + return 1; + } + + +// +// +// +// public static final int NONE_TYPE = 0; +// public static final int INBOX_TYPE = 1; +// public static final int SENT_TYPE = 2; +// public static final int SENT_PENDING = 4; +// public static final int FAILED_TYPE = 5; +// +// public static final int OUTBOX_TYPE = 43; // Messages are stored local encrypted and need delivery. +// +// +// public static final int ENCRYPTING_TYPE = 42; // Messages are stored local encrypted and need async encryption and delivery. +// public static final int SECURE_SENT_TYPE = 44; // Messages were sent with async encryption. +// public static final int SECURE_RECEIVED_TYPE = 45; // Messages were received with async decryption. +// public static final int FAILED_DECRYPT_TYPE = 46; // Messages were received with async encryption and failed to decrypt. +// public static final int DECRYPTING_TYPE = 47; // Messages are in the process of being asymmetricaly decrypted. +// public static final int NO_SESSION_TYPE = 48; // Messages were received with async encryption but there is no session yet. +// +// public static final int OUTGOING_KEY_EXCHANGE_TYPE = 49; +// public static final int INCOMING_KEY_EXCHANGE_TYPE = 50; +// public static final int STALE_KEY_EXCHANGE_TYPE = 51; +// public static final int PROCESSED_KEY_EXCHANGE_TYPE = 52; +// +// public static final int[] OUTGOING_MESSAGE_TYPES = {SENT_TYPE, SENT_PENDING, ENCRYPTING_TYPE, +// OUTBOX_TYPE, SECURE_SENT_TYPE, +// FAILED_TYPE, OUTGOING_KEY_EXCHANGE_TYPE}; +// +// public static boolean isFailedMessageType(long type) { +// return type == FAILED_TYPE; +// } +// +// public static boolean isOutgoingMessageType(long type) { +// for (int outgoingType : OUTGOING_MESSAGE_TYPES) { +// if (type == outgoingType) +// return true; +// } +// +// return false; +// } +// +// public static boolean isPendingMessageType(long type) { +// return type == SENT_PENDING || type == ENCRYPTING_TYPE || type == OUTBOX_TYPE; +// } +// +// public static boolean isSecureType(long type) { +// return +// type == SECURE_SENT_TYPE || type == ENCRYPTING_TYPE || +// type == SECURE_RECEIVED_TYPE || type == DECRYPTING_TYPE; +// } +// +// public static boolean isKeyExchangeType(long type) { +// return type == OUTGOING_KEY_EXCHANGE_TYPE || type == INCOMING_KEY_EXCHANGE_TYPE; +// } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java new file mode 100644 index 00000000..f6b6709e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -0,0 +1,775 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import net.sqlcipher.database.SQLiteQueryBuilder; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.whispersystems.libsignal.util.Pair; + +import java.io.Closeable; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public class MmsSmsDatabase extends Database { + + @SuppressWarnings("unused") + private static final String TAG = MmsSmsDatabase.class.getSimpleName(); + + public static final String TRANSPORT = "transport_type"; + public static final String MMS_TRANSPORT = "mms"; + public static final String SMS_TRANSPORT = "sms"; + + private static final String[] PROJECTION = {MmsSmsColumns.ID, + MmsSmsColumns.UNIQUE_ROW_ID, + SmsDatabase.BODY, + SmsDatabase.TYPE, + MmsSmsColumns.THREAD_ID, + SmsDatabase.RECIPIENT_ID, + SmsDatabase.ADDRESS_DEVICE_ID, + SmsDatabase.SUBJECT, + MmsSmsColumns.NORMALIZED_DATE_SENT, + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, + MmsSmsColumns.DATE_SERVER, + MmsDatabase.MESSAGE_TYPE, + MmsDatabase.MESSAGE_BOX, + SmsDatabase.STATUS, + MmsSmsColumns.UNIDENTIFIED, + MmsSmsColumns.REACTIONS, + MmsDatabase.PART_COUNT, + MmsDatabase.CONTENT_LOCATION, + MmsDatabase.TRANSACTION_ID, + MmsDatabase.MESSAGE_SIZE, + MmsDatabase.EXPIRY, + MmsDatabase.STATUS, + MmsSmsColumns.DELIVERY_RECEIPT_COUNT, + MmsSmsColumns.READ_RECEIPT_COUNT, + MmsSmsColumns.MISMATCHED_IDENTITIES, + MmsDatabase.NETWORK_FAILURE, + MmsSmsColumns.SUBSCRIPTION_ID, + MmsSmsColumns.EXPIRES_IN, + MmsSmsColumns.EXPIRE_STARTED, + MmsSmsColumns.NOTIFIED, + TRANSPORT, + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, + MmsDatabase.QUOTE_ID, + MmsDatabase.QUOTE_AUTHOR, + MmsDatabase.QUOTE_BODY, + MmsDatabase.QUOTE_MISSING, + MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, + MmsDatabase.SHARED_CONTACTS, + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.VIEW_ONCE, + MmsSmsColumns.READ, + MmsSmsColumns.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN, + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF, + MmsSmsColumns.NOTIFIED_TIMESTAMP, + MmsSmsColumns.VIEWED_RECEIPT_COUNT}; + + public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + /** + * @return The user that added you to the group, otherwise null. + */ + public @Nullable RecipientId getGroupAddedBy(long threadId) { + long lastQuitChecked = System.currentTimeMillis(); + Pair pair; + + do { + pair = getGroupAddedBy(threadId, lastQuitChecked); + if (pair.first() != null) { + return pair.first(); + } else { + lastQuitChecked = pair.second(); + } + + } while (pair.second() != -1); + + return null; + } + + private @NonNull Pair getGroupAddedBy(long threadId, long lastQuitChecked) { + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + long latestQuit = mmsDatabase.getLatestGroupQuitTimestamp(threadId, lastQuitChecked); + RecipientId id = smsDatabase.getOldestGroupUpdateSender(threadId, latestQuit); + + return new Pair<>(id, latestQuit); + } + + public int getMessagePositionOnOrAfterTimestamp(long threadId, long timestamp) { + String[] projection = new String[] { "COUNT(*)" }; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " >= " + timestamp; + + try (Cursor cursor = queryTables(projection, selection, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return cursor.getInt(0); + } + } + return 0; + } + + public @Nullable MessageRecord getMessageFor(long timestamp, RecipientId author) { + MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); + + try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + MmsSmsDatabase.Reader reader = db.readerFor(cursor); + + MessageRecord messageRecord; + + while ((messageRecord = reader.getNext()) != null) { + if ((Recipient.resolved(author).isSelf() && messageRecord.isOutgoing()) || + (!Recipient.resolved(author).isSelf() && messageRecord.getIndividualRecipient().getId().equals(author))) + { + return messageRecord; + } + } + } + + return null; + } + + public @NonNull List getMessagesAfterVoiceNoteInclusive(long messageId, long limit) throws NoSuchMessageException { + MessageRecord origin = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + List mms = DatabaseFactory.getMmsDatabase(context).getMessagesInThreadAfterInclusive(origin.getThreadId(), origin.getDateReceived(), limit); + List sms = DatabaseFactory.getSmsDatabase(context).getMessagesInThreadAfterInclusive(origin.getThreadId(), origin.getDateReceived(), limit); + + mms.addAll(sms); + Collections.sort(mms, (a, b) -> Long.compare(a.getDateReceived(), b.getDateReceived())); + + return Stream.of(mms).limit(limit).toList(); + } + + + public Cursor getConversation(long threadId, long offset, long limit) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; + + Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); + setNotifyConversationListeners(cursor, threadId); + + return cursor; + } + + public Cursor getConversation(long threadId) { + return getConversation(threadId, 0, 0); + } + + public Cursor getIdentityConflictMessagesForThread(long threadId) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.MISMATCHED_IDENTITIES + " IS NOT NULL"; + + Cursor cursor = queryTables(PROJECTION, selection, order, null); + setNotifyConversationListeners(cursor, threadId); + + return cursor; + } + + public Cursor getConversationSnippet(long threadId) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND (" + SmsDatabase.TYPE + " IS NULL OR " + SmsDatabase.TYPE + " NOT IN (" + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ", " + SmsDatabase.Types.GV1_MIGRATION_TYPE + "))"; + + return queryTables(PROJECTION, selection, order, "1"); + } + + public Cursor getUnread() { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC"; + String selection = MmsSmsColumns.NOTIFIED + " = 0 AND (" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1)"; + + return queryTables(PROJECTION, selection, order, null); + } + + public int getUnreadCount(long threadId) { + String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; + Cursor cursor = queryTables(PROJECTION, selection, null, null); + + try { + return cursor != null ? cursor.getCount() : 0; + } finally { + if (cursor != null) cursor.close();; + } + } + + public boolean checkMessageExists(@NonNull MessageRecord messageRecord) { + MessageDatabase db = messageRecord.isMms() ? DatabaseFactory.getMmsDatabase(context) + : DatabaseFactory.getSmsDatabase(context); + + try (Cursor cursor = db.getMessageCursor(messageRecord.getId())) { + return cursor != null && cursor.getCount() > 0; + } + } + + public int getSecureConversationCount(long threadId) { + if (threadId == -1) { + return 0; + } + + int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCount(threadId); + count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCount(threadId); + + return count; + } + + public int getOutgoingSecureConversationCount(long threadId) { + if (threadId == -1L) { + return 0; + } + + int count = DatabaseFactory.getSmsDatabase(context).getOutgoingSecureMessageCount(threadId); + count += DatabaseFactory.getMmsDatabase(context).getOutgoingSecureMessageCount(threadId); + + return count; + } + + public int getConversationCount(long threadId) { + int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId); + count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId); + + return count; + } + + public int getConversationCount(long threadId, long beforeTime) { + return DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId, beforeTime) + + DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId, beforeTime); + } + + public int getConversationCountForThreadSummary(long threadId) { + int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThreadSummary(threadId); + count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThreadSummary(threadId); + + return count; + } + + public int getInsecureSentCount(long threadId) { + int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId); + count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId); + + return count; + } + + public int getInsecureMessageCountForInsights() { + int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessageCountForInsights(); + count += DatabaseFactory.getMmsDatabase(context).getInsecureMessageCountForInsights(); + + return count; + } + + public int getMessageCountBeforeDate(long date) { + String selection = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " < " + date; + + try (Cursor cursor = queryTables(new String[] { "COUNT(*)" }, selection, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + public int getSecureMessageCountForInsights() { + int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCountForInsights(); + count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCountForInsights(); + + return count; + } + + public long getThreadForMessageId(long messageId) { + long id = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); + + if (id == -1) return DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId); + else return id; + } + + public void incrementDeliveryReceiptCounts(@NonNull List syncMessageIds, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (SyncMessageId id : syncMessageIds) { + incrementDeliveryReceiptCount(id, timestamp); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, MessageDatabase.ReceiptType.DELIVERY); + DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, MessageDatabase.ReceiptType.DELIVERY); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * @return A list of ID's that were not updated. + */ + public @NonNull Collection incrementReadReceiptCounts(@NonNull List syncMessageIds, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + List unhandled = new LinkedList<>(); + + db.beginTransaction(); + try { + for (SyncMessageId id : syncMessageIds) { + boolean handled = incrementReadReceiptCount(id, timestamp); + + if (!handled) { + unhandled.add(id); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return unhandled; + } + + public boolean incrementReadReceiptCount(SyncMessageId syncMessageId, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + boolean handled = false; + + handled |= DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, MessageDatabase.ReceiptType.READ); + handled |= DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, MessageDatabase.ReceiptType.READ); + + db.setTransactionSuccessful(); + + return handled; + } finally { + db.endTransaction(); + } + } + + /** + * @return A list of ID's that were not updated. + */ + public @NonNull Collection incrementViewedReceiptCounts(@NonNull List syncMessageIds, long timestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + List unhandled = new LinkedList<>(); + + db.beginTransaction(); + try { + for (SyncMessageId id : syncMessageIds) { + boolean handled = incrementViewedReceiptCount(id, timestamp); + + if (!handled) { + unhandled.add(id); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return unhandled; + } + + public boolean incrementViewedReceiptCount(SyncMessageId syncMessageId, long timestamp) { + return DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, MessageDatabase.ReceiptType.VIEWED); + } + + public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull RecipientId recipientId) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + + try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.RECIPIENT_ID, MmsSmsColumns.REMOTE_DELETED}, selection, order, null)) { + boolean isOwnNumber = Recipient.resolved(recipientId).isSelf(); + + while (cursor != null && cursor.moveToNext()) { + boolean quoteIdMatches = cursor.getLong(0) == quoteId; + boolean recipientIdMatches = recipientId.equals(RecipientId.from(cursor.getLong(1))); + + if (quoteIdMatches && (recipientIdMatches || isOwnNumber)) { + if (CursorUtil.requireBoolean(cursor, MmsSmsColumns.REMOTE_DELETED)) { + return -1; + } else { + return cursor.getPosition(); + } + } + } + } + return -1; + } + + public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull RecipientId recipientId) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + + try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.RECIPIENT_ID, MmsSmsColumns.REMOTE_DELETED}, selection, order, null)) { + boolean isOwnNumber = Recipient.resolved(recipientId).isSelf(); + + while (cursor != null && cursor.moveToNext()) { + boolean timestampMatches = cursor.getLong(0) == receivedTimestamp; + boolean recipientIdMatches = recipientId.equals(RecipientId.from(cursor.getLong(1))); + + + if (timestampMatches && (recipientIdMatches || isOwnNumber)) { + if (CursorUtil.requireBoolean(cursor, MmsSmsColumns.REMOTE_DELETED)) { + return -1; + } else { + return cursor.getPosition(); + } + } + } + } + return -1; + } + + boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { + return DatabaseFactory.getSmsDatabase(context).hasReceivedAnyCallsSince(threadId, timestamp); + } + + /** + * Retrieves the position of the message with the provided timestamp in the query results you'd + * get from calling {@link #getConversation(long)}. + * + * Note: This could give back incorrect results in the situation where multiple messages have the + * same received timestamp. However, because this was designed to determine where to scroll to, + * you'll still wind up in about the right spot. + */ + public int getMessagePositionInConversation(long threadId, long receivedTimestamp) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " > " + receivedTimestamp; + + try (Cursor cursor = queryTables(new String[]{ "COUNT(*)" }, selection, order, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + return -1; + } + + public long getTimestampForFirstMessageAfterDate(long date) { + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC"; + String selection = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " > " + date; + + try (Cursor cursor = queryTables(new String[] { MmsSmsColumns.NORMALIZED_DATE_RECEIVED }, selection, order, "1")) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(0); + } + } + + return 0; + } + + public void setNotifiedTimestamp(long timestamp, @NonNull List smsIds, @NonNull List mmsIds) { + DatabaseFactory.getSmsDatabase(context).setNotifiedTimestamp(timestamp, smsIds); + DatabaseFactory.getMmsDatabase(context).setNotifiedTimestamp(timestamp, mmsIds); + } + + public void deleteMessagesInThreadBeforeDate(long threadId, long trimBeforeDate) { + Log.d(TAG, "deleteMessagesInThreadBeforeData(" + threadId + ", " + trimBeforeDate + ")"); + DatabaseFactory.getSmsDatabase(context).deleteMessagesInThreadBeforeDate(threadId, trimBeforeDate); + DatabaseFactory.getMmsDatabase(context).deleteMessagesInThreadBeforeDate(threadId, trimBeforeDate); + } + + public void deleteAbandonedMessages() { + Log.d(TAG, "deleteAbandonedMessages()"); + DatabaseFactory.getSmsDatabase(context).deleteAbandonedMessages(); + DatabaseFactory.getMmsDatabase(context).deleteAbandonedMessages(); + } + + private Cursor queryTables(String[] projection, String selection, String order, String limit) { + String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID, + "'MMS::' || " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + + " || '::' || " + MmsDatabase.DATE_SENT + + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, + "json_group_array(json_object(" + + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + + "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + "," + + "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + + "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + + "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + + "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + + "'" + AttachmentDatabase.CDN_NUMBER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CDN_NUMBER + ", " + + "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + + "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", " + + "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", " + + "'" + AttachmentDatabase.BORDERLESS + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BORDERLESS + ", " + + "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", " + + "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", " + + "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + + "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + + "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + + "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " + + "'" + AttachmentDatabase.STICKER_EMOJI + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_EMOJI + ", " + + "'" + AttachmentDatabase.VISUAL_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", " + + "'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " + + "'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", " + + "'" + AttachmentDatabase.UPLOAD_TIMESTAMP + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UPLOAD_TIMESTAMP + + ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, + SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, + SmsDatabase.TYPE, SmsDatabase.RECIPIENT_ID, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, + MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, + MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, + MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, + MmsDatabase.UNIDENTIFIED, + MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, + MmsSmsColumns.MISMATCHED_IDENTITIES, + MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, + MmsSmsColumns.NOTIFIED, + MmsDatabase.NETWORK_FAILURE, TRANSPORT, + MmsDatabase.QUOTE_ID, + MmsDatabase.QUOTE_AUTHOR, + MmsDatabase.QUOTE_BODY, + MmsDatabase.QUOTE_MISSING, + MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, + MmsDatabase.SHARED_CONTACTS, + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.VIEW_ONCE, + MmsDatabase.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN, + MmsSmsColumns.DATE_SERVER, + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF, + MmsSmsColumns.NOTIFIED_TIMESTAMP, + MmsSmsColumns.VIEWED_RECEIPT_COUNT}; + + String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, + MmsSmsColumns.ID, + "'SMS::' || " + MmsSmsColumns.ID + + " || '::' || " + SmsDatabase.DATE_SENT + + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, + "NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, + SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, + SmsDatabase.TYPE, SmsDatabase.RECIPIENT_ID, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, + MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, + MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, + MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, + MmsDatabase.UNIDENTIFIED, + MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, + MmsSmsColumns.MISMATCHED_IDENTITIES, + MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, + MmsSmsColumns.NOTIFIED, + MmsDatabase.NETWORK_FAILURE, TRANSPORT, + MmsDatabase.QUOTE_ID, + MmsDatabase.QUOTE_AUTHOR, + MmsDatabase.QUOTE_BODY, + MmsDatabase.QUOTE_MISSING, + MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, + MmsDatabase.SHARED_CONTACTS, + MmsDatabase.LINK_PREVIEWS, + MmsDatabase.VIEW_ONCE, + MmsDatabase.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN, + MmsSmsColumns.DATE_SERVER, + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF, + MmsSmsColumns.NOTIFIED_TIMESTAMP, + MmsSmsColumns.VIEWED_RECEIPT_COUNT}; + + SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); + SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); + + mmsQueryBuilder.setDistinct(true); + smsQueryBuilder.setDistinct(true); + + smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME); + mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + + AttachmentDatabase.TABLE_NAME + + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); + + + Set mmsColumnsPresent = new HashSet<>(); + mmsColumnsPresent.add(MmsSmsColumns.ID); + mmsColumnsPresent.add(MmsSmsColumns.READ); + mmsColumnsPresent.add(MmsSmsColumns.THREAD_ID); + mmsColumnsPresent.add(MmsSmsColumns.BODY); + mmsColumnsPresent.add(MmsSmsColumns.RECIPIENT_ID); + mmsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); + mmsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); + mmsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); + mmsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); + mmsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); + mmsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); + mmsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); + mmsColumnsPresent.add(MmsDatabase.MESSAGE_TYPE); + mmsColumnsPresent.add(MmsDatabase.MESSAGE_BOX); + mmsColumnsPresent.add(MmsDatabase.DATE_SENT); + mmsColumnsPresent.add(MmsDatabase.DATE_RECEIVED); + mmsColumnsPresent.add(MmsDatabase.DATE_SERVER); + mmsColumnsPresent.add(MmsDatabase.PART_COUNT); + mmsColumnsPresent.add(MmsDatabase.CONTENT_LOCATION); + mmsColumnsPresent.add(MmsDatabase.TRANSACTION_ID); + mmsColumnsPresent.add(MmsDatabase.MESSAGE_SIZE); + mmsColumnsPresent.add(MmsDatabase.EXPIRY); + mmsColumnsPresent.add(MmsDatabase.NOTIFIED); + mmsColumnsPresent.add(MmsDatabase.STATUS); + mmsColumnsPresent.add(MmsDatabase.UNIDENTIFIED); + mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); + mmsColumnsPresent.add(MmsDatabase.QUOTE_ID); + mmsColumnsPresent.add(MmsDatabase.QUOTE_AUTHOR); + mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY); + mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); + mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); + mmsColumnsPresent.add(MmsDatabase.QUOTE_MENTIONS); + mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); + mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); + mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE); + mmsColumnsPresent.add(MmsDatabase.REACTIONS); + mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD); + mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN); + mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); + mmsColumnsPresent.add(MmsDatabase.MENTIONS_SELF); + mmsColumnsPresent.add(MmsSmsColumns.NOTIFIED_TIMESTAMP); + mmsColumnsPresent.add(MmsSmsColumns.VIEWED_RECEIPT_COUNT); + + Set smsColumnsPresent = new HashSet<>(); + smsColumnsPresent.add(MmsSmsColumns.ID); + smsColumnsPresent.add(MmsSmsColumns.BODY); + smsColumnsPresent.add(MmsSmsColumns.RECIPIENT_ID); + smsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); + smsColumnsPresent.add(MmsSmsColumns.READ); + smsColumnsPresent.add(MmsSmsColumns.THREAD_ID); + smsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); + smsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); + smsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); + smsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); + smsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); + smsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); + smsColumnsPresent.add(MmsSmsColumns.NOTIFIED); + smsColumnsPresent.add(SmsDatabase.TYPE); + smsColumnsPresent.add(SmsDatabase.SUBJECT); + smsColumnsPresent.add(SmsDatabase.DATE_SENT); + smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); + smsColumnsPresent.add(SmsDatabase.DATE_SERVER); + smsColumnsPresent.add(SmsDatabase.STATUS); + smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED); + smsColumnsPresent.add(SmsDatabase.REACTIONS); + smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD); + smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN); + smsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); + smsColumnsPresent.add(MmsSmsColumns.NOTIFIED_TIMESTAMP); + + @SuppressWarnings("deprecation") + String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); + @SuppressWarnings("deprecation") + String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 4, SMS_TRANSPORT, selection, null, null, null); + + SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder(); + String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit); + + SQLiteQueryBuilder outerQueryBuilder = new SQLiteQueryBuilder(); + outerQueryBuilder.setTables("(" + unionQuery + ")"); + + @SuppressWarnings("deprecation") + String query = outerQueryBuilder.buildQuery(projection, null, null, null, null, null, null); + + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + return db.rawQuery(query, null); + } + + public Reader readerFor(@NonNull Cursor cursor) { + return new Reader(cursor); + } + + public class Reader implements Closeable { + + private final Cursor cursor; + private SmsDatabase.Reader smsReader; + private MmsDatabase.Reader mmsReader; + + public Reader(Cursor cursor) { + this.cursor = cursor; + } + + private SmsDatabase.Reader getSmsReader() { + if (smsReader == null) { + smsReader = SmsDatabase.readerFor(cursor); + } + + return smsReader; + } + + private MmsDatabase.Reader getMmsReader() { + if (mmsReader == null) { + mmsReader = MmsDatabase.readerFor(cursor); + } + + return mmsReader; + } + + public MessageRecord getNext() { + if (cursor == null || !cursor.moveToNext()) + return null; + + return getCurrent(); + } + + public MessageRecord getCurrent() { + String type = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); + + if (MmsSmsDatabase.MMS_TRANSPORT.equals(type)) return getMmsReader().getCurrent(); + else if (MmsSmsDatabase.SMS_TRANSPORT.equals(type)) return getSmsReader().getCurrent(); + else throw new AssertionError("Bad type: " + type); + } + + @Override + public void close() { + cursor.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NoExternalStorageException.java b/app/src/main/java/org/thoughtcrime/securesms/database/NoExternalStorageException.java new file mode 100644 index 00000000..75b266b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NoExternalStorageException.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +public class NoExternalStorageException extends Exception { + + public NoExternalStorageException() { + // TODO Auto-generated constructor stub + } + + public NoExternalStorageException(String detailMessage) { + super(detailMessage); + // TODO Auto-generated constructor stub + } + + public NoExternalStorageException(Throwable throwable) { + super(throwable); + // TODO Auto-generated constructor stub + } + + public NoExternalStorageException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + // TODO Auto-generated constructor stub + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NoSuchMessageException.java b/app/src/main/java/org/thoughtcrime/securesms/database/NoSuchMessageException.java new file mode 100644 index 00000000..930920b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NoSuchMessageException.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.database; + +public class NoSuchMessageException extends Exception { + public NoSuchMessageException(String s) {super(s);} + public NoSuchMessageException(Exception e) {super(e);} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NotInDirectoryException.java b/app/src/main/java/org/thoughtcrime/securesms/database/NotInDirectoryException.java new file mode 100644 index 00000000..92a150e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NotInDirectoryException.java @@ -0,0 +1,4 @@ +package org.thoughtcrime.securesms.database; + +public class NotInDirectoryException extends Throwable { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ObservableContent.java b/app/src/main/java/org/thoughtcrime/securesms/database/ObservableContent.java new file mode 100644 index 00000000..6362267b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ObservableContent.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.database; + +import android.database.ContentObserver; + +import androidx.annotation.NonNull; + +import java.io.Closeable; + +public interface ObservableContent extends Closeable { + void registerContentObserver(@NonNull ContentObserver observer); + void unregisterContentObserver(@NonNull ContentObserver observer); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyDatabase.java new file mode 100644 index 00000000..f0453511 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/OneTimePreKeyDatabase.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.database; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.state.PreKeyRecord; + +import java.io.IOException; + +public class OneTimePreKeyDatabase extends Database { + + private static final String TAG = OneTimePreKeyDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "one_time_prekeys"; + private static final String ID = "_id"; + public static final String KEY_ID = "key_id"; + public static final String PUBLIC_KEY = "public_key"; + public static final String PRIVATE_KEY = "private_key"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + + " (" + ID + " INTEGER PRIMARY KEY, " + + KEY_ID + " INTEGER UNIQUE, " + + PUBLIC_KEY + " TEXT NOT NULL, " + + PRIVATE_KEY + " TEXT NOT NULL);"; + + OneTimePreKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public @Nullable PreKeyRecord getPreKey(int keyId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, null, KEY_ID + " = ?", + new String[] {String.valueOf(keyId)}, + null, null, null)) + { + if (cursor != null && cursor.moveToFirst()) { + try { + ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0); + ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY)))); + + return new PreKeyRecord(keyId, new ECKeyPair(publicKey, privateKey)); + } catch (InvalidKeyException | IOException e) { + Log.w(TAG, e); + } + } + } + + return null; + } + + public void insertPreKey(int keyId, PreKeyRecord record) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(KEY_ID, keyId); + contentValues.put(PUBLIC_KEY, Base64.encodeBytes(record.getKeyPair().getPublicKey().serialize())); + contentValues.put(PRIVATE_KEY, Base64.encodeBytes(record.getKeyPair().getPrivateKey().serialize())); + + database.replace(TABLE_NAME, null, contentValues); + } + + public void removePreKey(int keyId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, KEY_ID + " = ?", new String[] {String.valueOf(keyId)}); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java new file mode 100644 index 00000000..cc3d69b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.Closeable; +import java.io.IOException; + +public class PushDatabase extends Database { + + private static final String TAG = PushDatabase.class.getSimpleName(); + + private static final String TABLE_NAME = "push"; + public static final String ID = "_id"; + public static final String TYPE = "type"; + public static final String SOURCE_E164 = "source"; + public static final String SOURCE_UUID = "source_uuid"; + public static final String DEVICE_ID = "device_id"; + public static final String LEGACY_MSG = "body"; + public static final String CONTENT = "content"; + public static final String TIMESTAMP = "timestamp"; + public static final String SERVER_RECEIVED_TIMESTAMP = "server_timestamp"; + public static final String SERVER_DELIVERED_TIMESTAMP = "server_delivered_timestamp"; + public static final String SERVER_GUID = "server_guid"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + TYPE + " INTEGER, " + + SOURCE_E164 + " TEXT, " + + SOURCE_UUID + " TEXT, " + + DEVICE_ID + " INTEGER, " + + LEGACY_MSG + " TEXT, " + + CONTENT + " TEXT, " + + TIMESTAMP + " INTEGER, " + + SERVER_RECEIVED_TIMESTAMP + " INTEGER DEFAULT 0, " + + SERVER_DELIVERED_TIMESTAMP + " INTEGER DEFAULT 0, " + + SERVER_GUID + " TEXT DEFAULT NULL);"; + + public PushDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public long insert(@NonNull SignalServiceEnvelope envelope) { + Optional messageId = find(envelope); + + if (messageId.isPresent()) { + return -1; + } else { + ContentValues values = new ContentValues(); + values.put(TYPE, envelope.getType()); + values.put(SOURCE_UUID, envelope.getSourceUuid().orNull()); + values.put(SOURCE_E164, envelope.getSourceE164().orNull()); + values.put(DEVICE_ID, envelope.getSourceDevice()); + values.put(LEGACY_MSG, envelope.hasLegacyMessage() ? Base64.encodeBytes(envelope.getLegacyMessage()) : ""); + values.put(CONTENT, envelope.hasContent() ? Base64.encodeBytes(envelope.getContent()) : ""); + values.put(TIMESTAMP, envelope.getTimestamp()); + values.put(SERVER_RECEIVED_TIMESTAMP, envelope.getServerReceivedTimestamp()); + values.put(SERVER_DELIVERED_TIMESTAMP, envelope.getServerDeliveredTimestamp()); + values.put(SERVER_GUID, envelope.getUuid()); + + return databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + } + } + + public SignalServiceEnvelope get(long id) throws NoSuchMessageException { + Cursor cursor = null; + + try { + cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, ID_WHERE, + new String[] {String.valueOf(id)}, + null, null, null); + + if (cursor != null && cursor.moveToNext()) { + String legacyMessage = cursor.getString(cursor.getColumnIndexOrThrow(LEGACY_MSG)); + String content = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT)); + String uuid = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_UUID)); + String e164 = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_E164)); + + return new SignalServiceEnvelope(cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)), + SignalServiceAddress.fromRaw(uuid, e164), + cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE_ID)), + cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)), + Util.isEmpty(legacyMessage) ? null : Base64.decode(legacyMessage), + Util.isEmpty(content) ? null : Base64.decode(content), + cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_RECEIVED_TIMESTAMP)), + cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_DELIVERED_TIMESTAMP)), + cursor.getString(cursor.getColumnIndexOrThrow(SERVER_GUID))); + } + } catch (IOException e) { + Log.w(TAG, e); + throw new NoSuchMessageException(e); + } finally { + if (cursor != null) + cursor.close(); + } + + throw new NoSuchMessageException("Not found"); + } + + public Cursor getPending() { + return databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); + } + + public void delete(long id) { + databaseHelper.getWritableDatabase().delete(TABLE_NAME, ID_WHERE, new String[] {id+""}); + } + + public Reader readerFor(Cursor cursor) { + return new Reader(cursor); + } + + private Optional find(SignalServiceEnvelope envelope) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = TYPE + " = ? AND " + + DEVICE_ID + " = ? AND " + + LEGACY_MSG + " = ? AND " + + CONTENT + " = ? AND " + + TIMESTAMP + " = ? AND " + + "(" + + "(" + SOURCE_E164 + " NOT NULL AND " + SOURCE_E164 + " = ?) OR " + + "(" + SOURCE_UUID + " NOT NULL AND " + SOURCE_UUID + " = ?)" + + ")"; + String[] args = new String[] { String.valueOf(envelope.getType()), + String.valueOf(envelope.getSourceDevice()), + envelope.hasLegacyMessage() ? Base64.encodeBytes(envelope.getLegacyMessage()) : "", + envelope.hasContent() ? Base64.encodeBytes(envelope.getContent()) : "", + String.valueOf(envelope.getTimestamp()), + String.valueOf(envelope.getSourceUuid().orNull()), + String.valueOf(envelope.getSourceE164().orNull()) }; + + + try (Cursor cursor = database.query(TABLE_NAME, null, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return Optional.of(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); + } else { + return Optional.absent(); + } + } + } + + public static class Reader implements Closeable { + private final Cursor cursor; + + public Reader(Cursor cursor) { + this.cursor = cursor; + } + + public SignalServiceEnvelope getNext() { + try { + if (cursor == null || !cursor.moveToNext()) + return null; + + int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); + String sourceUuid = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_UUID)); + String sourceE164 = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_E164)); + int deviceId = cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE_ID)); + String legacyMessage = cursor.getString(cursor.getColumnIndexOrThrow(LEGACY_MSG)); + String content = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT)); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)); + long serverReceivedTimestamp = cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_RECEIVED_TIMESTAMP)); + long serverDeliveredTimestamp = cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_DELIVERED_TIMESTAMP)); + String serverGuid = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_GUID)); + + return new SignalServiceEnvelope(type, + SignalServiceAddress.fromRaw(sourceUuid, sourceE164), + deviceId, + timestamp, + legacyMessage != null ? Base64.decode(legacyMessage) : null, + content != null ? Base64.decode(content) : null, + serverReceivedTimestamp, + serverDeliveredTimestamp, + serverGuid); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public void close() { + this.cursor.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java new file mode 100644 index 00000000..7a9a8061 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -0,0 +1,3344 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import net.sqlcipher.SQLException; +import net.sqlcipher.database.SQLiteConstraintException; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime; +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileKeyCredentialColumnData; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate; +import org.thoughtcrime.securesms.storage.StorageSyncModels; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Bitmask; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public class RecipientDatabase extends Database { + + private static final String TAG = RecipientDatabase.class.getSimpleName(); + + static final String TABLE_NAME = "recipient"; + public static final String ID = "_id"; + private static final String UUID = "uuid"; + private static final String USERNAME = "username"; + public static final String PHONE = "phone"; + public static final String EMAIL = "email"; + static final String GROUP_ID = "group_id"; + static final String GROUP_TYPE = "group_type"; + private static final String BLOCKED = "blocked"; + private static final String MESSAGE_RINGTONE = "message_ringtone"; + private static final String MESSAGE_VIBRATE = "message_vibrate"; + private static final String CALL_RINGTONE = "call_ringtone"; + private static final String CALL_VIBRATE = "call_vibrate"; + private static final String NOTIFICATION_CHANNEL = "notification_channel"; + private static final String MUTE_UNTIL = "mute_until"; + private static final String COLOR = "color"; + private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; + private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; + private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time"; + public static final String REGISTERED = "registered"; + public static final String SYSTEM_DISPLAY_NAME = "system_display_name"; + private static final String SYSTEM_PHOTO_URI = "system_photo_uri"; + public static final String SYSTEM_PHONE_TYPE = "system_phone_type"; + public static final String SYSTEM_PHONE_LABEL = "system_phone_label"; + private static final String SYSTEM_CONTACT_URI = "system_contact_uri"; + private static final String SYSTEM_INFO_PENDING = "system_info_pending"; + private static final String PROFILE_KEY = "profile_key"; + private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential"; + private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar"; + private static final String PROFILE_SHARING = "profile_sharing"; + private static final String LAST_PROFILE_FETCH = "last_profile_fetch"; + private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; + static final String FORCE_SMS_SELECTION = "force_sms_selection"; + private static final String CAPABILITIES = "capabilities"; + private static final String STORAGE_SERVICE_ID = "storage_service_key"; + private static final String DIRTY = "dirty"; + private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; + private static final String PROFILE_FAMILY_NAME = "profile_family_name"; + private static final String PROFILE_JOINED_NAME = "profile_joined_name"; + private static final String MENTION_SETTING = "mention_setting"; + private static final String STORAGE_PROTO = "storage_proto"; + private static final String LAST_GV1_MIGRATE_REMINDER = "last_gv1_migrate_reminder"; + private static final String LAST_SESSION_RESET = "last_session_reset"; + private static final String WALLPAPER = "wallpaper"; + private static final String WALLPAPER_URI = "wallpaper_file"; + public static final String ABOUT = "about"; + public static final String ABOUT_EMOJI = "about_emoji"; + + public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; + private static final String SORT_NAME = "sort_name"; + private static final String IDENTITY_STATUS = "identity_status"; + private static final String IDENTITY_KEY = "identity_key"; + + private static final class Capabilities { + static final int BIT_LENGTH = 2; + + static final int GROUPS_V2 = 0; + static final int GROUPS_V1_MIGRATION = 1; + } + + private static final String[] RECIPIENT_PROJECTION = new String[] { + ID, UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE, + BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED, + PROFILE_KEY, PROFILE_KEY_CREDENTIAL, + SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI, + PROFILE_GIVEN_NAME, PROFILE_FAMILY_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, LAST_PROFILE_FETCH, + NOTIFICATION_CHANNEL, + UNIDENTIFIED_ACCESS_MODE, + FORCE_SMS_SELECTION, + CAPABILITIES, + STORAGE_SERVICE_ID, DIRTY, + MENTION_SETTING, WALLPAPER, WALLPAPER_URI, + MENTION_SETTING, + ABOUT, ABOUT_EMOJI + }; + + private static final String[] ID_PROJECTION = new String[]{ID}; + private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, ABOUT, ABOUT_EMOJI, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME}; + public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, ABOUT, ABOUT_EMOJI, SEARCH_PROFILE_NAME, SORT_NAME}; + private static final String[] TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) + .map(columnName -> TABLE_NAME + "." + columnName) + .toList().toArray(new String[0]); + + static final String[] TYPED_RECIPIENT_PROJECTION_NO_ID = Arrays.copyOfRange(TYPED_RECIPIENT_PROJECTION, 1, TYPED_RECIPIENT_PROJECTION.length); + + private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME}; + + public static final String[] CREATE_INDEXS = new String[] { + "CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");", + "CREATE INDEX IF NOT EXISTS recipient_group_type_index ON " + TABLE_NAME + " (" + GROUP_TYPE + ");", + }; + + public enum VibrateState { + DEFAULT(0), ENABLED(1), DISABLED(2); + + private final int id; + + VibrateState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static VibrateState fromId(int id) { + return values()[id]; + } + + public static VibrateState fromBoolean(boolean enabled) { + return enabled ? ENABLED : DISABLED; + } + } + + public enum RegisteredState { + UNKNOWN(0), REGISTERED(1), NOT_REGISTERED(2); + + private final int id; + + RegisteredState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static RegisteredState fromId(int id) { + return values()[id]; + } + } + + public enum UnidentifiedAccessMode { + UNKNOWN(0), DISABLED(1), ENABLED(2), UNRESTRICTED(3); + + private final int mode; + + UnidentifiedAccessMode(int mode) { + this.mode = mode; + } + + public int getMode() { + return mode; + } + + public static UnidentifiedAccessMode fromMode(int mode) { + return values()[mode]; + } + } + + public enum InsightsBannerTier { + NO_TIER(0), TIER_ONE(1), TIER_TWO(2); + + private final int id; + + InsightsBannerTier(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public boolean seen(InsightsBannerTier tier) { + return tier.getId() <= id; + } + + public static InsightsBannerTier fromId(int id) { + return values()[id]; + } + } + + public enum DirtyState { + CLEAN(0), UPDATE(1), INSERT(2), DELETE(3); + + private final int id; + + DirtyState(int id) { + this.id = id; + } + + int getId() { + return id; + } + + public static DirtyState fromId(int id) { + return values()[id]; + } + } + + public enum GroupType { + NONE(0), MMS(1), SIGNAL_V1(2), SIGNAL_V2(3); + + private final int id; + + GroupType(int id) { + this.id = id; + } + + int getId() { + return id; + } + + public static GroupType fromId(int id) { + return values()[id]; + } + } + + public enum MentionSetting { + ALWAYS_NOTIFY(0), DO_NOT_NOTIFY(1); + + private final int id; + + MentionSetting(int id) { + this.id = id; + } + + int getId() { + return id; + } + + public static MentionSetting fromId(int id) { + return values()[id]; + } + } + + public static final String CREATE_TABLE = + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + UUID + " TEXT UNIQUE DEFAULT NULL, " + + USERNAME + " TEXT UNIQUE DEFAULT NULL, " + + PHONE + " TEXT UNIQUE DEFAULT NULL, " + + EMAIL + " TEXT UNIQUE DEFAULT NULL, " + + GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " + + GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " + + BLOCKED + " INTEGER DEFAULT 0," + + MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " + + MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + + CALL_RINGTONE + " TEXT DEFAULT NULL, " + + CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + + NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " + + MUTE_UNTIL + " INTEGER DEFAULT 0, " + + COLOR + " TEXT DEFAULT NULL, " + + SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " + + DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " + + REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " + + SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " + + SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " + + SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " + + SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " + + SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " + + SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " + + PROFILE_KEY + " TEXT DEFAULT NULL, " + + PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " + + PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " + + PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " + + PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " + + SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + + PROFILE_SHARING + " INTEGER DEFAULT 0, " + + LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " + + UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " + + FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + + STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " + + MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " + + STORAGE_PROTO + " TEXT DEFAULT NULL, " + + CAPABILITIES + " INTEGER DEFAULT 0, " + + LAST_GV1_MIGRATE_REMINDER + " INTEGER DEFAULT 0, " + + LAST_SESSION_RESET + " BLOB DEFAULT NULL, " + + WALLPAPER + " BLOB DEFAULT NULL, " + + WALLPAPER_URI + " TEXT DEFAULT NULL, " + + ABOUT + " TEXT DEFAULT NULL, " + + ABOUT_EMOJI + " TEXT DEFAULT NULL);"; + + private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + + " FROM " + TABLE_NAME + + " INNER JOIN " + ThreadDatabase.TABLE_NAME + + " ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + + " WHERE " + + TABLE_NAME + "." + GROUP_ID + " IS NULL AND " + + TABLE_NAME + "." + REGISTERED + " = " + RegisteredState.NOT_REGISTERED.id + " AND " + + TABLE_NAME + "." + SEEN_INVITE_REMINDER + " < " + InsightsBannerTier.TIER_TWO.id + " AND " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.HAS_SENT + " AND " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " > ?" + + " ORDER BY " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC LIMIT 50"; + + public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public @NonNull boolean containsPhoneOrUuid(@NonNull String id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = UUID + " = ? OR " + PHONE + " = ?"; + String[] args = new String[]{id, id}; + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, query, args, null, null, null)) { + return cursor != null && cursor.moveToFirst(); + } + } + + public @NonNull Optional getByE164(@NonNull String e164) { + return getByColumn(PHONE, e164); + } + + public @NonNull Optional getByEmail(@NonNull String email) { + return getByColumn(EMAIL, email); + } + + public @NonNull Optional getByGroupId(@NonNull GroupId groupId) { + return getByColumn(GROUP_ID, groupId.toString()); + + } + + public @NonNull Optional getByUuid(@NonNull UUID uuid) { + return getByColumn(UUID, uuid.toString()); + } + + public @NonNull Optional getByUsername(@NonNull String username) { + return getByColumn(USERNAME, username); + } + + public @NonNull RecipientId getAndPossiblyMerge(@Nullable UUID uuid, @Nullable String e164, boolean highTrust) { + if (uuid == null && e164 == null) { + throw new IllegalArgumentException("Must provide a UUID or E164!"); + } + + RecipientId recipientNeedingRefresh = null; + Pair remapped = null; + boolean transactionSuccessful = false; + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + Optional byE164 = e164 != null ? getByE164(e164) : Optional.absent(); + Optional byUuid = uuid != null ? getByUuid(uuid) : Optional.absent(); + + RecipientId finalId; + + if (!byE164.isPresent() && !byUuid.isPresent()) { + Log.i(TAG, "Discovered a completely new user. Inserting."); + if (highTrust) { + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(e164, uuid)); + finalId = RecipientId.from(id); + } else { + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(uuid == null ? e164 : null, uuid)); + finalId = RecipientId.from(id); + } + } else if (byE164.isPresent() && !byUuid.isPresent()) { + if (uuid != null) { + RecipientSettings e164Settings = getRecipientSettings(byE164.get()); + if (e164Settings.uuid != null) { + if (highTrust) { + Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to a new entry."); + + removePhoneNumber(byE164.get(), db); + recipientNeedingRefresh = byE164.get(); + + ContentValues insertValues = buildContentValuesForNewUser(e164, uuid); + insertValues.put(BLOCKED, e164Settings.blocked ? 1 : 0); + + long id = db.insert(TABLE_NAME, null, insertValues); + finalId = RecipientId.from(id); + } else { + Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. Low-trust, so making a new user for the UUID."); + + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid)); + finalId = RecipientId.from(id); + } + } else { + if (highTrust) { + Log.i(TAG, "Found out about a UUID for a known E164 user. High-trust, so updating."); + markRegisteredOrThrow(byE164.get(), uuid); + finalId = byE164.get(); + } else { + Log.i(TAG, "Found out about a UUID for a known E164 user. Low-trust, so making a new user for the UUID."); + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid)); + finalId = RecipientId.from(id); + } + } + } else { + finalId = byE164.get(); + } + } else if (!byE164.isPresent() && byUuid.isPresent()) { + if (e164 != null) { + if (highTrust) { + Log.i(TAG, "Found out about an E164 for a known UUID user. High-trust, so updating."); + setPhoneNumberOrThrow(byUuid.get(), e164); + finalId = byUuid.get(); + } else { + Log.i(TAG, "Found out about an E164 for a known UUID user. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } else { + finalId = byUuid.get(); + } + } else { + if (byE164.equals(byUuid)) { + finalId = byUuid.get(); + } else { + Log.w(TAG, "Hit a conflict between " + byE164.get() + " (E164) and " + byUuid.get() + " (UUID). They map to different recipients.", new Throwable()); + + RecipientSettings e164Settings = getRecipientSettings(byE164.get()); + + if (e164Settings.getUuid() != null) { + if (highTrust) { + Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to the UUID entry."); + + removePhoneNumber(byE164.get(), db); + recipientNeedingRefresh = byE164.get(); + + setPhoneNumberOrThrow(byUuid.get(), Objects.requireNonNull(e164)); + + finalId = byUuid.get(); + } else { + Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } else { + if (highTrust) { + Log.w(TAG, "We have one contact with just an E164, and another with UUID. High-trust, so merging the two rows together."); + finalId = merge(byUuid.get(), byE164.get()); + recipientNeedingRefresh = byUuid.get(); + remapped = new Pair<>(byE164.get(), byUuid.get()); + } else { + Log.w(TAG, "We have one contact with just an E164, and another with UUID. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } + } + } + + db.setTransactionSuccessful(); + transactionSuccessful = true; + return finalId; + } finally { + db.endTransaction(); + + if (transactionSuccessful) { + if (recipientNeedingRefresh != null) { + Recipient.live(recipientNeedingRefresh).refresh(); + RetrieveProfileJob.enqueue(recipientNeedingRefresh); + } + + if (remapped != null) { + Recipient.live(remapped.first()).refresh(remapped.second()); + } + + if (recipientNeedingRefresh != null || remapped != null) { + StorageSyncHelper.scheduleSyncForDataChange(); + RecipientId.clearCache(); + } + } + } + } + + private static ContentValues buildContentValuesForNewUser(@Nullable String e164, @Nullable UUID uuid) { + ContentValues values = new ContentValues(); + + values.put(PHONE, e164); + + if (uuid != null) { + values.put(UUID, uuid.toString().toLowerCase()); + values.put(REGISTERED, RegisteredState.REGISTERED.getId()); + values.put(DIRTY, DirtyState.INSERT.getId()); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); + } + + return values; + } + + + public @NonNull RecipientId getOrInsertFromUuid(@NonNull UUID uuid) { + return getOrInsertByColumn(UUID, uuid.toString()).recipientId; + } + + public @NonNull RecipientId getOrInsertFromE164(@NonNull String e164) { + return getOrInsertByColumn(PHONE, e164).recipientId; + } + + public @NonNull RecipientId getOrInsertFromEmail(@NonNull String email) { + return getOrInsertByColumn(EMAIL, email).recipientId; + } + + public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) { + Optional existing = getByGroupId(groupId); + + if (existing.isPresent()) { + return existing.get(); + } else if (groupId.isV1() && DatabaseFactory.getGroupDatabase(context).groupExists(groupId.requireV1().deriveV2MigrationGroupId())) { + throw new GroupDatabase.LegacyGroupInsertException(groupId); + } else if (groupId.isV2() && DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) { + throw new GroupDatabase.MissedGroupMigrationInsertException(groupId); + } else { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, groupId.toString()); + + long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + + if (id < 0) { + existing = getByColumn(GROUP_ID, groupId.toString()); + + if (existing.isPresent()) { + return existing.get(); + } else if (groupId.isV1() && DatabaseFactory.getGroupDatabase(context).groupExists(groupId.requireV1().deriveV2MigrationGroupId())) { + throw new GroupDatabase.LegacyGroupInsertException(groupId); + } else if (groupId.isV2() && DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) { + throw new GroupDatabase.MissedGroupMigrationInsertException(groupId); + } else { + throw new AssertionError("Failed to insert recipient!"); + } + } else { + ContentValues groupUpdates = new ContentValues(); + + if (groupId.isMms()) { + groupUpdates.put(GROUP_TYPE, GroupType.MMS.getId()); + } else { + if (groupId.isV2()) { + groupUpdates.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId()); + } else { + groupUpdates.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); + } + groupUpdates.put(DIRTY, DirtyState.INSERT.getId()); + groupUpdates.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); + } + + RecipientId recipientId = RecipientId.from(id); + + update(recipientId, groupUpdates); + + return recipientId; + } + } + } + + /** + * See {@link Recipient#externalPossiblyMigratedGroup(Context, GroupId)}. + */ + public @NonNull RecipientId getOrInsertFromPossiblyMigratedGroupId(@NonNull GroupId groupId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + Optional existing = getByColumn(GROUP_ID, groupId.toString()); + + if (existing.isPresent()) { + db.setTransactionSuccessful(); + return existing.get(); + } + + if (groupId.isV1()) { + Optional v2 = getByGroupId(groupId.requireV1().deriveV2MigrationGroupId()); + if (v2.isPresent()) { + db.setTransactionSuccessful(); + return v2.get(); + } + } + + if (groupId.isV2()) { + Optional v1 = DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()); + if (v1.isPresent()) { + db.setTransactionSuccessful(); + return v1.get().getRecipientId(); + } + } + + RecipientId id = getOrInsertFromGroupId(groupId); + + db.setTransactionSuccessful(); + return id; + } finally { + db.endTransaction(); + } + } + + public Cursor getBlocked() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + return database.query(TABLE_NAME, ID_PROJECTION, BLOCKED + " = 1", + null, null, null, null, null); + } + + public RecipientReader readerForBlocked(Cursor cursor) { + return new RecipientReader(cursor); + } + + public RecipientReader getRecipientsWithNotificationChannels() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = database.query(TABLE_NAME, ID_PROJECTION, NOTIFICATION_CHANNEL + " NOT NULL", + null, null, null, null, null); + + return new RecipientReader(cursor); + } + + public @NonNull RecipientSettings getRecipientSettings(@NonNull RecipientId id) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = ID + " = ?"; + String[] args = new String[] { id.serialize() }; + + try (Cursor cursor = database.query(TABLE_NAME, RECIPIENT_PROJECTION, query, args, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return getRecipientSettings(context, cursor); + } else { + Optional remapped = RemappedRecords.getInstance().getRecipient(context, id); + if (remapped.isPresent()) { + Log.w(TAG, "Missing recipient, but found it in the remapped records."); + return getRecipientSettings(remapped.get()); + } else { + throw new MissingRecipientException(id); + } + } + } + } + + public @NonNull DirtyState getDirtyState(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { DIRTY }, ID_WHERE, new String[] { recipientId.serialize() }, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return DirtyState.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(DIRTY))); + } + } + + return DirtyState.CLEAN; + } + + public @Nullable RecipientSettings getRecipientSettingsForSync(@NonNull RecipientId id) { + String query = TABLE_NAME + "." + ID + " = ?"; + String[] args = new String[]{id.serialize()}; + + List recipientSettingsForSync = getRecipientSettingsForSync(query, args); + + if (recipientSettingsForSync.isEmpty()) { + return null; + } + + if (recipientSettingsForSync.size() > 1) { + throw new AssertionError(); + } + + return recipientSettingsForSync.get(0); + } + + public @NonNull List getPendingRecipientSyncUpdates() { + String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()), Recipient.self().getId().serialize() }; + + return getRecipientSettingsForSync(query, args); + } + + public @NonNull List getPendingRecipientSyncInsertions() { + String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()), Recipient.self().getId().serialize() }; + + return getRecipientSettingsForSync(query, args); + } + + public @NonNull List getPendingRecipientSyncDeletions() { + String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize() }; + + return getRecipientSettingsForSync(query, args); + } + + public @Nullable RecipientSettings getByStorageId(@NonNull byte[] storageId) { + List result = getRecipientSettingsForSync(TABLE_NAME + "." + STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) }); + + if (result.size() > 0) { + return result.get(0); + } + + return null; + } + + public void markNeedsSync(@NonNull Collection recipientIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (RecipientId recipientId : recipientIds) { + markDirty(recipientId, DirtyState.UPDATE); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void markNeedsSync(@NonNull RecipientId recipientId) { + markDirty(recipientId, DirtyState.UPDATE); + } + + public void applyStorageIdUpdates(@NonNull Map storageIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + String query = ID + " = ?"; + + for (Map.Entry entry : storageIds.entrySet()) { + ContentValues values = new ContentValues(); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() }); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + for (RecipientId id : storageIds.keySet()) { + Recipient.live(id).refresh(); + } + } + + public void applyStorageSyncUpdates(@NonNull Collection contactInserts, + @NonNull Collection> contactUpdates, + @NonNull Collection groupV1Inserts, + @NonNull Collection> groupV1Updates, + @NonNull Collection groupV2Inserts, + @NonNull Collection> groupV2Updates) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Set needsRefresh = new HashSet<>(); + + db.beginTransaction(); + + try { + for (SignalContactRecord insert : contactInserts) { + ContentValues values = getValuesForStorageContact(insert, true); + long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + RecipientId recipientId = null; + + if (id < 0) { + values = getValuesForStorageContact(insert, false); + Log.w(TAG, "Failed to insert! It's likely that these were newly-registered users that were missed in the merge. Doing an update instead."); + + if (insert.getAddress().getNumber().isPresent()) { + try { + int count = db.update(TABLE_NAME, values, PHONE + " = ?", new String[] { insert.getAddress().getNumber().get() }); + Log.w(TAG, "Updated " + count + " users by E164."); + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Failed to update the UUID on an existing E164 user. Possibly merging."); + recipientId = getAndPossiblyMerge(insert.getAddress().getUuid().get(), insert.getAddress().getNumber().get(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Resulting id: " + recipientId); + } + } + + if (recipientId == null && insert.getAddress().getUuid().isPresent()) { + try { + int count = db.update(TABLE_NAME, values, UUID + " = ?", new String[] { insert.getAddress().getUuid().get().toString() }); + Log.w(TAG, "Updated " + count + " users by UUID."); + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Failed to update the E164 on an existing UUID user. Possibly merging."); + recipientId = getAndPossiblyMerge(insert.getAddress().getUuid().get(), insert.getAddress().getNumber().get(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Resulting id: " + recipientId); + } + } + + if (recipientId == null && insert.getAddress().getNumber().isPresent()) { + recipientId = getByE164(insert.getAddress().getNumber().get()).orNull(); + } + + if (recipientId == null && insert.getAddress().getUuid().isPresent()) { + recipientId = getByUuid(insert.getAddress().getUuid().get()).orNull(); + } + + if (recipientId == null) { + Log.w(TAG, "Failed to recover from a failed insert!"); + continue; + } + } else { + recipientId = RecipientId.from(id); + } + + if (insert.getIdentityKey().isPresent()) { + try { + IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); + + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState())); + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); + } + } + + threadDatabase.applyStorageSyncUpdate(recipientId, insert); + needsRefresh.add(recipientId); + } + + for (RecordUpdate update : contactUpdates) { + ContentValues values = getValuesForStorageContact(update.getNew(), false); + + try { + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Failed to update a user by storageId."); + + RecipientId recipientId = getByColumn(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getOld().getId().getRaw())).get(); + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Found user " + recipientId + ". Possibly merging."); + + recipientId = getAndPossiblyMerge(update.getNew().getAddress().getUuid().orNull(), update.getNew().getAddress().getNumber().orNull(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Merged into " + recipientId); + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } + + RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getId().getRaw()); + + if (StorageSyncHelper.profileKeyChanged(update)) { + clearProfileKeyCredential(recipientId); + } + + try { + Optional oldIdentityRecord = identityDatabase.getIdentity(recipientId); + + if (update.getNew().getIdentityKey().isPresent()) { + IdentityKey identityKey = new IdentityKey(update.getNew().getIdentityKey().get(), 0); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.getNew().getIdentityState())); + } + + Optional newIdentityRecord = identityDatabase.getIdentity(recipientId); + + if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED) && + (!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED)) + { + IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true); + } else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED) && + (oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED)) + { + IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true); + } + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to process identity key during update! Skipping.", e); + } + + threadDatabase.applyStorageSyncUpdate(recipientId, update.getNew()); + needsRefresh.add(recipientId); + } + + for (SignalGroupV1Record insert : groupV1Inserts) { + db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); + + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v1orThrow(insert.getGroupId())); + + threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert); + needsRefresh.add(recipient.getId()); + } + + for (RecordUpdate update : groupV1Updates) { + ContentValues values = getValuesForStorageGroupV1(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v1orThrow(update.getOld().getGroupId())); + + threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew()); + needsRefresh.add(recipient.getId()); + } + + for (SignalGroupV2Record insert : groupV2Inserts) { + GroupMasterKey masterKey = insert.getMasterKeyOrThrow(); + GroupId.V2 groupId = GroupId.v2(masterKey); + ContentValues values = getValuesForStorageGroupV2(insert); + long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + Recipient recipient = Recipient.externalGroupExact(context, groupId); + + if (id < 0) { + Log.w(TAG, String.format("Recipient %s is already linked to group %s", recipient.getId(), groupId)); + } else { + Log.i(TAG, String.format("Inserted recipient %s for group %s", recipient.getId(), groupId)); + } + + Log.i(TAG, "Creating restore placeholder for " + groupId); + DatabaseFactory.getGroupDatabase(context) + .create(masterKey, + DecryptedGroup.newBuilder() + .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) + .build()); + + Log.i(TAG, "Scheduling request for latest group info for " + groupId); + + ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId)); + + threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert); + needsRefresh.add(recipient.getId()); + } + + for (RecordUpdate update : groupV2Updates) { + ContentValues values = getValuesForStorageGroupV2(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + + GroupMasterKey masterKey = update.getOld().getMasterKeyOrThrow(); + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v2(masterKey)); + + threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew()); + needsRefresh.add(recipient.getId()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + for (RecipientId id : needsRefresh) { + Recipient.live(id).refresh(); + } + } + + public void applyStorageSyncUpdates(@NonNull StorageId storageId, SignalAccountRecord update) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + ProfileName profileName = ProfileName.fromParts(update.getGivenName().orNull(), update.getFamilyName().orNull()); + Optional localKey = ProfileKeyUtil.profileKeyOptional(Recipient.self().getProfileKey()); + Optional remoteKey = ProfileKeyUtil.profileKeyOptional(update.getProfileKey().orNull()); + String profileKey = remoteKey.or(localKey).transform(ProfileKey::serialize).transform(Base64::encodeBytes).orNull(); + + if (!remoteKey.isPresent()) { + Log.w(TAG, "Got an empty profile key while applying an account record update!"); + } + + values.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); + values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName()); + values.put(PROFILE_JOINED_NAME, profileName.toString()); + values.put(PROFILE_KEY, profileKey); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getId().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + if (update.hasUnknownFields()) { + values.put(STORAGE_PROTO, Base64.encodeBytes(update.serializeUnknownFields())); + } else { + values.putNull(STORAGE_PROTO); + } + + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(storageId.getRaw())}); + if (updateCount < 1) { + throw new AssertionError("Account update didn't match any rows!"); + } + + if (!remoteKey.equals(localKey)) { + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + } + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(Recipient.self().getId(), update); + + Recipient.self().live().refresh(); + } + + public void updatePhoneNumbers(@NonNull Map mapping) { + if (mapping.isEmpty()) return; + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + String query = PHONE + " = ?"; + + for (Map.Entry entry : mapping.entrySet()) { + ContentValues values = new ContentValues(); + values.put(PHONE, entry.getValue()); + + db.updateWithOnConflict(TABLE_NAME, values, query, new String[] { entry.getKey() }, SQLiteDatabase.CONFLICT_IGNORE); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private @NonNull RecipientId getByStorageKeyOrThrow(byte[] storageKey) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = STORAGE_SERVICE_ID + " = ?"; + String[] args = new String[]{Base64.encodeBytes(storageKey)}; + + try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + return RecipientId.from(id); + } else { + throw new AssertionError("No recipient with that storage key!"); + } + } + } + + private static @NonNull ContentValues getValuesForStorageContact(@NonNull SignalContactRecord contact, boolean isInsert) { + ContentValues values = new ContentValues(); + + if (contact.getAddress().getUuid().isPresent()) { + values.put(UUID, contact.getAddress().getUuid().get().toString()); + } + + ProfileName profileName = ProfileName.fromParts(contact.getGivenName().orNull(), contact.getFamilyName().orNull()); + String username = contact.getUsername().orNull(); + + values.put(PHONE, contact.getAddress().getNumber().orNull()); + values.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); + values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName()); + values.put(PROFILE_JOINED_NAME, profileName.toString()); + values.put(PROFILE_KEY, contact.getProfileKey().transform(Base64::encodeBytes).orNull()); + values.put(USERNAME, TextUtils.isEmpty(username) ? null : username); + values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0"); + values.put(BLOCKED, contact.isBlocked() ? "1" : "0"); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.getId().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + if (contact.isProfileSharingEnabled() && isInsert && !profileName.isEmpty()) { + values.put(COLOR, ContactColors.generateFor(profileName.toString()).serialize()); + } + + if (contact.hasUnknownFields()) { + values.put(STORAGE_PROTO, Base64.encodeBytes(contact.serializeUnknownFields())); + } else { + values.putNull(STORAGE_PROTO); + } + + return values; + } + + private static @NonNull ContentValues getValuesForStorageGroupV1(@NonNull SignalGroupV1Record groupV1) { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, GroupId.v1orThrow(groupV1.getGroupId()).toString()); + values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); + values.put(PROFILE_SHARING, groupV1.isProfileSharingEnabled() ? "1" : "0"); + values.put(BLOCKED, groupV1.isBlocked() ? "1" : "0"); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV1.getId().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + if (groupV1.hasUnknownFields()) { + values.put(STORAGE_PROTO, Base64.encodeBytes(groupV1.serializeUnknownFields())); + } else { + values.putNull(STORAGE_PROTO); + } + + return values; + } + + private static @NonNull ContentValues getValuesForStorageGroupV2(@NonNull SignalGroupV2Record groupV2) { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, GroupId.v2(groupV2.getMasterKeyOrThrow()).toString()); + values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId()); + values.put(PROFILE_SHARING, groupV2.isProfileSharingEnabled() ? "1" : "0"); + values.put(BLOCKED, groupV2.isBlocked() ? "1" : "0"); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV2.getId().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + if (groupV2.hasUnknownFields()) { + values.put(STORAGE_PROTO, Base64.encodeBytes(groupV2.serializeUnknownFields())); + } else { + values.putNull(STORAGE_PROTO); + } + + return values; + } + + private List getRecipientSettingsForSync(@Nullable String query, @Nullable String[] args) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID + + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + GROUP_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID + + " LEFT OUTER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID; + List out = new ArrayList<>(); + + String[] columns = Stream.of(TYPED_RECIPIENT_PROJECTION, + new String[]{ RecipientDatabase.TABLE_NAME + "." + STORAGE_PROTO, + GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY, + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ARCHIVED, + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.READ, + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS, + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY }) + .flatMap(Stream::of) + .toArray(String[]::new); + + try (Cursor cursor = db.query(table, columns, query, args, TABLE_NAME + "." + ID, null, null)) { + while (cursor != null && cursor.moveToNext()) { + out.add(getRecipientSettings(context, cursor)); + } + } + + return out; + } + + /** + * @return All storage ids for ContactRecords, excluding the ones that need to be deleted. + */ + public List getContactStorageSyncIds() { + return new ArrayList<>(getContactStorageSyncIdsMap().values()); + } + + /** + * @return All storage IDs for ContactRecords, excluding the ones that need to be deleted. + */ + public @NonNull Map getContactStorageSyncIdsMap() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " != ? AND " + ID + " != ? AND " + GROUP_TYPE + " != ?"; + String[] args = { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize(), String.valueOf(GroupType.SIGNAL_V2.getId()) }; + Map out = new HashMap<>(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_ID, GROUP_TYPE }, query, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); + String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID)); + GroupType groupType = GroupType.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE))); + byte[] key = Base64.decodeOrThrow(encodedKey); + + switch (groupType) { + case NONE : out.put(id, StorageId.forContact(key)); break; + case SIGNAL_V1 : out.put(id, StorageId.forGroupV1(key)); break; + default : throw new AssertionError(); + } + } + } + + for (GroupId.V2 id : DatabaseFactory.getGroupDatabase(context).getAllGroupV2Ids()) { + Recipient recipient = Recipient.externalGroupExact(context, id); + RecipientId recipientId = recipient.getId(); + RecipientSettings recipientSettingsForSync = getRecipientSettingsForSync(recipientId); + + if (recipientSettingsForSync == null) { + throw new AssertionError(); + } + + byte[] key = recipientSettingsForSync.storageId; + + if (key == null) { + throw new AssertionError(); + } + + out.put(recipientId, StorageId.forGroupV2(key)); + } + + return out; + } + + static @NonNull RecipientSettings getRecipientSettings(@NonNull Context context, @NonNull Cursor cursor) { + return getRecipientSettings(context, cursor, ID); + } + + static @NonNull RecipientSettings getRecipientSettings(@NonNull Context context, @NonNull Cursor cursor, @NonNull String idColumnName) { + long id = CursorUtil.requireLong(cursor, idColumnName); + UUID uuid = UuidUtil.parseOrNull(CursorUtil.requireString(cursor, UUID)); + String username = CursorUtil.requireString(cursor, USERNAME); + String e164 = CursorUtil.requireString(cursor, PHONE); + String email = CursorUtil.requireString(cursor, EMAIL); + GroupId groupId = GroupId.parseNullableOrThrow(CursorUtil.requireString(cursor, GROUP_ID)); + int groupType = CursorUtil.requireInt(cursor, GROUP_TYPE); + boolean blocked = CursorUtil.requireBoolean(cursor, BLOCKED); + String messageRingtone = CursorUtil.requireString(cursor, MESSAGE_RINGTONE); + String callRingtone = CursorUtil.requireString(cursor, CALL_RINGTONE); + int messageVibrateState = CursorUtil.requireInt(cursor, MESSAGE_VIBRATE); + int callVibrateState = CursorUtil.requireInt(cursor, CALL_VIBRATE); + long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); + String serializedColor = CursorUtil.requireString(cursor, COLOR); + int insightsBannerTier = CursorUtil.requireInt(cursor, SEEN_INVITE_REMINDER); + int defaultSubscriptionId = CursorUtil.requireInt(cursor, DEFAULT_SUBSCRIPTION_ID); + int expireMessages = CursorUtil.requireInt(cursor, MESSAGE_EXPIRATION_TIME); + int registeredState = CursorUtil.requireInt(cursor, REGISTERED); + String profileKeyString = CursorUtil.requireString(cursor, PROFILE_KEY); + String profileKeyCredentialString = CursorUtil.requireString(cursor, PROFILE_KEY_CREDENTIAL); + String systemDisplayName = CursorUtil.requireString(cursor, SYSTEM_DISPLAY_NAME); + String systemContactPhoto = CursorUtil.requireString(cursor, SYSTEM_PHOTO_URI); + String systemPhoneLabel = CursorUtil.requireString(cursor, SYSTEM_PHONE_LABEL); + String systemContactUri = CursorUtil.requireString(cursor, SYSTEM_CONTACT_URI); + String profileGivenName = CursorUtil.requireString(cursor, PROFILE_GIVEN_NAME); + String profileFamilyName = CursorUtil.requireString(cursor, PROFILE_FAMILY_NAME); + String signalProfileAvatar = CursorUtil.requireString(cursor, SIGNAL_PROFILE_AVATAR); + boolean profileSharing = CursorUtil.requireBoolean(cursor, PROFILE_SHARING); + long lastProfileFetch = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_PROFILE_FETCH)); + String notificationChannel = CursorUtil.requireString(cursor, NOTIFICATION_CHANNEL); + int unidentifiedAccessMode = CursorUtil.requireInt(cursor, UNIDENTIFIED_ACCESS_MODE); + boolean forceSmsSelection = CursorUtil.requireBoolean(cursor, FORCE_SMS_SELECTION); + long capabilities = CursorUtil.requireLong(cursor, CAPABILITIES); + String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); + int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); + byte[] wallpaper = CursorUtil.requireBlob(cursor, WALLPAPER); + String about = CursorUtil.requireString(cursor, ABOUT); + String aboutEmoji = CursorUtil.requireString(cursor, ABOUT_EMOJI); + + MaterialColor color; + byte[] profileKey = null; + ProfileKeyCredential profileKeyCredential = null; + + try { + color = serializedColor == null ? null : MaterialColor.fromSerialized(serializedColor); + } catch (MaterialColor.UnknownColorException e) { + Log.w(TAG, e); + color = null; + } + + if (profileKeyString != null) { + try { + profileKey = Base64.decode(profileKeyString); + } catch (IOException e) { + Log.w(TAG, e); + profileKey = null; + } + + if (profileKeyCredentialString != null) { + try { + byte[] columnDataBytes = Base64.decode(profileKeyCredentialString); + + ProfileKeyCredentialColumnData columnData = ProfileKeyCredentialColumnData.parseFrom(columnDataBytes); + + if (Arrays.equals(columnData.getProfileKey().toByteArray(), profileKey)) { + profileKeyCredential = new ProfileKeyCredential(columnData.getProfileKeyCredential().toByteArray()); + } else { + Log.i(TAG, "Out of date profile key credential data ignored on read"); + } + } catch (InvalidInputException | IOException e) { + Log.w(TAG, "Profile key credential column data could not be read", e); + } + } + } + + byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null; + + ChatWallpaper chatWallpaper = null; + + if (wallpaper != null) { + try { + chatWallpaper = ChatWallpaperFactory.create(Wallpaper.parseFrom(wallpaper)); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Failed to parse wallpaper.", e); + } + } + + return new RecipientSettings(RecipientId.from(id), + uuid, + username, + e164, + email, + groupId, + GroupType.fromId(groupType), + blocked, + muteUntil, + VibrateState.fromId(messageVibrateState), + VibrateState.fromId(callVibrateState), + Util.uri(messageRingtone), + Util.uri(callRingtone), + color, + defaultSubscriptionId, + expireMessages, + RegisteredState.fromId(registeredState), + profileKey, + profileKeyCredential, + systemDisplayName, + systemContactPhoto, + systemPhoneLabel, + systemContactUri, + ProfileName.fromParts(profileGivenName, profileFamilyName), + signalProfileAvatar, + AvatarHelper.hasAvatar(context, RecipientId.from(id)), + profileSharing, + lastProfileFetch, + notificationChannel, + UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), + forceSmsSelection, + capabilities, + InsightsBannerTier.fromId(insightsBannerTier), + storageKey, + MentionSetting.fromId(mentionSettingId), + chatWallpaper, + about, + aboutEmoji, + getSyncExtras(cursor)); + } + + private static @NonNull RecipientSettings.SyncExtras getSyncExtras(@NonNull Cursor cursor) { + String storageProtoRaw = CursorUtil.getString(cursor, STORAGE_PROTO).orNull(); + byte[] storageProto = storageProtoRaw != null ? Base64.decodeOrThrow(storageProtoRaw) : null; + boolean archived = CursorUtil.getBoolean(cursor, ThreadDatabase.ARCHIVED).or(false); + boolean forcedUnread = CursorUtil.getInt(cursor, ThreadDatabase.READ).transform(status -> status == ThreadDatabase.ReadStatus.FORCED_UNREAD.serialize()).or(false); + GroupMasterKey groupMasterKey = CursorUtil.getBlob(cursor, GroupDatabase.V2_MASTER_KEY).transform(GroupUtil::requireMasterKey).orNull(); + byte[] identityKey = CursorUtil.getString(cursor, IDENTITY_KEY).transform(Base64::decodeOrThrow).orNull(); + VerifiedStatus identityStatus = CursorUtil.getInt(cursor, IDENTITY_STATUS).transform(VerifiedStatus::forState).or(VerifiedStatus.DEFAULT); + + + return new RecipientSettings.SyncExtras(storageProto, groupMasterKey, identityKey, identityStatus, archived, forcedUnread); + } + + public BulkOperationsHandle beginBulkSystemContactUpdate() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.beginTransaction(); + + ContentValues contentValues = new ContentValues(1); + contentValues.put(SYSTEM_INFO_PENDING, 1); + + database.update(TABLE_NAME, contentValues, SYSTEM_CONTACT_URI + " NOT NULL", null); + + return new BulkOperationsHandle(database); + } + + public void setColor(@NonNull RecipientId id, @NonNull MaterialColor color) { + ContentValues values = new ContentValues(); + values.put(COLOR, color.serialize()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setColorIfNotSet(@NonNull RecipientId id, @NonNull MaterialColor color) { + if (setColorIfNotSetInternal(id, color)) { + Recipient.live(id).refresh(); + } + } + + private boolean setColorIfNotSetInternal(@NonNull RecipientId id, @NonNull MaterialColor color) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String query = ID + " = ? AND " + COLOR + " IS NULL"; + String[] args = new String[]{ id.serialize() }; + + ContentValues values = new ContentValues(); + values.put(COLOR, color.serialize()); + + return db.update(TABLE_NAME, values, query, args) > 0; + } + + public void setDefaultSubscriptionId(@NonNull RecipientId id, int defaultSubscriptionId) { + ContentValues values = new ContentValues(); + values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setForceSmsSelection(@NonNull RecipientId id, boolean forceSmsSelection) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(FORCE_SMS_SELECTION, forceSmsSelection ? 1 : 0); + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + } + } + + public void setBlocked(@NonNull RecipientId id, boolean blocked) { + ContentValues values = new ContentValues(); + values.put(BLOCKED, blocked ? 1 : 0); + if (update(id, values)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + } + } + + public void setMessageRingtone(@NonNull RecipientId id, @Nullable Uri notification) { + ContentValues values = new ContentValues(); + values.put(MESSAGE_RINGTONE, notification == null ? null : notification.toString()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setCallRingtone(@NonNull RecipientId id, @Nullable Uri ringtone) { + ContentValues values = new ContentValues(); + values.put(CALL_RINGTONE, ringtone == null ? null : ringtone.toString()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setMessageVibrate(@NonNull RecipientId id, @NonNull VibrateState enabled) { + ContentValues values = new ContentValues(); + values.put(MESSAGE_VIBRATE, enabled.getId()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setCallVibrate(@NonNull RecipientId id, @NonNull VibrateState enabled) { + ContentValues values = new ContentValues(); + values.put(CALL_VIBRATE, enabled.getId()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setMuted(@NonNull RecipientId id, long until) { + ContentValues values = new ContentValues(); + values.put(MUTE_UNTIL, until); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setSeenFirstInviteReminder(@NonNull RecipientId id) { + setInsightsBannerTier(id, InsightsBannerTier.TIER_ONE); + } + + public void setSeenSecondInviteReminder(@NonNull RecipientId id) { + setInsightsBannerTier(id, InsightsBannerTier.TIER_TWO); + } + + public void setHasSentInvite(@NonNull RecipientId id) { + setSeenSecondInviteReminder(id); + } + + private void setInsightsBannerTier(@NonNull RecipientId id, @NonNull InsightsBannerTier insightsBannerTier) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(1); + String query = ID + " = ? AND " + SEEN_INVITE_REMINDER + " < ?"; + String[] args = new String[]{ id.serialize(), String.valueOf(insightsBannerTier) }; + + values.put(SEEN_INVITE_REMINDER, insightsBannerTier.id); + database.update(TABLE_NAME, values, query, args); + Recipient.live(id).refresh(); + } + + public void setExpireMessages(@NonNull RecipientId id, int expiration) { + ContentValues values = new ContentValues(1); + values.put(MESSAGE_EXPIRATION_TIME, expiration); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setUnidentifiedAccessMode(@NonNull RecipientId id, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) { + ContentValues values = new ContentValues(1); + values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); + if (update(id, values)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + } + } + + public void markGroupsV1MigrationReminderSeen(@NonNull RecipientId id, long time) { + ContentValues values = new ContentValues(1); + values.put(LAST_GV1_MIGRATE_REMINDER, time); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public long getGroupsV1MigrationReminderLastSeen(@NonNull RecipientId id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { LAST_GV1_MIGRATE_REMINDER }, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) { + if (cursor.moveToFirst()) { + return CursorUtil.requireLong(cursor, LAST_GV1_MIGRATE_REMINDER); + } + } + + return 0; + } + + + public void setLastSessionResetTime(@NonNull RecipientId id, DeviceLastResetTime lastResetTime) { + ContentValues values = new ContentValues(1); + values.put(LAST_SESSION_RESET, lastResetTime.toByteArray()); + update(id, values); + } + + public @NonNull DeviceLastResetTime getLastSessionResetTimes(@NonNull RecipientId id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] {LAST_SESSION_RESET}, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) { + if (cursor.moveToFirst()) { + try { + byte[] serialized = CursorUtil.requireBlob(cursor, LAST_SESSION_RESET); + if (serialized != null) { + return DeviceLastResetTime.parseFrom(serialized); + } else { + return DeviceLastResetTime.newBuilder().build(); + } + } catch (InvalidProtocolBufferException | SQLException e) { + Log.w(TAG, e); + return DeviceLastResetTime.newBuilder().build(); + } + } + } + + return DeviceLastResetTime.newBuilder().build(); + } + + public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) { + long value = 0; + + value = Bitmask.update(value, Capabilities.GROUPS_V2, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isGv2()).serialize()); + value = Bitmask.update(value, Capabilities.GROUPS_V1_MIGRATION, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isGv1Migration()).serialize()); + + ContentValues values = new ContentValues(1); + values.put(CAPABILITIES, value); + + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public void setMentionSetting(@NonNull RecipientId id, @NonNull MentionSetting mentionSetting) { + ContentValues values = new ContentValues(); + values.put(MENTION_SETTING, mentionSetting.getId()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + /** + * Updates the profile key. + *

+ * If it changes, it clears out the profile key credential and resets the unidentified access mode. + * @return true iff changed. + */ + public boolean setProfileKey(@NonNull RecipientId id, @NonNull ProfileKey profileKey) { + String selection = ID + " = ?"; + String[] args = new String[]{id.serialize()}; + ContentValues valuesToCompare = new ContentValues(1); + ContentValues valuesToSet = new ContentValues(3); + String encodedProfileKey = Base64.encodeBytes(profileKey.serialize()); + + valuesToCompare.put(PROFILE_KEY, encodedProfileKey); + + valuesToSet.put(PROFILE_KEY, encodedProfileKey); + valuesToSet.putNull(PROFILE_KEY_CREDENTIAL); + valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode()); + + SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, valuesToCompare); + + if (update(updateQuery, valuesToSet)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + return true; + } + return false; + } + + /** + * Sets the profile key iff currently null. + *

+ * If it sets it, it also clears out the profile key credential and resets the unidentified access mode. + * @return true iff changed. + */ + public boolean setProfileKeyIfAbsent(@NonNull RecipientId id, @NonNull ProfileKey profileKey) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String selection = ID + " = ? AND " + PROFILE_KEY + " is NULL"; + String[] args = new String[]{id.serialize()}; + ContentValues valuesToSet = new ContentValues(3); + + valuesToSet.put(PROFILE_KEY, Base64.encodeBytes(profileKey.serialize())); + valuesToSet.putNull(PROFILE_KEY_CREDENTIAL); + valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode()); + + if (database.update(TABLE_NAME, valuesToSet, selection, args) > 0) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + return true; + } else { + return false; + } + } + + /** + * Updates the profile key credential as long as the profile key matches. + */ + public boolean setProfileKeyCredential(@NonNull RecipientId id, + @NonNull ProfileKey profileKey, + @NonNull ProfileKeyCredential profileKeyCredential) + { + String selection = ID + " = ? AND " + PROFILE_KEY + " = ?"; + String[] args = new String[]{id.serialize(), Base64.encodeBytes(profileKey.serialize())}; + ContentValues values = new ContentValues(1); + + ProfileKeyCredentialColumnData columnData = ProfileKeyCredentialColumnData.newBuilder() + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .setProfileKeyCredential(ByteString.copyFrom(profileKeyCredential.serialize())) + .build(); + + values.put(PROFILE_KEY_CREDENTIAL, Base64.encodeBytes(columnData.toByteArray())); + + SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values); + + boolean updated = update(updateQuery, values); + + if (updated) { + Recipient.live(id).refresh(); + } + + return updated; + } + + private void clearProfileKeyCredential(@NonNull RecipientId id) { + ContentValues values = new ContentValues(1); + values.putNull(PROFILE_KEY_CREDENTIAL); + if (update(id, values)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + } + } + + /** + * Fills in gaps (nulls) in profile key knowledge from new profile keys. + *

+ * If from authoritative source, this will overwrite local, otherwise it will only write to the + * database if missing. + */ + public Set persistProfileKeySet(@NonNull ProfileKeySet profileKeySet) { + Map profileKeys = profileKeySet.getProfileKeys(); + Map authoritativeProfileKeys = profileKeySet.getAuthoritativeProfileKeys(); + int totalKeys = profileKeys.size() + authoritativeProfileKeys.size(); + + if (totalKeys == 0) { + return Collections.emptySet(); + } + + Log.i(TAG, String.format(Locale.US, "Persisting %d Profile keys, %d of which are authoritative", totalKeys, authoritativeProfileKeys.size())); + + HashSet updated = new HashSet<>(totalKeys); + RecipientId selfId = Recipient.self().getId(); + + for (Map.Entry entry : profileKeys.entrySet()) { + RecipientId recipientId = getOrInsertFromUuid(entry.getKey()); + + if (setProfileKeyIfAbsent(recipientId, entry.getValue())) { + Log.i(TAG, "Learned new profile key"); + updated.add(recipientId); + } + } + + for (Map.Entry entry : authoritativeProfileKeys.entrySet()) { + RecipientId recipientId = getOrInsertFromUuid(entry.getKey()); + + if (selfId.equals(recipientId)) { + Log.i(TAG, "Seen authoritative update for self"); + if (!entry.getValue().equals(ProfileKeyUtil.getSelfProfileKey())) { + Log.w(TAG, "Seen authoritative update for self that didn't match local, scheduling storage sync"); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } else { + Log.i(TAG, String.format("Profile key from owner %s", recipientId)); + if (setProfileKey(recipientId, entry.getValue())) { + Log.i(TAG, "Learned new profile key from owner"); + updated.add(recipientId); + } + } + } + + return updated; + } + + public @NonNull List getSimilarRecipientIds(@NonNull Recipient recipient) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = SqlUtil.buildArgs(ID, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ") AS checked_name"); + String where = "checked_name = ?"; + + String[] arguments = SqlUtil.buildArgs(recipient.getProfileName().toString()); + + try (Cursor cursor = db.query(TABLE_NAME, projection, where, arguments, null, null, null)) { + if (cursor == null || cursor.getCount() == 0) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + results.add(RecipientId.from(CursorUtil.requireLong(cursor, ID))); + } + + return results; + } + } + + public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); + contentValues.put(PROFILE_FAMILY_NAME, profileName.getFamilyName()); + contentValues.put(PROFILE_JOINED_NAME, profileName.toString()); + if (update(id, contentValues)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void setProfileAvatar(@NonNull RecipientId id, @Nullable String profileAvatar) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar); + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + + if (id.equals(Recipient.self().getId())) { + markDirty(id, DirtyState.UPDATE); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + } + + public void setAbout(@NonNull RecipientId id, @Nullable String about, @Nullable String emoji) { + ContentValues contentValues = new ContentValues(); + contentValues.put(ABOUT, about); + contentValues.put(ABOUT_EMOJI, emoji); + + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + } + } + + public void setProfileSharing(@NonNull RecipientId id, @SuppressWarnings("SameParameterValue") boolean enabled) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(PROFILE_SHARING, enabled ? 1 : 0); + + boolean profiledUpdated = update(id, contentValues); + boolean colorUpdated = enabled && setColorIfNotSetInternal(id, ContactColors.generateFor(Recipient.resolved(id).getDisplayName(context))); + + if (profiledUpdated || colorUpdated) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void setNotificationChannel(@NonNull RecipientId id, @Nullable String notificationChannel) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(NOTIFICATION_CHANNEL, notificationChannel); + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + } + } + + public void resetAllWallpaper() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String[] selection = SqlUtil.buildArgs(ID, WALLPAPER_URI); + String where = WALLPAPER + " IS NOT NULL"; + List> idWithWallpaper = new LinkedList<>(); + + database.beginTransaction(); + + try { + try (Cursor cursor = database.query(TABLE_NAME, selection, where, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + idWithWallpaper.add(new Pair<>(RecipientId.from(CursorUtil.requireInt(cursor, ID)), + CursorUtil.getString(cursor, WALLPAPER_URI).orNull())); + } + } + + if (idWithWallpaper.isEmpty()) { + return; + } + + ContentValues values = new ContentValues(2); + values.put(WALLPAPER_URI, (String) null); + values.put(WALLPAPER, (byte[]) null); + + int rowsUpdated = database.update(TABLE_NAME, values, where, null); + if (rowsUpdated == idWithWallpaper.size()) { + for (Pair pair : idWithWallpaper) { + Recipient.live(pair.first()).refresh(); + if (pair.second() != null) { + WallpaperStorage.onWallpaperDeselected(context, Uri.parse(pair.second())); + } + } + } else { + throw new AssertionError("expected " + idWithWallpaper.size() + " but got " + rowsUpdated); + } + + } finally { + database.setTransactionSuccessful(); + database.endTransaction(); + } + + } + + public void setWallpaper(@NonNull RecipientId id, @Nullable ChatWallpaper chatWallpaper) { + setWallpaper(id, chatWallpaper != null ? chatWallpaper.serialize() : null); + } + + private void setWallpaper(@NonNull RecipientId id, @Nullable Wallpaper wallpaper) { + Uri existingWallpaperUri = getWallpaperUri(id); + + ContentValues values = new ContentValues(); + values.put(WALLPAPER, wallpaper != null ? wallpaper.toByteArray() : null); + + if (wallpaper != null && wallpaper.hasFile()) { + values.put(WALLPAPER_URI, wallpaper.getFile().getUri()); + } else { + values.putNull(WALLPAPER_URI); + } + + if (update(id, values)) { + Recipient.live(id).refresh(); + } + + if (existingWallpaperUri != null) { + WallpaperStorage.onWallpaperDeselected(context, existingWallpaperUri); + } + } + + public void setDimWallpaperInDarkTheme(@NonNull RecipientId id, boolean enabled) { + Wallpaper wallpaper = getWallpaper(id); + + if (wallpaper == null) { + throw new IllegalStateException("No wallpaper set for " + id); + } + + Wallpaper updated = wallpaper.toBuilder() + .setDimLevelInDarkTheme(enabled ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME : 0) + .build(); + + setWallpaper(id, updated); + } + + private @Nullable Wallpaper getWallpaper(@NonNull RecipientId id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] {WALLPAPER}, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) { + if (cursor.moveToFirst()) { + byte[] raw = CursorUtil.requireBlob(cursor, WALLPAPER); + + if (raw != null) { + try { + return Wallpaper.parseFrom(raw); + } catch (InvalidProtocolBufferException e) { + return null; + } + } else { + return null; + } + } + } + + return null; + } + + private @Nullable Uri getWallpaperUri(@NonNull RecipientId id) { + Wallpaper wallpaper = getWallpaper(id); + + if (wallpaper != null && wallpaper.hasFile()) { + return Uri.parse(wallpaper.getFile().getUri()); + } else { + return null; + } + } + + public int getWallpaperUriUsageCount(@NonNull Uri uri) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = WALLPAPER_URI + " = ?"; + String[] args = SqlUtil.buildArgs(uri); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { "COUNT(*)" }, query, args, null, null, null)) { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + /** + * @return True if setting the phone number resulted in changed recipientId, otherwise false. + */ + public boolean setPhoneNumber(@NonNull RecipientId id, @NonNull String e164) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + setPhoneNumberOrThrow(id, e164); + db.setTransactionSuccessful(); + return false; + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[setPhoneNumber] Hit a conflict when trying to update " + id + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(id); + RecipientId newId = getAndPossiblyMerge(existing.getUuid(), e164, true); + Log.w(TAG, "[setPhoneNumber] Resulting id: " + newId); + + db.setTransactionSuccessful(); + return !newId.equals(existing.getId()); + } finally { + db.endTransaction(); + } + } + + private void removePhoneNumber(@NonNull RecipientId recipientId, @NonNull SQLiteDatabase db) { + ContentValues values = new ContentValues(); + values.putNull(PHONE); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } + + /** + * Should only use if you are confident that this will not result in any contact merging. + */ + public void setPhoneNumberOrThrow(@NonNull RecipientId id, @NonNull String e164) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(PHONE, e164); + + if (update(id, contentValues)) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void setUsername(@NonNull RecipientId id, @Nullable String username) { + if (username != null) { + Optional existingUsername = getByUsername(username); + + if (existingUsername.isPresent() && !id.equals(existingUsername.get())) { + Log.i(TAG, "Username was previously thought to be owned by " + existingUsername.get() + ". Clearing their username."); + setUsername(existingUsername.get(), null); + } + } + + ContentValues contentValues = new ContentValues(1); + contentValues.put(USERNAME, username); + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void clearUsernameIfExists(@NonNull String username) { + Optional existingUsername = getByUsername(username); + + if (existingUsername.isPresent()) { + setUsername(existingUsername.get(), null); + } + } + + public Set getAllPhoneNumbers() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Set results = new HashSet<>(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { PHONE }, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String number = cursor.getString(cursor.getColumnIndexOrThrow(PHONE)); + + if (!TextUtils.isEmpty(number)) { + results.add(number); + } + } + } + + return results; + } + + /** + * @return True if setting the UUID resulted in changed recipientId, otherwise false. + */ + public boolean markRegistered(@NonNull RecipientId id, @NonNull UUID uuid) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + markRegisteredOrThrow(id, uuid); + db.setTransactionSuccessful(); + return false; + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[markRegistered] Hit a conflict when trying to update " + id + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(id); + RecipientId newId = getAndPossiblyMerge(uuid, existing.getE164(), true); + Log.w(TAG, "[markRegistered] Merged into " + newId); + + db.setTransactionSuccessful(); + return !newId.equals(existing.getId()); + } finally { + db.endTransaction(); + } + } + + /** + * Should only use if you are confident that this shouldn't result in any contact merging. + */ + public void markRegisteredOrThrow(@NonNull RecipientId id, @NonNull UUID uuid) { + ContentValues contentValues = new ContentValues(2); + contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + contentValues.put(UUID, uuid.toString().toLowerCase()); + + if (update(id, contentValues)) { + markDirty(id, DirtyState.INSERT); + Recipient.live(id).refresh(); + } + } + + /** + * Marks the user as registered without providing a UUID. This should only be used when one + * cannot be reasonably obtained. {@link #markRegistered(RecipientId, UUID)} should be strongly + * preferred. + */ + public void markRegistered(@NonNull RecipientId id) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + if (update(id, contentValues)) { + markDirty(id, DirtyState.INSERT); + Recipient.live(id).refresh(); + } + } + + public void markUnregistered(@NonNull RecipientId id) { + ContentValues contentValues = new ContentValues(2); + contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); + if (update(id, contentValues)) { + markDirty(id, DirtyState.DELETE); + Recipient.live(id).refresh(); + } + } + + public void bulkUpdatedRegisteredStatus(@NonNull Map registered, Collection unregistered) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + for (Map.Entry entry : registered.entrySet()) { + ContentValues values = new ContentValues(2); + values.put(REGISTERED, RegisteredState.REGISTERED.getId()); + + if (entry.getValue() != null) { + values.put(UUID, entry.getValue().toLowerCase()); + } + + try { + if (update(entry.getKey(), values)) { + markDirty(entry.getKey(), DirtyState.INSERT); + } + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[bulkUpdateRegisteredStatus] Hit a conflict when trying to update " + entry.getKey() + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(entry.getKey()); + RecipientId newId = getAndPossiblyMerge(UuidUtil.parseOrThrow(entry.getValue()), existing.getE164(), true); + Log.w(TAG, "[bulkUpdateRegisteredStatus] Merged into " + newId); + } + } + + for (RecipientId id : unregistered) { + ContentValues values = new ContentValues(2); + values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); + if (update(id, values)) { + markDirty(id, DirtyState.DELETE); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @Deprecated + public void setRegistered(@NonNull RecipientId id, RegisteredState registeredState) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(REGISTERED, registeredState.getId()); + + if (update(id, contentValues)) { + if (registeredState == RegisteredState.REGISTERED) { + markDirty(id, DirtyState.INSERT); + } else if (registeredState == RegisteredState.NOT_REGISTERED) { + markDirty(id, DirtyState.DELETE); + } + + Recipient.live(id).refresh(); + } + } + + @Deprecated + public void setRegistered(@NonNull Collection activeIds, + @NonNull Collection inactiveIds) + { + for (RecipientId activeId : activeIds) { + ContentValues registeredValues = new ContentValues(1); + registeredValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + + if (update(activeId, registeredValues)) { + markDirty(activeId, DirtyState.INSERT); + Recipient.live(activeId).refresh(); + } + } + + for (RecipientId inactiveId : inactiveIds) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); + + if (update(inactiveId, contentValues)) { + markDirty(inactiveId, DirtyState.DELETE); + Recipient.live(inactiveId).refresh(); + } + } + } + + /** + * Handles inserts the (e164, UUID) pairs, which could result in merges. Does not mark users as + * registered. + * + * @return A mapping of (RecipientId, UUID) + */ + public @NonNull Map bulkProcessCdsResult(@NonNull Map mapping) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + HashMap uuidMap = new HashMap<>(); + + db.beginTransaction(); + try { + for (Map.Entry entry : mapping.entrySet()) { + String e164 = entry.getKey(); + UUID uuid = entry.getValue(); + Optional uuidEntry = uuid != null ? getByUuid(uuid) : Optional.absent(); + + if (uuidEntry.isPresent()) { + boolean idChanged = setPhoneNumber(uuidEntry.get(), e164); + if (idChanged) { + uuidEntry = getByUuid(Objects.requireNonNull(uuid)); + } + } + + RecipientId id = uuidEntry.isPresent() ? uuidEntry.get() : getOrInsertFromE164(e164); + + uuidMap.put(id, uuid != null ? uuid.toString() : null); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return uuidMap; + } + + public @NonNull List getUninvitedRecipientsForInsights() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + final String[] args = new String[]{String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))}; + + try (Cursor cursor = db.rawQuery(INSIGHTS_INVITEE_LIST, args)) { + while (cursor != null && cursor.moveToNext()) { + results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))); + } + } + + return results; + } + + public @NonNull List getRegistered() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, REGISTERED + " = ?", new String[] {"1"}, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))); + } + } + + return results; + } + + public List getSystemContacts() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))); + } + } + + return results; + } + + public void updateSystemContactColors(@NonNull ColorUpdater updater) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map updates = new HashMap<>(); + + db.beginTransaction(); + try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID, COLOR, SYSTEM_DISPLAY_NAME}, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + MaterialColor newColor = updater.update(cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)), + cursor.getString(cursor.getColumnIndexOrThrow(COLOR))); + + ContentValues contentValues = new ContentValues(1); + contentValues.put(COLOR, newColor.serialize()); + db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] { String.valueOf(id) }); + + updates.put(RecipientId.from(id), newColor); + } + } finally { + db.setTransactionSuccessful(); + db.endTransaction(); + + Stream.of(updates.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh()); + } + } + + public @Nullable Cursor getSignalContacts(boolean includeSelf) { + String selection = BLOCKED + " = ? AND " + + REGISTERED + " = ? AND " + + GROUP_ID + " IS NULL AND " + + "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " + + "(" + SORT_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)"; + String[] args; + + if (includeSelf) { + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1" }; + } else { + selection += " AND " + ID + " != ?"; + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", Recipient.self().getId().serialize() }; + } + + String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE; + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); + } + + public @Nullable Cursor querySignalContacts(@NonNull String query, boolean includeSelf) { + query = buildCaseInsensitiveGlobPattern(query); + + String selection = BLOCKED + " = ? AND " + + REGISTERED + " = ? AND " + + GROUP_ID + " IS NULL AND " + + "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " + + "(" + + PHONE + " GLOB ? OR " + + SORT_NAME + " GLOB ? OR " + + USERNAME + " GLOB ?" + + ")"; + String[] args; + + if (includeSelf) { + args = new String[]{"0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query}; + } else { + selection += " AND " + ID + " != ?"; + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query, String.valueOf(Recipient.self().getId().toLong()) }; + } + + String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE; + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); + } + + public @Nullable Cursor getNonSignalContacts() { + String selection = BLOCKED + " = ? AND " + + REGISTERED + " != ? AND " + + GROUP_ID + " IS NULL AND " + + SYSTEM_DISPLAY_NAME + " NOT NULL AND " + + "(" + PHONE + " NOT NULL OR " + EMAIL + " NOT NULL)"; + String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) }; + String orderBy = SYSTEM_DISPLAY_NAME + ", " + PHONE; + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); + } + + public @Nullable Cursor queryNonSignalContacts(@NonNull String query) { + query = buildCaseInsensitiveGlobPattern(query); + + String selection = BLOCKED + " = ? AND " + + REGISTERED + " != ? AND " + + GROUP_ID + " IS NULL AND " + + SYSTEM_DISPLAY_NAME + " NOT NULL AND " + + "(" + PHONE + " NOT NULL OR " + EMAIL + " NOT NULL) AND " + + "(" + + PHONE + " GLOB ? OR " + + EMAIL + " GLOB ? OR " + + SYSTEM_DISPLAY_NAME + " GLOB ?" + + ")"; + String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query }; + String orderBy = SYSTEM_DISPLAY_NAME + ", " + PHONE; + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); + } + + public @Nullable Cursor queryAllContacts(@NonNull String query) { + query = buildCaseInsensitiveGlobPattern(query); + + String selection = BLOCKED + " = ? AND " + + "(" + + SORT_NAME + " GLOB ? OR " + + USERNAME + " GLOB ? OR " + + PHONE + " GLOB ? OR " + + EMAIL + " GLOB ?" + + ")"; + String[] args = new String[] { "0", query, query, query, query }; + + return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null); + } + + public @NonNull List queryRecipientsForMentions(@NonNull String query) { + return queryRecipientsForMentions(query, null); + } + + public @NonNull List queryRecipientsForMentions(@NonNull String query, @Nullable List recipientIds) { + query = buildCaseInsensitiveGlobPattern(query); + + String ids = null; + if (Util.hasItems(recipientIds)) { + ids = TextUtils.join(",", Stream.of(recipientIds).map(RecipientId::serialize).toList()); + } + + String selection = BLOCKED + " = 0 AND " + + (ids != null ? ID + " IN (" + ids + ") AND " : "") + + SORT_NAME + " GLOB ?"; + + List recipients = new ArrayList<>(); + try (RecipientDatabase.RecipientReader reader = new RecipientReader(databaseHelper.getReadableDatabase().query(TABLE_NAME, MENTION_SEARCH_PROJECTION, selection, SqlUtil.buildArgs(query), null, null, SORT_NAME))) { + Recipient recipient; + while ((recipient = reader.getNext()) != null) { + recipients.add(recipient); + } + } + return recipients; + } + + /** + * Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode + * characters. + * + * Ex: + * cat -> [cC][aA][tT] + */ + private static String buildCaseInsensitiveGlobPattern(@NonNull String query) { + if (TextUtils.isEmpty(query)) { + return "*"; + } + + StringBuilder pattern = new StringBuilder(); + + for (int i = 0, len = query.codePointCount(0, query.length()); i < len; i++) { + String point = StringUtil.codePointToString(query.codePointAt(i)); + + pattern.append("["); + pattern.append(point.toLowerCase()); + pattern.append(point.toUpperCase()); + pattern.append("]"); + } + + return "*" + pattern.toString() + "*"; + } + + public @NonNull List getRecipientsForMultiDeviceSync() { + String subquery = "SELECT " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " FROM " + ThreadDatabase.TABLE_NAME; + String selection = REGISTERED + " = ? AND " + + GROUP_ID + " IS NULL AND " + + ID + " != ? AND " + + "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + ID + " IN (" + subquery + "))"; + String[] args = new String[] { String.valueOf(RegisteredState.REGISTERED.getId()), Recipient.self().getId().serialize() }; + + List recipients = new ArrayList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, ID_PROJECTION, selection, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + recipients.add(Recipient.resolved(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))))); + } + } + + return recipients; + } + + /** + * @param lastInteractionThreshold Only include contacts that have been interacted with since this time. + * @param lastProfileFetchThreshold Only include contacts that haven't their profile fetched after this time. + * @param limit Only return at most this many contact. + */ + public List getRecipientsForRoutineProfileFetch(long lastInteractionThreshold, long lastProfileFetchThreshold, int limit) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Set recipientsWithinInteractionThreshold = new LinkedHashSet<>(); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false))) { + ThreadRecord record; + + while ((record = reader.getNext()) != null && record.getDate() > lastInteractionThreshold) { + Recipient recipient = Recipient.resolved(record.getRecipient().getId()); + + if (recipient.isGroup()) { + recipientsWithinInteractionThreshold.addAll(recipient.getParticipants()); + } else { + recipientsWithinInteractionThreshold.add(recipient); + } + } + } + + return Stream.of(recipientsWithinInteractionThreshold) + .filterNot(Recipient::isSelf) + .filter(r -> r.getLastProfileFetchTime() < lastProfileFetchThreshold) + .limit(limit) + .map(Recipient::getId) + .toList(); + } + + public void markProfilesFetched(@NonNull Collection ids, long time) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(1); + values.put(LAST_PROFILE_FETCH, time); + + for (RecipientId id : ids) { + db.update(TABLE_NAME, values, ID_WHERE, new String[] { id.serialize() }); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void applyBlockedUpdate(@NonNull List blocked, List groupIds) { + List blockedE164 = Stream.of(blocked) + .filter(b -> b.getNumber().isPresent()) + .map(b -> b.getNumber().get()) + .toList(); + List blockedUuid = Stream.of(blocked) + .filter(b -> b.getUuid().isPresent()) + .map(b -> b.getUuid().get().toString().toLowerCase()) + .toList(); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + ContentValues resetBlocked = new ContentValues(); + resetBlocked.put(BLOCKED, 0); + db.update(TABLE_NAME, resetBlocked, null, null); + + ContentValues setBlocked = new ContentValues(); + setBlocked.put(BLOCKED, 1); + setBlocked.put(PROFILE_SHARING, 0); + + for (String e164 : blockedE164) { + db.update(TABLE_NAME, setBlocked, PHONE + " = ?", new String[] { e164 }); + } + + for (String uuid : blockedUuid) { + db.update(TABLE_NAME, setBlocked, UUID + " = ?", new String[] { uuid }); + } + + List groupIdStrings = Stream.of(groupIds).map(GroupId::v1orThrow).toList(); + + for (GroupId.V1 groupId : groupIdStrings) { + db.update(TABLE_NAME, setBlocked, GROUP_ID + " = ?", new String[] { groupId.toString() }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + ApplicationDependencies.getRecipientCache().clear(); + } + + public void updateStorageKeys(@NonNull Map keys) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + for (Map.Entry entry : keys.entrySet()) { + ContentValues values = new ContentValues(); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue())); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + for (RecipientId id : keys.keySet()) { + Recipient.live(id).refresh(); + } + } + + public void clearDirtyState(@NonNull List recipients) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + ContentValues values = new ContentValues(); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + for (RecipientId id : recipients) { + Optional remapped = RemappedRecords.getInstance().getRecipient(context, id); + if (remapped.isPresent()) { + Log.w(TAG, "While clearing dirty state, noticed we have a remapped contact (" + id + " to " + remapped.get() + "). Safe to delete now."); + db.delete(TABLE_NAME, ID_WHERE, new String[]{id.serialize()}); + } else { + db.update(TABLE_NAME, values, ID_WHERE, new String[]{id.serialize()}); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) { + Log.d(TAG, "Attempting to mark " + recipientId + " with dirty state " + dirtyState); + + ContentValues contentValues = new ContentValues(1); + contentValues.put(DIRTY, dirtyState.getId()); + + String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL OR " + GROUP_ID + " NOT NULL) AND "; + String[] args = new String[] { recipientId.serialize(), String.valueOf(dirtyState.id) }; + + switch (dirtyState) { + case INSERT: + query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; + args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId())); + + contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); + break; + case DELETE: + query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; + args = SqlUtil.appendArg(args, String.valueOf(DirtyState.INSERT.getId())); + break; + default: + query += DIRTY + " < ?"; + } + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args); + } + + /** + * Updates a group recipient with a new V2 group ID. Should only be done as a part of GV1->GV2 + * migration. + */ + void updateGroupId(@NonNull GroupId.V1 v1Id, @NonNull GroupId.V2 v2Id) { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, v2Id.toString()); + values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId()); + + SqlUtil.Query query = SqlUtil.buildTrueUpdateQuery(GROUP_ID + " = ?", SqlUtil.buildArgs(v1Id), values); + + if (update(query, values)) { + RecipientId id = getByGroupId(v2Id).get(); + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + } + } + + /** + * Will update the database with the content values you specified. It will make an intelligent + * query such that this will only return true if a row was *actually* updated. + */ + private boolean update(@NonNull RecipientId id, @NonNull ContentValues contentValues) { + SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(id), contentValues); + + return update(updateQuery, contentValues); + } + + /** + * Will update the database with the {@param contentValues} you specified. + *

+ * This will only return true if a row was *actually* updated with respect to the where clause of the {@param updateQuery}. + */ + private boolean update(@NonNull SqlUtil.Query updateQuery, @NonNull ContentValues contentValues) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + return database.update(TABLE_NAME, contentValues, updateQuery.getWhere(), updateQuery.getWhereArgs()) > 0; + } + + private @NonNull Optional getByColumn(@NonNull String column, String value) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String query = column + " = ?"; + String[] args = new String[] { value }; + + try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return Optional.of(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))); + } else { + return Optional.absent(); + } + } + } + + private @NonNull GetOrInsertResult getOrInsertByColumn(@NonNull String column, String value) { + if (TextUtils.isEmpty(value)) { + throw new AssertionError(column + " cannot be empty."); + } + + Optional existing = getByColumn(column, value); + + if (existing.isPresent()) { + return new GetOrInsertResult(existing.get(), false); + } else { + ContentValues values = new ContentValues(); + values.put(column, value); + + long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + + if (id < 0) { + existing = getByColumn(column, value); + + if (existing.isPresent()) { + return new GetOrInsertResult(existing.get(), false); + } else { + throw new AssertionError("Failed to insert recipient!"); + } + } else { + return new GetOrInsertResult(RecipientId.from(id), true); + } + } + } + + /** + * Merges one UUID recipient with an E164 recipient. It is assumed that the E164 recipient does + * *not* have a UUID. + */ + @SuppressWarnings("ConstantConditions") + private @NonNull RecipientId merge(@NonNull RecipientId byUuid, @NonNull RecipientId byE164) { + ensureInTransaction(); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + RecipientSettings uuidSettings = getRecipientSettings(byUuid); + RecipientSettings e164Settings = getRecipientSettings(byE164); + + // Recipient + if (e164Settings.getStorageId() == null) { + Log.w(TAG, "No storageId on the E164 recipient. Can delete right away."); + db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164)); + } else { + Log.w(TAG, "The E164 recipient has a storageId. Clearing data and marking for deletion."); + ContentValues values = new ContentValues(); + values.putNull(PHONE); + values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); + values.put(DIRTY, DirtyState.DELETE.getId()); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(byE164)); + } + RemappedRecords.getInstance().addRecipient(context, byE164, byUuid); + + ContentValues uuidValues = new ContentValues(); + uuidValues.put(PHONE, e164Settings.getE164()); + uuidValues.put(BLOCKED, e164Settings.isBlocked() || uuidSettings.isBlocked()); + uuidValues.put(MESSAGE_RINGTONE, Optional.fromNullable(uuidSettings.getMessageRingtone()).or(Optional.fromNullable(e164Settings.getMessageRingtone())).transform(Uri::toString).orNull()); + uuidValues.put(MESSAGE_VIBRATE, uuidSettings.getMessageVibrateState() != VibrateState.DEFAULT ? uuidSettings.getMessageVibrateState().getId() : e164Settings.getMessageVibrateState().getId()); + uuidValues.put(CALL_RINGTONE, Optional.fromNullable(uuidSettings.getCallRingtone()).or(Optional.fromNullable(e164Settings.getCallRingtone())).transform(Uri::toString).orNull()); + uuidValues.put(CALL_VIBRATE, uuidSettings.getCallVibrateState() != VibrateState.DEFAULT ? uuidSettings.getCallVibrateState().getId() : e164Settings.getCallVibrateState().getId()); + uuidValues.put(NOTIFICATION_CHANNEL, uuidSettings.getNotificationChannel() != null ? uuidSettings.getNotificationChannel() : e164Settings.getNotificationChannel()); + uuidValues.put(MUTE_UNTIL, uuidSettings.getMuteUntil() > 0 ? uuidSettings.getMuteUntil() : e164Settings.getMuteUntil()); + uuidValues.put(COLOR, Optional.fromNullable(uuidSettings.getColor()).or(Optional.fromNullable(e164Settings.getColor())).transform(MaterialColor::serialize).orNull()); + uuidValues.put(SEEN_INVITE_REMINDER, e164Settings.getInsightsBannerTier().getId()); + uuidValues.put(DEFAULT_SUBSCRIPTION_ID, e164Settings.getDefaultSubscriptionId().or(-1)); + uuidValues.put(MESSAGE_EXPIRATION_TIME, uuidSettings.getExpireMessages() > 0 ? uuidSettings.getExpireMessages() : e164Settings.getExpireMessages()); + uuidValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + uuidValues.put(SYSTEM_DISPLAY_NAME, e164Settings.getSystemDisplayName()); + uuidValues.put(SYSTEM_PHOTO_URI, e164Settings.getSystemContactPhotoUri()); + uuidValues.put(SYSTEM_PHONE_LABEL, e164Settings.getSystemPhoneLabel()); + uuidValues.put(SYSTEM_CONTACT_URI, e164Settings.getSystemContactUri()); + uuidValues.put(PROFILE_SHARING, uuidSettings.isProfileSharing() || e164Settings.isProfileSharing()); + uuidValues.put(CAPABILITIES, Math.max(uuidSettings.getCapabilities(), e164Settings.getCapabilities())); + uuidValues.put(MENTION_SETTING, uuidSettings.getMentionSetting() != MentionSetting.ALWAYS_NOTIFY ? uuidSettings.getMentionSetting().getId() : e164Settings.getMentionSetting().getId()); + if (uuidSettings.getProfileKey() != null) { + updateProfileValuesForMerge(uuidValues, uuidSettings); + } else if (e164Settings.getProfileKey() != null) { + updateProfileValuesForMerge(uuidValues, e164Settings); + } + db.update(TABLE_NAME, uuidValues, ID_WHERE, SqlUtil.buildArgs(byUuid)); + + // Identities + db.delete(IdentityDatabase.TABLE_NAME, IdentityDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Group Receipts + ContentValues groupReceiptValues = new ContentValues(); + groupReceiptValues.put(GroupReceiptDatabase.RECIPIENT_ID, byUuid.serialize()); + db.update(GroupReceiptDatabase.TABLE_NAME, groupReceiptValues, GroupReceiptDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Groups + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + for (GroupDatabase.GroupRecord group : groupDatabase.getGroupsContainingMember(byE164, false, true)) { + List newMembers = new ArrayList<>(group.getMembers()); + newMembers.remove(byE164); + + ContentValues groupValues = new ContentValues(); + groupValues.put(GroupDatabase.MEMBERS, RecipientId.toSerializedList(newMembers)); + db.update(GroupDatabase.TABLE_NAME, groupValues, GroupDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(group.getRecipientId())); + } + + // Threads + ThreadDatabase.MergeResult threadMerge = DatabaseFactory.getThreadDatabase(context).merge(byUuid, byE164); + + // SMS Messages + ContentValues smsValues = new ContentValues(); + smsValues.put(SmsDatabase.RECIPIENT_ID, byUuid.serialize()); + if (threadMerge.neededMerge) { + smsValues.put(SmsDatabase.THREAD_ID, threadMerge.threadId); + } + db.update(SmsDatabase.TABLE_NAME, smsValues, SmsDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // MMS Messages + ContentValues mmsValues = new ContentValues(); + mmsValues.put(MmsDatabase.RECIPIENT_ID, byUuid.serialize()); + if (threadMerge.neededMerge) { + mmsValues.put(MmsDatabase.THREAD_ID, threadMerge.threadId); + } + db.update(MmsDatabase.TABLE_NAME, mmsValues, MmsDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Sessions + boolean hasE164Session = DatabaseFactory.getSessionDatabase(context).getAllFor(byE164).size() > 0; + boolean hasUuidSession = DatabaseFactory.getSessionDatabase(context).getAllFor(byUuid).size() > 0; + + if (hasE164Session && hasUuidSession) { + Log.w(TAG, "Had a session for both users. Deleting the E164."); + db.delete(SessionDatabase.TABLE_NAME, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + } else if (hasE164Session && !hasUuidSession) { + Log.w(TAG, "Had a session for E164, but not UUID. Re-assigning to the UUID."); + ContentValues values = new ContentValues(); + values.put(SessionDatabase.RECIPIENT_ID, byUuid.serialize()); + db.update(SessionDatabase.TABLE_NAME, values, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + } else if (!hasE164Session && hasUuidSession) { + Log.w(TAG, "Had a session for UUID, but not E164. No action necessary."); + } else { + Log.w(TAG, "Had no sessions. No action necessary."); + } + + // Mentions + ContentValues mentionRecipientValues = new ContentValues(); + mentionRecipientValues.put(MentionDatabase.RECIPIENT_ID, byUuid.serialize()); + db.update(MentionDatabase.TABLE_NAME, mentionRecipientValues, MentionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + if (threadMerge.neededMerge) { + ContentValues mentionThreadValues = new ContentValues(); + mentionThreadValues.put(MentionDatabase.THREAD_ID, threadMerge.threadId); + db.update(MentionDatabase.TABLE_NAME, mentionThreadValues, MentionDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(threadMerge.previousThreadId)); + } + + DatabaseFactory.getThreadDatabase(context).update(threadMerge.threadId, false, false); + + return byUuid; + } + + private static void updateProfileValuesForMerge(@NonNull ContentValues values, @NonNull RecipientSettings settings) { + values.put(PROFILE_KEY, settings.getProfileKey() != null ? Base64.encodeBytes(settings.getProfileKey()) : null); + values.putNull(PROFILE_KEY_CREDENTIAL); + values.put(SIGNAL_PROFILE_AVATAR, settings.getProfileAvatar()); + values.put(PROFILE_GIVEN_NAME, settings.getProfileName().getGivenName()); + values.put(PROFILE_FAMILY_NAME, settings.getProfileName().getFamilyName()); + values.put(PROFILE_JOINED_NAME, settings.getProfileName().toString()); + } + + private void ensureInTransaction() { + if (!databaseHelper.getWritableDatabase().inTransaction()) { + throw new IllegalStateException("Must be in a transaction!"); + } + } + + public class BulkOperationsHandle { + + private final SQLiteDatabase database; + + private final Map pendingContactInfoMap = new HashMap<>(); + + BulkOperationsHandle(SQLiteDatabase database) { + this.database = database; + } + + public void setSystemContactInfo(@NonNull RecipientId id, + @Nullable String displayName, + @Nullable String photoUri, + @Nullable String systemPhoneLabel, + int systemPhoneType, + @Nullable String systemContactUri) + { + ContentValues dirtyQualifyingValues = new ContentValues(); + dirtyQualifyingValues.put(SYSTEM_DISPLAY_NAME, displayName); + + if (update(id, dirtyQualifyingValues)) { + markDirty(id, DirtyState.UPDATE); + } + + ContentValues refreshQualifyingValues = new ContentValues(); + refreshQualifyingValues.put(SYSTEM_PHOTO_URI, photoUri); + refreshQualifyingValues.put(SYSTEM_PHONE_LABEL, systemPhoneLabel); + refreshQualifyingValues.put(SYSTEM_PHONE_TYPE, systemPhoneType); + refreshQualifyingValues.put(SYSTEM_CONTACT_URI, systemContactUri); + + boolean updatedValues = update(id, refreshQualifyingValues); + boolean updatedColor = displayName != null && setColorIfNotSetInternal(id, ContactColors.generateFor(displayName)); + + if (updatedValues || updatedColor) { + pendingContactInfoMap.put(id, new PendingContactInfo(displayName, photoUri, systemPhoneLabel, systemContactUri)); + } + + ContentValues otherValues = new ContentValues(); + otherValues.put(SYSTEM_INFO_PENDING, 0); + update(id, otherValues); + } + + public void finish() { + markAllRelevantEntriesDirty(); + clearSystemDataForPendingInfo(); + + database.setTransactionSuccessful(); + database.endTransaction(); + + Stream.of(pendingContactInfoMap.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh()); + } + + private void markAllRelevantEntriesDirty() { + String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " < ?"; + String[] args = new String[] { "1", String.valueOf(DirtyState.UPDATE.getId()) }; + + ContentValues values = new ContentValues(1); + values.put(DIRTY, DirtyState.UPDATE.getId()); + + database.update(TABLE_NAME, values, query, args); + } + + private void clearSystemDataForPendingInfo() { + String query = SYSTEM_INFO_PENDING + " = ?"; + String[] args = new String[] { "1" }; + + ContentValues values = new ContentValues(5); + + values.put(SYSTEM_INFO_PENDING, 0); + values.put(SYSTEM_DISPLAY_NAME, (String) null); + values.put(SYSTEM_PHOTO_URI, (String) null); + values.put(SYSTEM_PHONE_LABEL, (String) null); + values.put(SYSTEM_CONTACT_URI, (String) null); + + database.update(TABLE_NAME, values, query, args); + } + } + + private static @NonNull String nullIfEmpty(String column) { + return "NULLIF(" + column + ", '')"; + } + + private static @NonNull String removeWhitespace(@NonNull String column) { + return "REPLACE(" + column + ", ' ', '')"; + } + + public interface ColorUpdater { + MaterialColor update(@NonNull String name, @Nullable String color); + } + + public static class RecipientSettings { + private final RecipientId id; + private final UUID uuid; + private final String username; + private final String e164; + private final String email; + private final GroupId groupId; + private final GroupType groupType; + private final boolean blocked; + private final long muteUntil; + private final VibrateState messageVibrateState; + private final VibrateState callVibrateState; + private final Uri messageRingtone; + private final Uri callRingtone; + private final MaterialColor color; + private final int defaultSubscriptionId; + private final int expireMessages; + private final RegisteredState registered; + private final byte[] profileKey; + private final ProfileKeyCredential profileKeyCredential; + private final String systemDisplayName; + private final String systemContactPhoto; + private final String systemPhoneLabel; + private final String systemContactUri; + private final ProfileName signalProfileName; + private final String signalProfileAvatar; + private final boolean hasProfileImage; + private final boolean profileSharing; + private final long lastProfileFetch; + private final String notificationChannel; + private final UnidentifiedAccessMode unidentifiedAccessMode; + private final boolean forceSmsSelection; + private final long capabilities; + private final Recipient.Capability groupsV2Capability; + private final Recipient.Capability groupsV1MigrationCapability; + private final InsightsBannerTier insightsBannerTier; + private final byte[] storageId; + private final MentionSetting mentionSetting; + private final ChatWallpaper wallpaper; + private final String about; + private final String aboutEmoji; + private final SyncExtras syncExtras; + + RecipientSettings(@NonNull RecipientId id, + @Nullable UUID uuid, + @Nullable String username, + @Nullable String e164, + @Nullable String email, + @Nullable GroupId groupId, + @NonNull GroupType groupType, + boolean blocked, + long muteUntil, + @NonNull VibrateState messageVibrateState, + @NonNull VibrateState callVibrateState, + @Nullable Uri messageRingtone, + @Nullable Uri callRingtone, + @Nullable MaterialColor color, + int defaultSubscriptionId, + int expireMessages, + @NonNull RegisteredState registered, + @Nullable byte[] profileKey, + @Nullable ProfileKeyCredential profileKeyCredential, + @Nullable String systemDisplayName, + @Nullable String systemContactPhoto, + @Nullable String systemPhoneLabel, + @Nullable String systemContactUri, + @NonNull ProfileName signalProfileName, + @Nullable String signalProfileAvatar, + boolean hasProfileImage, + boolean profileSharing, + long lastProfileFetch, + @Nullable String notificationChannel, + @NonNull UnidentifiedAccessMode unidentifiedAccessMode, + boolean forceSmsSelection, + long capabilities, + @NonNull InsightsBannerTier insightsBannerTier, + @Nullable byte[] storageId, + @NonNull MentionSetting mentionSetting, + @Nullable ChatWallpaper wallpaper, + @Nullable String about, + @Nullable String aboutEmoji, + @NonNull SyncExtras syncExtras) + { + this.id = id; + this.uuid = uuid; + this.username = username; + this.e164 = e164; + this.email = email; + this.groupId = groupId; + this.groupType = groupType; + this.blocked = blocked; + this.muteUntil = muteUntil; + this.messageVibrateState = messageVibrateState; + this.callVibrateState = callVibrateState; + this.messageRingtone = messageRingtone; + this.callRingtone = callRingtone; + this.color = color; + this.defaultSubscriptionId = defaultSubscriptionId; + this.expireMessages = expireMessages; + this.registered = registered; + this.profileKey = profileKey; + this.profileKeyCredential = profileKeyCredential; + this.systemDisplayName = systemDisplayName; + this.systemContactPhoto = systemContactPhoto; + this.systemPhoneLabel = systemPhoneLabel; + this.systemContactUri = systemContactUri; + this.signalProfileName = signalProfileName; + this.signalProfileAvatar = signalProfileAvatar; + this.hasProfileImage = hasProfileImage; + this.profileSharing = profileSharing; + this.lastProfileFetch = lastProfileFetch; + this.notificationChannel = notificationChannel; + this.unidentifiedAccessMode = unidentifiedAccessMode; + this.forceSmsSelection = forceSmsSelection; + this.capabilities = capabilities; + this.groupsV2Capability = Recipient.Capability.deserialize((int) Bitmask.read(capabilities, Capabilities.GROUPS_V2, Capabilities.BIT_LENGTH)); + this.groupsV1MigrationCapability = Recipient.Capability.deserialize((int) Bitmask.read(capabilities, Capabilities.GROUPS_V1_MIGRATION, Capabilities.BIT_LENGTH)); + this.insightsBannerTier = insightsBannerTier; + this.storageId = storageId; + this.mentionSetting = mentionSetting; + this.wallpaper = wallpaper; + this.about = about; + this.aboutEmoji = aboutEmoji; + this.syncExtras = syncExtras; + } + + public RecipientId getId() { + return id; + } + + public @Nullable UUID getUuid() { + return uuid; + } + + public @Nullable String getUsername() { + return username; + } + + public @Nullable String getE164() { + return e164; + } + + public @Nullable String getEmail() { + return email; + } + + public @Nullable GroupId getGroupId() { + return groupId; + } + + public @NonNull GroupType getGroupType() { + return groupType; + } + + public @Nullable MaterialColor getColor() { + return color; + } + + public boolean isBlocked() { + return blocked; + } + + public long getMuteUntil() { + return muteUntil; + } + + public @NonNull VibrateState getMessageVibrateState() { + return messageVibrateState; + } + + public @NonNull VibrateState getCallVibrateState() { + return callVibrateState; + } + + public @Nullable Uri getMessageRingtone() { + return messageRingtone; + } + + public @Nullable Uri getCallRingtone() { + return callRingtone; + } + + public @NonNull InsightsBannerTier getInsightsBannerTier() { + return insightsBannerTier; + } + + public Optional getDefaultSubscriptionId() { + return defaultSubscriptionId != -1 ? Optional.of(defaultSubscriptionId) : Optional.absent(); + } + + public int getExpireMessages() { + return expireMessages; + } + + public RegisteredState getRegistered() { + return registered; + } + + public @Nullable byte[] getProfileKey() { + return profileKey; + } + + public @Nullable ProfileKeyCredential getProfileKeyCredential() { + return profileKeyCredential; + } + + public @Nullable String getSystemDisplayName() { + return systemDisplayName; + } + + public @Nullable String getSystemContactPhotoUri() { + return systemContactPhoto; + } + + public @Nullable String getSystemPhoneLabel() { + return systemPhoneLabel; + } + + public @Nullable String getSystemContactUri() { + return systemContactUri; + } + + public @NonNull ProfileName getProfileName() { + return signalProfileName; + } + + public @Nullable String getProfileAvatar() { + return signalProfileAvatar; + } + + public boolean hasProfileImage() { + return hasProfileImage; + } + + public boolean isProfileSharing() { + return profileSharing; + } + + public long getLastProfileFetch() { + return lastProfileFetch; + } + + public @Nullable String getNotificationChannel() { + return notificationChannel; + } + + public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() { + return unidentifiedAccessMode; + } + + public boolean isForceSmsSelection() { + return forceSmsSelection; + } + + public @NonNull Recipient.Capability getGroupsV2Capability() { + return groupsV2Capability; + } + + public @NonNull Recipient.Capability getGroupsV1MigrationCapability() { + return groupsV1MigrationCapability; + } + + public @Nullable byte[] getStorageId() { + return storageId; + } + + public @NonNull MentionSetting getMentionSetting() { + return mentionSetting; + } + + public @Nullable ChatWallpaper getWallpaper() { + return wallpaper; + } + + public @Nullable String getAbout() { + return about; + } + + public @Nullable String getAboutEmoji() { + return aboutEmoji; + } + + public @NonNull SyncExtras getSyncExtras() { + return syncExtras; + } + + long getCapabilities() { + return capabilities; + } + + /** + * A bundle of data that's only necessary when syncing to storage service, not for a + * {@link Recipient}. + */ + public static class SyncExtras { + private final byte[] storageProto; + private final GroupMasterKey groupMasterKey; + private final byte[] identityKey; + private final VerifiedStatus identityStatus; + private final boolean archived; + private final boolean forcedUnread; + + public SyncExtras(@Nullable byte[] storageProto, + @Nullable GroupMasterKey groupMasterKey, + @Nullable byte[] identityKey, + @NonNull VerifiedStatus identityStatus, + boolean archived, + boolean forcedUnread) + { + this.storageProto = storageProto; + this.groupMasterKey = groupMasterKey; + this.identityKey = identityKey; + this.identityStatus = identityStatus; + this.archived = archived; + this.forcedUnread = forcedUnread; + } + + public @Nullable byte[] getStorageProto() { + return storageProto; + } + + public @Nullable GroupMasterKey getGroupMasterKey() { + return groupMasterKey; + } + + public boolean isArchived() { + return archived; + } + + public @Nullable byte[] getIdentityKey() { + return identityKey; + } + + public @NonNull VerifiedStatus getIdentityStatus() { + return identityStatus; + } + + public boolean isForcedUnread() { + return forcedUnread; + } + } + } + + public static class RecipientReader implements Closeable { + + private final Cursor cursor; + + RecipientReader(Cursor cursor) { + this.cursor = cursor; + } + + public @NonNull Recipient getCurrent() { + RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); + return Recipient.resolved(id); + } + + public @Nullable Recipient getNext() { + if (cursor != null && !cursor.moveToNext()) { + return null; + } + + return getCurrent(); + } + + public int getCount() { + if (cursor != null) return cursor.getCount(); + else return 0; + } + + public void close() { + cursor.close(); + } + } + + public final static class RecipientIdResult { + private final RecipientId recipientId; + private final boolean requiresDirectoryRefresh; + + public RecipientIdResult(@NonNull RecipientId recipientId, boolean requiresDirectoryRefresh) { + this.recipientId = recipientId; + this.requiresDirectoryRefresh = requiresDirectoryRefresh; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public boolean requiresDirectoryRefresh() { + return requiresDirectoryRefresh; + } + } + + private static class PendingContactInfo { + + private final String displayName; + private final String photoUri; + private final String phoneLabel; + private final String contactUri; + + private PendingContactInfo(String displayName, String photoUri, String phoneLabel, String contactUri) { + this.displayName = displayName; + this.photoUri = photoUri; + this.phoneLabel = phoneLabel; + this.contactUri = contactUri; + } + } + + public static class MissingRecipientException extends IllegalStateException { + public MissingRecipientException(@Nullable RecipientId id) { + super("Failed to find recipient with ID: " + id); + } + } + + private static class GetOrInsertResult { + final RecipientId recipientId; + final boolean neededInsert; + + private GetOrInsertResult(@NonNull RecipientId recipientId, boolean neededInsert) { + this.recipientId = recipientId; + this.neededInsert = neededInsert; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java new file mode 100644 index 00000000..3b147a4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Map; + +/** + * Merging together recipients and threads is messy business. We can easily replace *almost* all of + * the references, but there are specific places (notably reactions, jobs, etc) that are really + * expensive to address. For these cases, we keep mappings of old IDs to new ones to use as a + * fallback. + * + * There should be very few of these, so we keep them in a fast, lazily-loaded memory cache. + * + * One important thing to note is that this class will often be accesses inside of database + * transactions. As a result, it cannot attempt to acquire a database lock while holding a + * separate lock. Instead, we use the database lock itself as a locking mechanism. + */ +class RemappedRecords { + + private static final RemappedRecords INSTANCE = new RemappedRecords(); + + private Map recipientMap; + private Map threadMap; + + private RemappedRecords() {} + + static RemappedRecords getInstance() { + return INSTANCE; + } + + @NonNull Optional getRecipient(@NonNull Context context, @NonNull RecipientId oldId) { + ensureRecipientMapIsPopulated(context); + return Optional.fromNullable(recipientMap.get(oldId)); + } + + @NonNull Optional getThread(@NonNull Context context, long oldId) { + ensureThreadMapIsPopulated(context); + return Optional.fromNullable(threadMap.get(oldId)); + } + + /** + * Can only be called inside of a transaction. + */ + void addRecipient(@NonNull Context context, @NonNull RecipientId oldId, @NonNull RecipientId newId) { + ensureInTransaction(context); + ensureRecipientMapIsPopulated(context); + recipientMap.put(oldId, newId); + DatabaseFactory.getRemappedRecordsDatabase(context).addRecipientMapping(oldId, newId); + } + + /** + * Can only be called inside of a transaction. + */ + void addThread(@NonNull Context context, long oldId, long newId) { + ensureInTransaction(context); + ensureThreadMapIsPopulated(context); + threadMap.put(oldId, newId); + DatabaseFactory.getRemappedRecordsDatabase(context).addThreadMapping(oldId, newId); + } + + private void ensureRecipientMapIsPopulated(@NonNull Context context) { + if (recipientMap == null) { + recipientMap = DatabaseFactory.getRemappedRecordsDatabase(context).getAllRecipientMappings(); + } + } + + private void ensureThreadMapIsPopulated(@NonNull Context context) { + if (threadMap == null) { + threadMap = DatabaseFactory.getRemappedRecordsDatabase(context).getAllThreadMappings(); + } + } + + private void ensureInTransaction(@NonNull Context context) { + if (!DatabaseFactory.inTransaction(context)) { + throw new IllegalStateException("Must be in a transaction!"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java new file mode 100644 index 00000000..4fa7bab5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.Cursor; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * The backing datastore for {@link RemappedRecords}. See that class for more details. + */ +public class RemappedRecordsDatabase extends Database { + + public static final String[] CREATE_TABLE = { Recipients.CREATE_TABLE, + Threads.CREATE_TABLE }; + + private static class SharedColumns { + protected static final String ID = "_id"; + protected static final String OLD_ID = "old_id"; + protected static final String NEW_ID = "new_id"; + } + + private static final class Recipients extends SharedColumns { + private static final String TABLE_NAME = "remapped_recipients"; + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + OLD_ID + " INTEGER UNIQUE, " + + NEW_ID + " INTEGER)"; + } + + private static final class Threads extends SharedColumns { + private static final String TABLE_NAME = "remapped_threads"; + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + OLD_ID + " INTEGER UNIQUE, " + + NEW_ID + " INTEGER)"; + } + + RemappedRecordsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + @NonNull Map getAllRecipientMappings() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map recipientMap = new HashMap<>(); + + db.beginTransaction(); + try { + List mappings = getAllMappings(Recipients.TABLE_NAME); + + for (Mapping mapping : mappings) { + RecipientId oldId = RecipientId.from(mapping.getOldId()); + RecipientId newId = RecipientId.from(mapping.getNewId()); + recipientMap.put(oldId, newId); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return recipientMap; + } + + @NonNull Map getAllThreadMappings() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map threadMap = new HashMap<>(); + + db.beginTransaction(); + try { + List mappings = getAllMappings(Threads.TABLE_NAME); + + for (Mapping mapping : mappings) { + threadMap.put(mapping.getOldId(), mapping.getNewId()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return threadMap; + } + + void addRecipientMapping(@NonNull RecipientId oldId, @NonNull RecipientId newId) { + addMapping(Recipients.TABLE_NAME, new Mapping(oldId.toLong(), newId.toLong())); + } + + void addThreadMapping(long oldId, long newId) { + addMapping(Threads.TABLE_NAME, new Mapping(oldId, newId)); + } + + private @NonNull List getAllMappings(@NonNull String table) { + List mappings = new LinkedList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(table, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long oldId = CursorUtil.requireLong(cursor, SharedColumns.OLD_ID); + long newId = CursorUtil.requireLong(cursor, SharedColumns.NEW_ID); + mappings.add(new Mapping(oldId, newId)); + } + } + + return mappings; + } + + private void addMapping(@NonNull String table, @NonNull Mapping mapping) { + ContentValues values = new ContentValues(); + values.put(SharedColumns.OLD_ID, mapping.getOldId()); + values.put(SharedColumns.NEW_ID, mapping.getNewId()); + + databaseHelper.getWritableDatabase().insert(table, null, values); + } + + static final class Mapping { + private final long oldId; + private final long newId; + + public Mapping(long oldId, long newId) { + this.oldId = oldId; + this.newId = newId; + } + + public long getOldId() { + return oldId; + } + + public long getNewId() { + return newId; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java new file mode 100644 index 00000000..fcafb3da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java @@ -0,0 +1,329 @@ +package org.thoughtcrime.securesms.database; + + +import android.content.ContentValues; + +import net.sqlcipher.Cursor; +import net.sqlcipher.SQLException; +import net.sqlcipher.database.SQLiteQueryStats; +import net.sqlcipher.database.SQLiteStatement; +import net.sqlcipher.database.SQLiteTransactionListener; + +import org.signal.core.util.tracing.Tracer; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * This is a wrapper around {@link net.sqlcipher.database.SQLiteDatabase}. There's difficulties + * making a subclass, so instead we just match the interface. Callers should just need to change + * their import statements. + */ +public class SQLiteDatabase { + + public static final int CONFLICT_ROLLBACK = 1; + public static final int CONFLICT_ABORT = 2; + public static final int CONFLICT_FAIL = 3; + public static final int CONFLICT_IGNORE = 4; + public static final int CONFLICT_REPLACE = 5; + public static final int CONFLICT_NONE = 0; + + private static final String KEY_QUERY = "query"; + private static final String KEY_TABLE = "table"; + private static final String KEY_THREAD = "thread"; + private static final String NAME_LOCK = "LOCK"; + + private final net.sqlcipher.database.SQLiteDatabase wrapped; + private final Tracer tracer; + + public SQLiteDatabase(net.sqlcipher.database.SQLiteDatabase wrapped) { + this.wrapped = wrapped; + this.tracer = Tracer.getInstance(); + } + + private void traceLockStart() { + tracer.start(NAME_LOCK, Tracer.TrackId.DB_LOCK, KEY_THREAD, Thread.currentThread().getName()); + } + + private void traceLockEnd() { + tracer.end(NAME_LOCK, Tracer.TrackId.DB_LOCK); + } + + private void trace(String methodName, Runnable runnable) { + tracer.start(methodName); + runnable.run(); + tracer.end(methodName); + } + + private void traceSql(String methodName, String query, boolean locked, Runnable returnable) { + if (locked) { + traceLockStart(); + } + + tracer.start(methodName, KEY_QUERY, query); + returnable.run(); + tracer.end(methodName); + + if (locked) { + traceLockEnd(); + } + } + + private E traceSql(String methodName, String query, boolean locked, Returnable returnable) { + return traceSql(methodName, null, query, locked, returnable); + } + + private E traceSql(String methodName, String table, String query, boolean locked, Returnable returnable) { + if (locked) { + traceLockStart(); + } + + Map params = new HashMap<>(); + if (query != null) { + params.put(KEY_QUERY, query); + } + if (table != null) { + params.put(KEY_TABLE, table); + } + + tracer.start(methodName, params); + E result = returnable.run(); + tracer.end(methodName); + + if (locked) { + traceLockEnd(); + } + + return result; + } + + public net.sqlcipher.database.SQLiteDatabase getSqlCipherDatabase() { + return wrapped; + } + + private interface Returnable { + E run(); + } + + + // ======================================================= + // Traced + // ======================================================= + + public void beginTransaction() { + traceLockStart(); + trace("beginTransaction()", wrapped::beginTransaction); + } + + public void endTransaction() { + trace("endTransaction()", wrapped::endTransaction); + traceLockEnd(); + } + + public void setTransactionSuccessful() { + trace("setTransactionSuccessful()", wrapped::setTransactionSuccessful); + } + + public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { + return traceSql("query(9)", table, selection, false, () -> wrapped.query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); + } + + public Cursor queryWithFactory(net.sqlcipher.database.SQLiteDatabase.CursorFactory cursorFactory, boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { + return traceSql("queryWithFactory()", table, selection, false, () -> wrapped.queryWithFactory(cursorFactory, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); + } + + public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) { + return traceSql("query(7)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy)); + } + + public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { + return traceSql("query(8)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); + } + + public Cursor rawQuery(String sql, String[] selectionArgs) { + return traceSql("rawQuery(2a)", sql, false, () -> wrapped.rawQuery(sql, selectionArgs)); + } + + public Cursor rawQuery(String sql, Object[] args) { + return traceSql("rawQuery(2b)", sql, false,() -> wrapped.rawQuery(sql, args)); + } + + public Cursor rawQueryWithFactory(net.sqlcipher.database.SQLiteDatabase.CursorFactory cursorFactory, String sql, String[] selectionArgs, String editTable) { + return traceSql("rawQueryWithFactory()", sql, false, () -> wrapped.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable)); + } + + public Cursor rawQuery(String sql, String[] selectionArgs, int initialRead, int maxRead) { + return traceSql("rawQuery(4)", sql, false, () -> rawQuery(sql, selectionArgs, initialRead, maxRead)); + } + + public long insert(String table, String nullColumnHack, ContentValues values) { + return traceSql("insert()", table, null, true, () -> wrapped.insert(table, nullColumnHack, values)); + } + + public long insertOrThrow(String table, String nullColumnHack, ContentValues values) throws SQLException { + return traceSql("insertOrThrow()", table, null, true, () -> wrapped.insertOrThrow(table, nullColumnHack, values)); + } + + public long replace(String table, String nullColumnHack, ContentValues initialValues) { + return traceSql("replace()", table, null, true,() -> wrapped.replace(table, nullColumnHack, initialValues)); + } + + public long replaceOrThrow(String table, String nullColumnHack, ContentValues initialValues) throws SQLException { + return traceSql("replaceOrThrow()", table, null, true, () -> wrapped.replaceOrThrow(table, nullColumnHack, initialValues)); + } + + public long insertWithOnConflict(String table, String nullColumnHack, ContentValues initialValues, int conflictAlgorithm) { + return traceSql("insertWithOnConflict()", table, null, true, () -> wrapped.insertWithOnConflict(table, nullColumnHack, initialValues, conflictAlgorithm)); + } + + public int delete(String table, String whereClause, String[] whereArgs) { + return traceSql("delete()", table, whereClause, true, () -> wrapped.delete(table, whereClause, whereArgs)); + } + + public int update(String table, ContentValues values, String whereClause, String[] whereArgs) { + return traceSql("update()", table, whereClause, true, () -> wrapped.update(table, values, whereClause, whereArgs)); + } + + public int updateWithOnConflict(String table, ContentValues values, String whereClause, String[] whereArgs, int conflictAlgorithm) { + return traceSql("updateWithOnConflict()", table, whereClause, true, () -> wrapped.updateWithOnConflict(table, values, whereClause, whereArgs, conflictAlgorithm)); + } + + public void execSQL(String sql) throws SQLException { + traceSql("execSQL(1)", sql, true, () -> wrapped.execSQL(sql)); + } + + public void rawExecSQL(String sql) { + traceSql("rawExecSQL()", sql, true, () -> wrapped.rawExecSQL(sql)); + } + + public void execSQL(String sql, Object[] bindArgs) throws SQLException { + traceSql("execSQL(2)", sql, true, () -> wrapped.execSQL(sql, bindArgs)); + } + + + // ======================================================= + // Ignored + // ======================================================= + + public boolean enableWriteAheadLogging() { + return wrapped.enableWriteAheadLogging(); + } + + public void disableWriteAheadLogging() { + wrapped.disableWriteAheadLogging(); + } + + public boolean isWriteAheadLoggingEnabled() { + return wrapped.isWriteAheadLoggingEnabled(); + } + + public void setForeignKeyConstraintsEnabled(boolean enable) { + wrapped.setForeignKeyConstraintsEnabled(enable); + } + + public void beginTransactionWithListener(SQLiteTransactionListener transactionListener) { + wrapped.beginTransactionWithListener(transactionListener); + } + + public void beginTransactionNonExclusive() { + wrapped.beginTransactionNonExclusive(); + } + + public void beginTransactionWithListenerNonExclusive(SQLiteTransactionListener transactionListener) { + wrapped.beginTransactionWithListenerNonExclusive(transactionListener); + } + + public boolean inTransaction() { + return wrapped.inTransaction(); + } + + public boolean isDbLockedByCurrentThread() { + return wrapped.isDbLockedByCurrentThread(); + } + + public boolean isDbLockedByOtherThreads() { + return wrapped.isDbLockedByOtherThreads(); + } + + public boolean yieldIfContendedSafely() { + return wrapped.yieldIfContendedSafely(); + } + + public boolean yieldIfContendedSafely(long sleepAfterYieldDelay) { + return wrapped.yieldIfContendedSafely(sleepAfterYieldDelay); + } + + public int getVersion() { + return wrapped.getVersion(); + } + + public void setVersion(int version) { + wrapped.setVersion(version); + } + + public long getMaximumSize() { + return wrapped.getMaximumSize(); + } + + public long setMaximumSize(long numBytes) { + return wrapped.setMaximumSize(numBytes); + } + + public long getPageSize() { + return wrapped.getPageSize(); + } + + public void setPageSize(long numBytes) { + wrapped.setPageSize(numBytes); + } + + public SQLiteStatement compileStatement(String sql) throws SQLException { + return wrapped.compileStatement(sql); + } + + public SQLiteQueryStats getQueryStats(String sql, Object[] args) { + return wrapped.getQueryStats(sql, args); + } + + public boolean isReadOnly() { + return wrapped.isReadOnly(); + } + + public boolean isOpen() { + return wrapped.isOpen(); + } + + public boolean needUpgrade(int newVersion) { + return wrapped.needUpgrade(newVersion); + } + + public final String getPath() { + return wrapped.getPath(); + } + + public void setLocale(Locale locale) { + wrapped.setLocale(locale); + } + + public boolean isInCompiledSqlCache(String sql) { + return wrapped.isInCompiledSqlCache(sql); + } + + public void purgeFromCompiledSqlCache(String sql) { + wrapped.purgeFromCompiledSqlCache(sql); + } + + public void resetCompiledSqlCache() { + wrapped.resetCompiledSqlCache(); + } + + public int getMaxSqlCacheSize() { + return wrapped.getMaxSqlCacheSize(); + } + + public void setMaxSqlCacheSize(int cacheSize) { + wrapped.setMaxSqlCacheSize(cacheSize); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java new file mode 100644 index 00000000..6cf3793c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import net.sqlcipher.Cursor; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; + +/** + * Contains all databases necessary for full-text search (FTS). + */ +public class SearchDatabase extends Database { + + public static final String SMS_FTS_TABLE_NAME = "sms_fts"; + public static final String MMS_FTS_TABLE_NAME = "mms_fts"; + + public static final String ID = "rowid"; + public static final String BODY = MmsSmsColumns.BODY; + public static final String THREAD_ID = MmsSmsColumns.THREAD_ID; + public static final String SNIPPET = "snippet"; + public static final String CONVERSATION_RECIPIENT = "conversation_recipient"; + public static final String MESSAGE_RECIPIENT = "message_recipient"; + public static final String IS_MMS = "is_mms"; + public static final String MESSAGE_ID = "message_id"; + + public static final String SNIPPET_WRAP = "..."; + + public static final String[] CREATE_TABLE = { + "CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");", + + "CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + + "END;", + + + "CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");", + + "CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + + "END;" + }; + + private static final String MESSAGES_QUERY = + "SELECT " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " + + MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " + + "snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '" + SNIPPET_WRAP + "', 7) AS " + SNIPPET + ", " + + SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + SMS_FTS_TABLE_NAME + "." + THREAD_ID + ", " + + SMS_FTS_TABLE_NAME + "." + BODY + ", " + + SMS_FTS_TABLE_NAME + "." + ID + " AS " + MESSAGE_ID + ", " + + "0 AS " + IS_MMS + " " + + "FROM " + SmsDatabase.TABLE_NAME + " " + + "INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " + + "UNION ALL " + + "SELECT " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " + + MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " + + "snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '" + SNIPPET_WRAP + "', 7) AS " + SNIPPET + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + MMS_FTS_TABLE_NAME + "." + THREAD_ID + ", " + + MMS_FTS_TABLE_NAME + "." + BODY + ", " + + MMS_FTS_TABLE_NAME + "." + ID + " AS " + MESSAGE_ID + ", " + + "1 AS " + IS_MMS + " " + + "FROM " + MmsDatabase.TABLE_NAME + " " + + "INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " + + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + + "LIMIT 500"; + + private static final String MESSAGES_FOR_THREAD_QUERY = + "SELECT " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " + + MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " + + "snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '" + SNIPPET_WRAP + "', 7) AS " + SNIPPET + ", " + + SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + SMS_FTS_TABLE_NAME + "." + THREAD_ID + ", " + + SMS_FTS_TABLE_NAME + "." + BODY + ", " + + SMS_FTS_TABLE_NAME + "." + ID + " AS " + MESSAGE_ID + ", " + + "0 AS " + IS_MMS + " " + + "FROM " + SmsDatabase.TABLE_NAME + " " + + "INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " + + "UNION ALL " + + "SELECT " + + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " + + MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " + + "snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '" + SNIPPET_WRAP + "', 7) AS " + SNIPPET + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + MMS_FTS_TABLE_NAME + "." + THREAD_ID + ", " + + MMS_FTS_TABLE_NAME + "." + BODY + ", " + + MMS_FTS_TABLE_NAME + "." + ID + " AS " + MESSAGE_ID + ", " + + "1 AS " + IS_MMS + " " + + "FROM " + MmsDatabase.TABLE_NAME + " " + + "INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " + + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + + "LIMIT 500"; + + public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public Cursor queryMessages(@NonNull String query) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String fullTextSearchQuery = createFullTextSearchQuery(query); + + if (TextUtils.isEmpty(fullTextSearchQuery)) { + return null; + } + + Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { fullTextSearchQuery, + fullTextSearchQuery }); + + setNotifyConversationListListeners(cursor); + return cursor; + } + + public Cursor queryMessages(@NonNull String query, long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String fullTextSearchQuery = createFullTextSearchQuery(query); + + if (TextUtils.isEmpty(fullTextSearchQuery)) { + return null; + } + + Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { fullTextSearchQuery, + String.valueOf(threadId), + fullTextSearchQuery, + String.valueOf(threadId) }); + + setNotifyConversationListListeners(cursor); + return cursor; + } + + private static String createFullTextSearchQuery(@NonNull String query) { + return Stream.of(query.split(" ")) + .map(String::trim) + .filter(s -> s.length() > 0) + .map(SearchDatabase::fullTextSearchEscape) + .collect(StringBuilder::new, (sb, s) -> sb.append(s).append("* ")) + .toString(); + } + + private static String fullTextSearchEscape(String s) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.java new file mode 100644 index 00000000..1f7b1027 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.java @@ -0,0 +1,183 @@ +package org.thoughtcrime.securesms.database; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.whispersystems.libsignal.state.SessionRecord; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +public class SessionDatabase extends Database { + + private static final String TAG = SessionDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "sessions"; + + private static final String ID = "_id"; + public static final String RECIPIENT_ID = "address"; + public static final String DEVICE = "device"; + public static final String RECORD = "record"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + + "(" + ID + " INTEGER PRIMARY KEY, " + RECIPIENT_ID + " INTEGER NOT NULL, " + + DEVICE + " INTEGER NOT NULL, " + RECORD + " BLOB NOT NULL, " + + "UNIQUE(" + RECIPIENT_ID + "," + DEVICE + ") ON CONFLICT REPLACE);"; + + SessionDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void store(@NonNull RecipientId recipientId, int deviceId, @NonNull SessionRecord record) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(DEVICE, deviceId); + values.put(RECORD, record.serialize()); + + database.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + + public @Nullable SessionRecord load(@NonNull RecipientId recipientId, int deviceId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, new String[]{RECORD}, + RECIPIENT_ID + " = ? AND " + DEVICE + " = ?", + new String[] {recipientId.serialize(), String.valueOf(deviceId)}, + null, null, null)) + { + if (cursor != null && cursor.moveToFirst()) { + try { + return new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD))); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + return null; + } + + public @NonNull List getAllFor(@NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, null, + RECIPIENT_ID + " = ?", + new String[] {recipientId.serialize()}, + null, null, null)) + { + while (cursor != null && cursor.moveToNext()) { + try { + results.add(new SessionRow(recipientId, + cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE)), + new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD))))); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + return results; + } + + public @NonNull List getAll() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + try { + results.add(new SessionRow(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), + cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE)), + new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD))))); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + return results; + } + + public @NonNull List getSubDevices(@NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, new String[] {DEVICE}, + RECIPIENT_ID + " = ?", + new String[] {recipientId.serialize()}, + null, null, null)) + { + while (cursor != null && cursor.moveToNext()) { + int device = cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE)); + + if (device != SignalServiceAddress.DEFAULT_DEVICE_ID) { + results.add(device); + } + } + } + + return results; + } + + public void delete(@NonNull RecipientId recipientId, int deviceId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + database.delete(TABLE_NAME, RECIPIENT_ID + " = ? AND " + DEVICE + " = ?", + new String[] {recipientId.serialize(), String.valueOf(deviceId)}); + } + + public void deleteAllFor(@NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()}); + } + + public boolean hasSessionFor(@NonNull RecipientId recipientId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String query = RECIPIENT_ID + " = ?"; + String[] args = SqlUtil.buildArgs(recipientId); + + try (Cursor cursor = database.query(TABLE_NAME, new String[] { ID }, query, args, null, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + + public static final class SessionRow { + private final RecipientId recipientId; + private final int deviceId; + private final SessionRecord record; + + public SessionRow(@NonNull RecipientId recipientId, int deviceId, SessionRecord record) { + this.recipientId = recipientId; + this.deviceId = deviceId; + this.record = record; + } + + public RecipientId getRecipientId() { + return recipientId; + } + + public int getDeviceId() { + return deviceId; + } + + public SessionRecord getRecord() { + return record; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.java new file mode 100644 index 00000000..46130e8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +/** + * Simple interface for common methods across our various + * {@link net.sqlcipher.database.SQLiteOpenHelper}s. + */ +public interface SignalDatabase { + SQLiteDatabase getSqlCipherDatabase(); + String getDatabaseName(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyDatabase.java new file mode 100644 index 00000000..97a87ed3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignedPreKeyDatabase.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.database; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +public class SignedPreKeyDatabase extends Database { + + private static final String TAG = SignedPreKeyDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "signed_prekeys"; + + private static final String ID = "_id"; + public static final String KEY_ID = "key_id"; + public static final String PUBLIC_KEY = "public_key"; + public static final String PRIVATE_KEY = "private_key"; + public static final String SIGNATURE = "signature"; + public static final String TIMESTAMP = "timestamp"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + + " (" + ID + " INTEGER PRIMARY KEY, " + + KEY_ID + " INTEGER UNIQUE, " + + PUBLIC_KEY + " TEXT NOT NULL, " + + PRIVATE_KEY + " TEXT NOT NULL, " + + SIGNATURE + " TEXT NOT NULL, " + + TIMESTAMP + " INTEGER DEFAULT 0);"; + + SignedPreKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public @Nullable SignedPreKeyRecord getSignedPreKey(int keyId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, null, KEY_ID + " = ?", + new String[] {String.valueOf(keyId)}, + null, null, null)) + { + if (cursor != null && cursor.moveToFirst()) { + try { + ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0); + ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY)))); + byte[] signature = Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(SIGNATURE))); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)); + + return new SignedPreKeyRecord(keyId, timestamp, new ECKeyPair(publicKey, privateKey), signature); + } catch (InvalidKeyException | IOException e) { + Log.w(TAG, e); + } + } + } + + return null; + } + + public @NonNull List getAllSignedPreKeys() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List results = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + try { + int keyId = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_ID)); + ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0); + ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY)))); + byte[] signature = Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(SIGNATURE))); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)); + + results.add(new SignedPreKeyRecord(keyId, timestamp, new ECKeyPair(publicKey, privateKey), signature)); + } catch (InvalidKeyException | IOException e) { + Log.w(TAG, e); + } + } + } + + return results; + } + + public void insertSignedPreKey(int keyId, SignedPreKeyRecord record) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(KEY_ID, keyId); + contentValues.put(PUBLIC_KEY, Base64.encodeBytes(record.getKeyPair().getPublicKey().serialize())); + contentValues.put(PRIVATE_KEY, Base64.encodeBytes(record.getKeyPair().getPrivateKey().serialize())); + contentValues.put(SIGNATURE, Base64.encodeBytes(record.getSignature())); + contentValues.put(TIMESTAMP, record.getTimestamp()); + + database.replace(TABLE_NAME, null, contentValues); + } + + + public void removeSignedPreKey(int keyId) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.delete(TABLE_NAME, KEY_ID + " = ? AND " + SIGNATURE + " IS NOT NULL", new String[] {String.valueOf(keyId)}); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java new file mode 100644 index 00000000..cbd7e674 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -0,0 +1,1611 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 - 2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; +import com.google.android.mms.pdu_alt.NotificationInd; +import com.tm.androidcopysdk.DataGrabber; + +import net.sqlcipher.database.SQLiteStatement; + +import org.archiver.ArchiveConstants; +import org.archiver.ArchiveSender; +import org.archiver.ArchiveUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.mms.IncomingMediaMessage; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; +import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.Closeable; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Database for storage of SMS messages. + * + * @author Moxie Marlinspike + */ +public class SmsDatabase extends MessageDatabase { + + private static final String TAG = SmsDatabase.class.getSimpleName(); + + public static final String TABLE_NAME = "sms"; + public static final String PERSON = "person"; + static final String DATE_RECEIVED = "date"; + static final String DATE_SENT = "date_sent"; + public static final String PROTOCOL = "protocol"; + public static final String STATUS = "status"; + public static final String TYPE = "type"; + public static final String REPLY_PATH_PRESENT = "reply_path_present"; + public static final String SUBJECT = "subject"; + public static final String SERVICE_CENTER = "service_center"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + + RECIPIENT_ID + " INTEGER, " + + ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " + + PERSON + " INTEGER, " + + DATE_RECEIVED + " INTEGER, " + + DATE_SENT + " INTEGER, " + + DATE_SERVER + " INTEGER DEFAULT -1, " + + PROTOCOL + " INTEGER, " + + READ + " INTEGER DEFAULT 0, " + + STATUS + " INTEGER DEFAULT -1," + + TYPE + " INTEGER, " + + REPLY_PATH_PRESENT + " INTEGER, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," + + SUBJECT + " TEXT, " + + BODY + " TEXT, " + + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + + SERVICE_CENTER + " TEXT, " + + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + EXPIRES_IN + " INTEGER DEFAULT 0, " + + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + + NOTIFIED + " DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + UNIDENTIFIED + " INTEGER DEFAULT 0, " + + REACTIONS + " BLOB DEFAULT NULL, " + + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + + REMOTE_DELETED + " INTEGER DEFAULT 0, " + + NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0);"; + + public static final String[] CREATE_INDEXS = { + "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", + "CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");", + "CREATE INDEX IF NOT EXISTS sms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");", + "CREATE INDEX IF NOT EXISTS sms_type_index ON " + TABLE_NAME + " (" + TYPE + ");", + "CREATE INDEX IF NOT EXISTS sms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ");", + "CREATE INDEX IF NOT EXISTS sms_date_server_index ON " + TABLE_NAME + " (" + DATE_SERVER + ");", + "CREATE INDEX IF NOT EXISTS sms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");", + "CREATE INDEX IF NOT EXISTS sms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");" + }; + + private static final String[] MESSAGE_PROJECTION = new String[] { + ID, THREAD_ID, RECIPIENT_ID, ADDRESS_DEVICE_ID, PERSON, + DATE_RECEIVED + " AS " + NORMALIZED_DATE_RECEIVED, + DATE_SENT + " AS " + NORMALIZED_DATE_SENT, + DATE_SERVER, + PROTOCOL, READ, STATUS, TYPE, + REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, + MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, + NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, + REMOTE_DELETED, NOTIFIED_TIMESTAMP + }; + + private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")"; + private final String OUTGOING_SECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + TYPE + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; + + private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("SmsDelivery"); + + public SmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + @Override + protected String getTableName() { + return TABLE_NAME; + } + + @Override + protected String getDateSentColumnName() { + return DATE_SENT; + } + + @Override + protected String getDateReceivedColumnName() { + return DATE_RECEIVED; + } + + @Override + protected String getTypeField() { + return TYPE; + } + + private void updateTypeBitmask(long id, long maskOff, long maskOn) { + Log.i(TAG, "Updating ID: " + id + " to base type: " + maskOn); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.execSQL("UPDATE " + TABLE_NAME + + " SET " + TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + + " WHERE " + ID + " = ?", new String[] {id+""}); + + long threadId = getThreadIdForMessage(id); + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{RECIPIENT_ID}; + String query = THREAD_ID + " = ? AND " + TYPE + " & ? AND " + DATE_RECEIVED + " >= ?"; + long type = Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT | Types.GROUP_UPDATE_BIT | Types.BASE_INBOX_TYPE; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(minimumDateReceived)}; + String limit = "1"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, limit)) { + if (cursor.moveToFirst()) { + return RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + } + } + + return null; + } + + @Override + public long getThreadIdForMessage(long id) { + String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; + String[] sqlArgs = new String[] {id+""}; + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + Cursor cursor = null; + + try { + cursor = db.rawQuery(sql, sqlArgs); + if (cursor != null && cursor.moveToFirst()) + return cursor.getLong(0); + else + return -1; + } finally { + if (cursor != null) + cursor.close(); + } + } + + @Override + public int getMessageCountForThreadSummary(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = { "COUNT(*)" }; + String query = THREAD_ID + " = ? AND (NOT " + TYPE + " & ? AND TYPE != ?)"; + long type = Types.END_SESSION_BIT | Types.KEY_EXCHANGE_IDENTITY_UPDATE_BIT | Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; + String[] args = SqlUtil.buildArgs(threadId, type, Types.PROFILE_CHANGE_TYPE); + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int count = cursor.getInt(0); + if (count > 0) { + return getMessageCountForThread(threadId); + } + } + } + + return 0; + } + + @Override + public int getMessageCountForThread(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + @Override + public int getMessageCountForThread(long threadId, long beforeTime) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + @Override + public void markAsEndSession(long id) { + updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.END_SESSION_BIT); + } + + @Override + public void markAsPreKeyBundle(long id) { + updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.KEY_EXCHANGE_BIT | Types.KEY_EXCHANGE_BUNDLE_BIT); + } + + @Override + public void markAsInvalidVersionKeyExchange(long id) { + updateTypeBitmask(id, 0, Types.KEY_EXCHANGE_INVALID_VERSION_BIT); + } + + @Override + public void markAsSecure(long id) { + updateTypeBitmask(id, 0, Types.SECURE_MESSAGE_BIT); + } + + @Override + public void markAsInsecure(long id) { + updateTypeBitmask(id, Types.SECURE_MESSAGE_BIT, 0); + } + + @Override + public void markAsPush(long id) { + updateTypeBitmask(id, 0, Types.PUSH_MESSAGE_BIT); + } + + @Override + public void markAsForcedSms(long id) { + updateTypeBitmask(id, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT); + } + + @Override + public void markAsDecryptFailed(long id) { + updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT); + } + + @Override + public void markAsDecryptDuplicate(long id) { + updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_DUPLICATE_BIT); + } + + @Override + public void markAsNoSession(long id) { + updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_NO_SESSION_BIT); + } + + @Override + public void markAsUnsupportedProtocolVersion(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.UNSUPPORTED_MESSAGE_TYPE); + } + + @Override + public void markAsInvalidMessage(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.INVALID_MESSAGE_TYPE); + } + + @Override + public void markAsLegacyVersion(long id) { + updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_LEGACY_BIT); + } + + @Override + public void markAsOutbox(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_OUTBOX_TYPE); + } + + @Override + public void markAsPendingInsecureSmsFallback(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_PENDING_INSECURE_SMS_FALLBACK); + } + + @Override + public void markAsSent(long id, boolean isSecure) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSecure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0)); + } + + @Override + public void markAsSending(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE); + } + + @Override + public void markAsMissedCall(long id, boolean isVideoOffer) { + updateTypeBitmask(id, Types.TOTAL_MASK, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE); + } + + @Override + public void markAsRemoteDelete(long id) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + values.put(REMOTE_DELETED, 1); + values.putNull(BODY); + values.putNull(REACTIONS); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) }); + + long threadId = getThreadIdForMessage(id); + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public void markUnidentified(long id, boolean unidentified) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); + } + + @Override + public void markExpireStarted(long id) { + markExpireStarted(id, System.currentTimeMillis()); + } + + @Override + public void markExpireStarted(long id, long startedAtTimestamp) { + markExpireStarted(Collections.singleton(id), startedAtTimestamp); + } + + @Override + public void markExpireStarted(Collection ids, long startedAtTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long threadId = -1; + + db.beginTransaction(); + try { + String query = ID + " = ? AND (" + EXPIRE_STARTED + " = 0 OR " + EXPIRE_STARTED + " > ?)"; + + for (long id : ids) { + ContentValues contentValues = new ContentValues(); + contentValues.put(EXPIRE_STARTED, startedAtTimestamp); + + db.update(TABLE_NAME, contentValues, query, new String[]{String.valueOf(id), String.valueOf(startedAtTimestamp)}); + + if (threadId < 0) { + threadId = getThreadIdForMessage(id); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public void markSmsStatus(long id, int status) { + Log.i(TAG, "Updating ID: " + id + " to status: " + status); + ContentValues contentValues = new ContentValues(); + contentValues.put(STATUS, status); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {id+""}); + + long threadId = getThreadIdForMessage(id); + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + + @Override + public void markAsSentFailed(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE); + } + + @Override + public void markAsNotified(long id) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(); + + contentValues.put(NOTIFIED, 1); + + database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); + } + + @Override + public boolean incrementReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType) { + if (receiptType == ReceiptType.VIEWED) { + return false; + } + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + boolean foundMessage = false; + + try (Cursor cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, RECIPIENT_ID, TYPE, DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT}, + DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())}, + null, null, null, null)) { + + while (cursor.moveToNext()) { + if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)))) { + RecipientId theirRecipientId = messageId.getRecipientId(); + RecipientId outRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); + String columnName = receiptType.getColumnName(); + boolean isFirstIncrement = cursor.getLong(cursor.getColumnIndexOrThrow(columnName)) == 0; + + if (outRecipientId.equals(theirRecipientId)) { + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + + database.execSQL("UPDATE " + TABLE_NAME + + " SET " + columnName + " = " + columnName + " + 1 WHERE " + + ID + " = ?", + new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + + if (isFirstIncrement) { + notifyConversationListeners(threadId); + } else { + notifyVerboseConversationListeners(threadId); + } + + foundMessage = true; + } + } + } + + if (!foundMessage && receiptType == ReceiptType.DELIVERY) { + earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getRecipientId()); + return true; + } + + return foundMessage; + } + } + + @Override + public List> setTimestampRead(SyncMessageId messageId, long proposedExpireStarted) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + List> expiring = new LinkedList<>(); + Cursor cursor = null; + + try { + cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, RECIPIENT_ID, TYPE, EXPIRES_IN, EXPIRE_STARTED}, + DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())}, + null, null, null, null); + + while (cursor.moveToNext()) { + RecipientId theirRecipientId = messageId.getRecipientId(); + RecipientId outRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); + + if (outRecipientId.equals(theirRecipientId) || theirRecipientId.equals(Recipient.self().getId())) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)); + + expireStarted = expireStarted > 0 ? Math.min(proposedExpireStarted, expireStarted) : proposedExpireStarted; + + ContentValues contentValues = new ContentValues(); + contentValues.put(READ, 1); + contentValues.put(REACTIONS_UNREAD, 0); + contentValues.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + if (expiresIn > 0) { + contentValues.put(EXPIRE_STARTED, expireStarted); + expiring.add(new Pair<>(id, expiresIn)); + } + + database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + ""}); + + DatabaseFactory.getThreadDatabase(context).updateReadState(threadId); + DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId); + notifyConversationListeners(threadId); + } + } + } finally { + if (cursor != null) cursor.close(); + } + + return expiring; + } + + @Override + public List setEntireThreadRead(long threadId) { + return setMessagesRead(THREAD_ID + " = ?", new String[] {String.valueOf(threadId)}); + } + + @Override + public List setMessagesReadSince(long threadId, long sinceTimestamp) { + if (sinceTimestamp == -1) { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + ")))", new String[] {String.valueOf(threadId)}); + } else { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND ( " + getOutgoingTypeClause() + " ))) AND " + DATE_RECEIVED + " <= ?", new String[] {String.valueOf(threadId),String.valueOf(sinceTimestamp)}); + } + } + + @Override + public List setAllMessagesRead() { + return setMessagesRead(READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + "))", null); + } + + private List setMessagesRead(String where, String[] arguments) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + List results = new LinkedList<>(); + Cursor cursor = null; + + database.beginTransaction(); + try { + cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID}, where, arguments, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + if (Types.isSecureType(CursorUtil.requireLong(cursor, TYPE))) { + long threadId = CursorUtil.requireLong(cursor, THREAD_ID); + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); + long messageId = CursorUtil.requireLong(cursor, ID); + long expiresIn = CursorUtil.requireLong(cursor, EXPIRES_IN); + long expireStarted = CursorUtil.requireLong(cursor, EXPIRE_STARTED); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, false); + + results.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo)); + } + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(READ, 1); + contentValues.put(REACTIONS_UNREAD, 0); + contentValues.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + database.update(TABLE_NAME, contentValues, where, arguments); + database.setTransactionSuccessful(); + } finally { + if (cursor != null) cursor.close(); + database.endTransaction(); + } + + return results; + } + + @Override + public Pair updateBundleMessageBody(long messageId, String body) { + long type = Types.BASE_INBOX_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; + return updateMessageBodyAndType(messageId, body, Types.TOTAL_MASK, type); + } + + @Override + public @NonNull List getViewedIncomingMessages(long threadId) { + return Collections.emptyList(); + } + + @Override + public @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId) { + return null; + } + + private Pair updateMessageBodyAndType(long messageId, String body, long maskOff, long maskOn) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.execSQL("UPDATE " + TABLE_NAME + " SET " + BODY + " = ?, " + + TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + ") " + + "WHERE " + ID + " = ?", + new String[] {body, messageId + ""}); + + long threadId = getThreadIdForMessage(messageId); + + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + notifyConversationListeners(threadId); + notifyConversationListListeners(); + + return new Pair<>(messageId, threadId); + } + + @Override + public boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = SqlUtil.buildArgs(SmsDatabase.TYPE); + String selection = THREAD_ID + " = ? AND " + DATE_RECEIVED + " > ? AND (" + TYPE + " = ? OR " + TYPE + " = ? OR " + TYPE + " = ? OR " + TYPE + " =?)"; + String[] selectionArgs = SqlUtil.buildArgs(threadId, + timestamp, + Types.INCOMING_AUDIO_CALL_TYPE, + Types.INCOMING_VIDEO_CALL_TYPE, + Types.MISSED_AUDIO_CALL_TYPE, + Types.MISSED_VIDEO_CALL_TYPE); + + try (Cursor cursor = db.query(TABLE_NAME, projection, selection, selectionArgs, null, null, null)) { + return cursor != null && cursor.moveToFirst(); + } + } + + @Override + public @NonNull Pair insertReceivedCall(@NonNull RecipientId address, boolean isVideoOffer) { + return insertCallLog(address, isVideoOffer ? Types.INCOMING_VIDEO_CALL_TYPE : Types.INCOMING_AUDIO_CALL_TYPE, false, System.currentTimeMillis()); + } + + @Override + public @NonNull Pair insertOutgoingCall(@NonNull RecipientId address, boolean isVideoOffer) { + return insertCallLog(address, isVideoOffer ? Types.OUTGOING_VIDEO_CALL_TYPE : Types.OUTGOING_AUDIO_CALL_TYPE, false, System.currentTimeMillis()); + } + + @Override + public @NonNull Pair insertMissedCall(@NonNull RecipientId address, long timestamp, boolean isVideoOffer) { + return insertCallLog(address, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE, true, timestamp); + } + + @Override + public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids, + boolean isCallFull) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + Recipient recipient = Recipient.resolved(groupRecipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + boolean peerEraIdSameAsPrevious = updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull); + + try { + db.beginTransaction(); + + if (!peerEraIdSameAsPrevious && !Util.isEmpty(peekGroupCallEraId)) { + Recipient self = Recipient.self(); + boolean markRead = peekJoinedUuids.contains(self.requireUuid()) || self.getId().equals(sender); + + byte[] updateDetails = GroupCallUpdateDetails.newBuilder() + .setEraId(Util.emptyIfNull(peekGroupCallEraId)) + .setStartedCallUuid(Recipient.resolved(sender).requireUuid().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(Stream.of(peekJoinedUuids).map(UUID::toString).toList()) + .setIsCallFull(isCallFull) + .build() + .toByteArray(); + + String body = Base64.encodeBytes(updateDetails); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, sender.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, timestamp); + values.put(DATE_SENT, timestamp); + values.put(READ, markRead ? 1 : 0); + values.put(BODY, body); + values.put(TYPE, Types.GROUP_CALL_TYPE); + values.put(THREAD_ID, threadId); + + db.insert(TABLE_NAME, null, values); + + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + @Override + public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + long threadId; + + try { + db.beginTransaction(); + + Recipient recipient = Recipient.resolved(groupRecipientId); + + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + String where = TYPE + " = ? AND " + THREAD_ID + " = ?"; + String[] args = SqlUtil.buildArgs(Types.GROUP_CALL_TYPE, threadId); + boolean sameEraId = false; + + try (Reader reader = new Reader(db.query(TABLE_NAME, MESSAGE_PROJECTION, where, args, null, null, DATE_RECEIVED + " DESC", "1"))) { + MessageRecord record = reader.getNext(); + if (record != null) { + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.getBody()); + + sameEraId = groupCallUpdateDetails.getEraId().equals(messageGroupCallEraId) && !Util.isEmpty(messageGroupCallEraId); + + if (!sameEraId) { + String body = GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, Collections.emptyList(), false); + + ContentValues contentValues = new ContentValues(); + contentValues.put(BODY, body); + + db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(record.getId())); + } + } + } + + if (!sameEraId && !Util.isEmpty(messageGroupCallEraId)) { + byte[] updateDetails = GroupCallUpdateDetails.newBuilder() + .setEraId(Util.emptyIfNull(messageGroupCallEraId)) + .setStartedCallUuid(Recipient.resolved(sender).requireUuid().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(Collections.emptyList()) + .setIsCallFull(false) + .build() + .toByteArray(); + + String body = Base64.encodeBytes(updateDetails); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, sender.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, timestamp); + values.put(DATE_SENT, timestamp); + values.put(READ, 0); + values.put(BODY, body); + values.put(TYPE, Types.GROUP_CALL_TYPE); + values.put(THREAD_ID, threadId); + + db.insert(TABLE_NAME, null, values); + + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + @Override + public boolean updatePreviousGroupCall(long threadId, @Nullable String peekGroupCallEraId, @NonNull Collection peekJoinedUuids, boolean isCallFull) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = TYPE + " = ? AND " + THREAD_ID + " = ?"; + String[] args = SqlUtil.buildArgs(Types.GROUP_CALL_TYPE, threadId); + boolean sameEraId = false; + + try (Reader reader = new Reader(db.query(TABLE_NAME, MESSAGE_PROJECTION, where, args, null, null, DATE_RECEIVED + " DESC", "1"))) { + MessageRecord record = reader.getNext(); + if (record == null) { + return false; + } + + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.getBody()); + boolean containsSelf = peekJoinedUuids.contains(Recipient.self().requireUuid()); + + sameEraId = groupCallUpdateDetails.getEraId().equals(peekGroupCallEraId) && !Util.isEmpty(peekGroupCallEraId); + + List inCallUuids = sameEraId ? Stream.of(peekJoinedUuids).map(UUID::toString).toList() + : Collections.emptyList(); + + String body = GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids, isCallFull); + + ContentValues contentValues = new ContentValues(); + contentValues.put(BODY, body); + + if (sameEraId && containsSelf) { + contentValues.put(READ, 1); + } + + db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(record.getId())); + } + + notifyConversationListeners(threadId); + + return sameEraId; + } + + private @NonNull Pair insertCallLog(@NonNull RecipientId recipientId, long type, boolean unread, long timestamp) { + Recipient recipient = Recipient.resolved(recipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + ContentValues values = new ContentValues(6); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, timestamp); + values.put(READ, unread ? 0 : 1); + values.put(TYPE, type); + values.put(THREAD_ID, threadId); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long messageId = db.insert(TABLE_NAME, null, values); + + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + if (unread) { + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + + return new Pair<>(messageId, threadId); + } + + @Override + public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) { + String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?"; + String[] args = SqlUtil.buildArgs(threadId, afterTimestamp, Types.PROFILE_CHANGE_TYPE); + + try (Reader reader = readerFor(queryMessages(where, args, true, -1))) { + List results = new ArrayList<>(reader.getCount()); + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + @Override + public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + List groupRecords = DatabaseFactory.getGroupDatabase(context).getGroupsContainingMember(recipient.getId(), false); + List threadIdsToUpdate = new LinkedList<>(); + + byte[] profileChangeDetails = ProfileChangeDetails.newBuilder() + .setProfileNameChange(ProfileChangeDetails.StringChange.newBuilder() + .setNew(newProfileName) + .setPrevious(previousProfileName)) + .build() + .toByteArray(); + + String body = Base64.encodeBytes(profileChangeDetails); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + threadIdsToUpdate.add(threadDatabase.getThreadIdFor(recipient.getId())); + for (GroupDatabase.GroupRecord groupRecord : groupRecords) { + if (groupRecord.isActive()) { + threadIdsToUpdate.add(threadDatabase.getThreadIdFor(groupRecord.getRecipientId())); + } + } + + Stream.of(threadIdsToUpdate) + .withoutNulls() + .forEach(threadId -> { + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, recipient.getId().serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, System.currentTimeMillis()); + values.put(READ, 1); + values.put(TYPE, Types.PROFILE_CHANGE_TYPE); + values.put(THREAD_ID, threadId); + values.put(BODY, body); + + db.insert(TABLE_NAME, null, values); + + notifyConversationListeners(threadId); + }); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + Stream.of(threadIdsToUpdate) + .withoutNulls() + .forEach(threadId -> ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId))); + } + + @Override + public void insertGroupV1MigrationEvents(@NonNull RecipientId recipientId, + long threadId, + @NonNull GroupMigrationMembershipChange membershipChange) + { + insertGroupV1MigrationNotification(recipientId, threadId); + + if (!membershipChange.isEmpty()) { + insertGroupV1MigrationMembershipChanges(recipientId, threadId, membershipChange); + } + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + private void insertGroupV1MigrationNotification(@NonNull RecipientId recipientId, long threadId) { + insertGroupV1MigrationMembershipChanges(recipientId, threadId, GroupMigrationMembershipChange.empty()); + } + + private void insertGroupV1MigrationMembershipChanges(@NonNull RecipientId recipientId, + long threadId, + @NonNull GroupMigrationMembershipChange membershipChange) + { + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, System.currentTimeMillis()); + values.put(READ, 1); + values.put(TYPE, Types.GV1_MIGRATION_TYPE); + values.put(THREAD_ID, threadId); + + if (!membershipChange.isEmpty()) { + values.put(BODY, membershipChange.serialize()); + } + + databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + } + + @Override + public Optional insertMessageInbox(IncomingTextMessage message, long type) { + if (message.isJoined()) { + type = (type & (Types.TOTAL_MASK - Types.BASE_TYPE_MASK)) | Types.JOINED_TYPE; + } else if (message.isPreKeyBundle()) { + type |= Types.KEY_EXCHANGE_BIT | Types.KEY_EXCHANGE_BUNDLE_BIT; + } else if (message.isSecureMessage()) { + type |= Types.SECURE_MESSAGE_BIT; + } else if (message.isGroup()) { + IncomingGroupUpdateMessage incomingGroupUpdateMessage = (IncomingGroupUpdateMessage) message; + + type |= Types.SECURE_MESSAGE_BIT; + + if (incomingGroupUpdateMessage.isGroupV2()) type |= Types.GROUP_V2_BIT | Types.GROUP_UPDATE_BIT; + else if (incomingGroupUpdateMessage.isUpdate()) type |= Types.GROUP_UPDATE_BIT; + else if (incomingGroupUpdateMessage.isQuit()) type |= Types.GROUP_QUIT_BIT; + + } else if (message.isEndSession()) { + type |= Types.SECURE_MESSAGE_BIT; + type |= Types.END_SESSION_BIT; + } + + if (message.isPush()) type |= Types.PUSH_MESSAGE_BIT; + if (message.isIdentityUpdate()) type |= Types.KEY_EXCHANGE_IDENTITY_UPDATE_BIT; + if (message.isContentPreKeyBundle()) type |= Types.KEY_EXCHANGE_CONTENT_FORMAT; + + if (message.isIdentityVerified()) type |= Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; + else if (message.isIdentityDefault()) type |= Types.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT; + + Recipient recipient = Recipient.resolved(message.getSender()); + + Recipient groupRecipient; + + if (message.getGroupId() == null) { + groupRecipient = null; + } else { + RecipientId id = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(message.getGroupId()); + groupRecipient = Recipient.resolved(id); + } + + boolean silent = message.isIdentityUpdate() || + message.isIdentityVerified() || + message.isIdentityDefault() || + message.isJustAGroupLeave(); + boolean unread = !silent && (Util.isDefaultSmsProvider(context) || + message.isSecureMessage() || + message.isGroup() || + message.isPreKeyBundle()); + + long threadId; + + if (groupRecipient == null) threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + else threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, message.getSender().serialize()); + values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, message.getSentTimestampMillis()); + values.put(DATE_SERVER, message.getServerTimestampMillis()); + values.put(PROTOCOL, message.getProtocol()); + values.put(READ, unread ? 0 : 1); + values.put(SUBSCRIPTION_ID, message.getSubscriptionId()); + values.put(EXPIRES_IN, message.getExpiresIn()); + values.put(UNIDENTIFIED, message.isUnidentified()); + + if (!TextUtils.isEmpty(message.getPseudoSubject())) + values.put(SUBJECT, message.getPseudoSubject()); + + values.put(REPLY_PATH_PRESENT, message.isReplyPathPresent()); + values.put(SERVICE_CENTER, message.getServiceCenterAddress()); + values.put(BODY, message.getMessageBody()); + values.put(TYPE, type); + values.put(THREAD_ID, threadId); + + + if (message.isPush() && isDuplicate(message, threadId)) { + Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring..."); + return Optional.absent(); + } else { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long messageId = db.insert(TABLE_NAME, null, values); + + if (unread) { + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + if (!silent) { + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + } + + if (message.getSubscriptionId() != -1) { + DatabaseFactory.getRecipientDatabase(context).setDefaultSubscriptionId(recipient.getId(), message.getSubscriptionId()); + } + + notifyConversationListeners(threadId); + + if (!silent) { + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + + Optional optional = Optional.of(new InsertResult(messageId, threadId)); + if(optional.isPresent()) { + ArchiveSender.Companion.archiveMessageInbox(context, ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX, (groupRecipient != null && groupRecipient.isGroup()) ? groupRecipient : recipient, message, messageId); + } + return optional; + } + } + + @Override + public Optional insertMessageInbox(IncomingTextMessage message) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE); + } + + @Override + public @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.resolved(recipientId)); + long type = Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; + + type = type & (Types.TOTAL_MASK - Types.ENCRYPTION_MASK) | Types.ENCRYPTION_REMOTE_FAILED_BIT; + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(ADDRESS_DEVICE_ID, senderDeviceId); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, sentTimestamp); + values.put(DATE_SERVER, -1); + values.put(READ, 0); + values.put(TYPE, type); + values.put(THREAD_ID, threadId); + + long messageId = db.insert(TABLE_NAME, null, values); + + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + + notifyConversationListeners(threadId); + + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + + return new InsertResult(messageId, threadId); + } + + @Override + public long insertMessageOutbox(long threadId, OutgoingTextMessage message, + boolean forceSms, long date, InsertListener insertListener) + { + long type = Types.BASE_SENDING_TYPE; + Log.d("MNMN", "insertMessageOutbox = " + message.getMessageBody()); + if (message.isKeyExchange()) type |= Types.KEY_EXCHANGE_BIT; + else if (message.isSecureMessage()) type |= (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT); + else if (message.isEndSession()) type |= Types.END_SESSION_BIT; + if (forceSms) type |= Types.MESSAGE_FORCE_SMS_BIT; + + if (message.isIdentityVerified()) type |= Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; + else if (message.isIdentityDefault()) type |= Types.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT; + + RecipientId recipientId = message.getRecipient().getId(); + Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(date); + + ContentValues contentValues = new ContentValues(6); + contentValues.put(RECIPIENT_ID, recipientId.serialize()); + contentValues.put(THREAD_ID, threadId); + contentValues.put(BODY, message.getMessageBody()); + contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); + contentValues.put(DATE_SENT, date); + contentValues.put(READ, 1); + contentValues.put(TYPE, type); + contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); + contentValues.put(EXPIRES_IN, message.getExpiresIn()); + contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long messageId = db.insert(TABLE_NAME, null, contentValues); + + if (insertListener != null) { + insertListener.onComplete(); + } + + if (!message.isIdentityVerified() && !message.isIdentityDefault()) { + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId); + } + + DatabaseFactory.getThreadDatabase(context).setHasSent(threadId, true); + + notifyConversationListeners(threadId); + + if (!message.isIdentityVerified() && !message.isIdentityDefault()) { + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + + + //Moti Amar + ArchiveSender.Companion.archiveMessageOutbox(context, ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_SEND, message.getRecipient(), message, messageId); + + + return messageId; + } + + @Override + public Cursor getExpirationStartedMessages() { + String where = EXPIRE_STARTED + " > 0"; + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null); + } + + @Override + public SmsMessageRecord getSmsMessage(long messageId) throws NoSuchMessageException { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null); + Reader reader = new Reader(cursor); + SmsMessageRecord record = reader.getNext(); + + reader.close(); + + if (record == null) throw new NoSuchMessageException("No message for ID: " + messageId); + else return record; + } + + @Override + public Cursor getVerboseMessageCursor(long messageId) { + Cursor cursor = getMessageCursor(messageId); + setNotifyVerboseConversationListeners(cursor, getThreadIdForMessage(messageId)); + return cursor; + } + + @Override + public Cursor getMessageCursor(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + return db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null); + } + + @Override + public boolean deleteMessage(long messageId) { + Log.d(TAG, "deleteMessage(" + messageId + ")"); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long threadId = getThreadIdForMessage(messageId); + + db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); + + boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false, true); + + notifyConversationListeners(threadId); + return threadDeleted; + } + + @Override + public void ensureMigration() { + databaseHelper.getWritableDatabase(); + } + + @Override + public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { + return getSmsMessage(messageId); + } + + private boolean isDuplicate(IncomingTextMessage message, long threadId) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + RECIPIENT_ID + " = ? AND " + THREAD_ID + " = ?", + new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().serialize(), String.valueOf(threadId)}, + null, null, null, "1"); + + try { + return cursor != null && cursor.moveToFirst(); + } finally { + if (cursor != null) cursor.close(); + } + } + + @Override + void deleteThread(long threadId) { + Log.d(TAG, "deleteThread(" + threadId + ")"); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); + } + + @Override + void deleteMessagesInThreadBeforeDate(long threadId, long date) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < " + date; + + db.delete(TABLE_NAME, where, SqlUtil.buildArgs(threadId)); + } + + @Override + void deleteAbandonedMessages() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " NOT IN (SELECT _id FROM " + ThreadDatabase.TABLE_NAME + ")"; + + db.delete(TABLE_NAME, where, null); + } + + @Override + public List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " >= ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(queryMessages(where, args, false, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + private Cursor queryMessages(@NonNull String where, @NonNull String[] args, boolean reverse, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + return db.query(TABLE_NAME, + MESSAGE_PROJECTION, + where, + args, + null, + null, + reverse ? ID + " DESC" : null, + limit > 0 ? String.valueOf(limit) : null); + } + + @Override + void deleteThreads(@NonNull Set threadIds) { + Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")"); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = ""; + + for (long threadId : threadIds) { + where += THREAD_ID + " = '" + threadId + "' OR "; + } + + where = where.substring(0, where.length() - 4); + + db.delete(TABLE_NAME, where, null); + } + + @Override + void deleteAllThreads() { + Log.d(TAG, "deleteAllThreads()"); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, null, null); + } + + @Override + public SQLiteDatabase beginTransaction() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + database.beginTransaction(); + return database; + } + + @Override + public void setTransactionSuccessful() { + databaseHelper.getWritableDatabase().setTransactionSuccessful(); + } + + @Override + public void endTransaction(SQLiteDatabase database) { + database.setTransactionSuccessful(); + database.endTransaction(); + } + + @Override + public void endTransaction() { + databaseHelper.getWritableDatabase().endTransaction(); + } + + @Override + public SQLiteStatement createInsertStatement(SQLiteDatabase database) { + return database.compileStatement("INSERT INTO " + TABLE_NAME + " (" + RECIPIENT_ID + ", " + + PERSON + ", " + + DATE_SENT + ", " + + DATE_RECEIVED + ", " + + PROTOCOL + ", " + + READ + ", " + + STATUS + ", " + + TYPE + ", " + + REPLY_PATH_PRESENT + ", " + + SUBJECT + ", " + + BODY + ", " + + SERVICE_CENTER + + ", " + THREAD_ID + ") " + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + } + + @Override + public @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isSent(long messageId) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isGroupQuitMessage(long messageId) { + throw new UnsupportedOperationException(); + } + + @Override + public @Nullable Pair getOldestUnreadMentionDetails(long threadId) { + throw new UnsupportedOperationException(); + } + + @Override + public int getUnreadMentionCount(long threadId) { + throw new UnsupportedOperationException(); + } + + @Override + public void addFailures(long messageId, List failure) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeFailure(long messageId, NetworkFailure failure) { + throw new UnsupportedOperationException(); + } + + @Override + public void markDownloadState(long messageId, long state) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional getNotification(long messageId) { + throw new UnsupportedOperationException(); + } + + @Override + public OutgoingMediaMessage getOutgoingMessage(long messageId) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional insertMessageInbox(IncomingMediaMessage retrieved, String contentLocation, long threadId) throws MmsException { + throw new UnsupportedOperationException(); + } + + @Override + public Pair insertMessageInbox(@NonNull NotificationInd notification, int subscriptionId) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) throws MmsException { + throw new UnsupportedOperationException(); + } + + @Override + public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, @Nullable InsertListener insertListener) throws MmsException { + throw new UnsupportedOperationException(); + } + + @Override + public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, int defaultReceiptStatus, @Nullable InsertListener insertListener) throws MmsException { + throw new UnsupportedOperationException(); + } + + @Override + public void markIncomingNotificationReceived(long threadId) { + throw new UnsupportedOperationException(); + } + + @Override + public MessageDatabase.Reader getMessages(Collection messageIds) { + throw new UnsupportedOperationException(); + } + + public static class Status { + public static final int STATUS_NONE = -1; + public static final int STATUS_COMPLETE = 0; + public static final int STATUS_PENDING = 0x20; + public static final int STATUS_FAILED = 0x40; + } + + public static Reader readerFor(Cursor cursor) { + return new Reader(cursor); + } + + public static OutgoingMessageReader readerFor(OutgoingTextMessage message, long threadId) { + return new OutgoingMessageReader(message, threadId); + } + + public static class OutgoingMessageReader { + + private final OutgoingTextMessage message; + private final long id; + private final long threadId; + + public OutgoingMessageReader(OutgoingTextMessage message, long threadId) { + this.message = message; + this.threadId = threadId; + this.id = new SecureRandom().nextLong(); + } + + public MessageRecord getCurrent() { + return new SmsMessageRecord(id, + message.getMessageBody(), + message.getRecipient(), + message.getRecipient(), + 1, + System.currentTimeMillis(), + System.currentTimeMillis(), + -1, + 0, + message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), + threadId, + 0, + new LinkedList<>(), + message.getSubscriptionId(), + message.getExpiresIn(), + System.currentTimeMillis(), + 0, + false, + Collections.emptyList(), + false, + 0); + } + } + + public static class Reader implements Closeable { + + private final Cursor cursor; + private final Context context; + + public Reader(Cursor cursor) { + this.cursor = cursor; + this.context = ApplicationDependencies.getApplication(); + } + + public SmsMessageRecord getNext() { + if (cursor == null || !cursor.moveToNext()) + return null; + + return getCurrent(); + } + + public int getCount() { + if (cursor == null) return 0; + else return cursor.getCount(); + } + + public SmsMessageRecord getCurrent() { + long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID)); + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID)); + int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID)); + long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE)); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED)); + long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT)); + long dateServer = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.DATE_SERVER)); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID)); + int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS)); + int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT)); + int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT)); + String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES)); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED)); + String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); + boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1; + boolean remoteDelete = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.REMOTE_DELETED)) == 1; + List reactions = parseReactions(cursor); + long notifiedTimestamp = CursorUtil.requireLong(cursor, NOTIFIED_TIMESTAMP); + + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + readReceiptCount = 0; + } + + List mismatches = getMismatches(mismatchDocument); + Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get(); + + return new SmsMessageRecord(messageId, body, recipient, + recipient, + addressDeviceId, + dateSent, dateReceived, dateServer, deliveryReceiptCount, type, + threadId, status, mismatches, subscriptionId, + expiresIn, expireStarted, + readReceiptCount, unidentified, reactions, remoteDelete, + notifiedTimestamp); + } + + private List getMismatches(String document) { + try { + if (!TextUtils.isEmpty(document)) { + return JsonUtils.fromJson(document, IdentityKeyMismatchList.class).getList(); + } + } catch (IOException e) { + Log.w(TAG, e); + } + + return new LinkedList<>(); + } + + @Override + public void close() { + cursor.close(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java new file mode 100644 index 00000000..9ce7ace8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import net.sqlcipher.database.SQLiteStatement; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.StringTokenizer; + +public class SmsMigrator { + + private static final String TAG = SmsMigrator.class.getSimpleName(); + + private static class SystemColumns { + private static final String ADDRESS = "address"; + private static final String PERSON = "person"; + private static final String DATE_RECEIVED = "date"; + private static final String PROTOCOL = "protocol"; + private static final String READ = "read"; + private static final String STATUS = "status"; + private static final String TYPE = "type"; + private static final String SUBJECT = "subject"; + private static final String REPLY_PATH_PRESENT = "reply_path_present"; + private static final String BODY = "body"; + private static final String SERVICE_CENTER = "service_center"; + } + + private static void addStringToStatement(SQLiteStatement statement, Cursor cursor, + int index, String key) + { + int columnIndex = cursor.getColumnIndexOrThrow(key); + + if (cursor.isNull(columnIndex)) { + statement.bindNull(index); + } else { + statement.bindString(index, cursor.getString(columnIndex)); + } + } + + private static void addIntToStatement(SQLiteStatement statement, Cursor cursor, + int index, String key) + { + int columnIndex = cursor.getColumnIndexOrThrow(key); + + if (cursor.isNull(columnIndex)) { + statement.bindNull(index); + } else { + statement.bindLong(index, cursor.getLong(columnIndex)); + } + } + + @SuppressWarnings("SameParameterValue") + private static void addTranslatedTypeToStatement(SQLiteStatement statement, Cursor cursor, int index, String key) + { + int columnIndex = cursor.getColumnIndexOrThrow(key); + + if (cursor.isNull(columnIndex)) { + statement.bindLong(index, SmsDatabase.Types.BASE_INBOX_TYPE); + } else { + long theirType = cursor.getLong(columnIndex); + statement.bindLong(index, SmsDatabase.Types.translateFromSystemBaseType(theirType)); + } + } + + private static boolean isAppropriateTypeForMigration(Cursor cursor, int columnIndex) { + long systemType = cursor.getLong(columnIndex); + long ourType = SmsDatabase.Types.translateFromSystemBaseType(systemType); + + return ourType == MmsSmsColumns.Types.BASE_INBOX_TYPE || + ourType == MmsSmsColumns.Types.BASE_SENT_TYPE || + ourType == MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE; + } + + private static void getContentValuesForRow(Context context, Cursor cursor, long threadId, SQLiteStatement statement) { + String address = cursor.getString(cursor.getColumnIndexOrThrow(SystemColumns.ADDRESS)); + RecipientId id = Recipient.external(context, address).getId(); + + statement.bindString(1, id.serialize()); + addIntToStatement(statement, cursor, 2, SystemColumns.PERSON); + addIntToStatement(statement, cursor, 3, SystemColumns.DATE_RECEIVED); + addIntToStatement(statement, cursor, 4, SystemColumns.DATE_RECEIVED); + addIntToStatement(statement, cursor, 5, SystemColumns.PROTOCOL); + addIntToStatement(statement, cursor, 6, SystemColumns.READ); + addIntToStatement(statement, cursor, 7, SystemColumns.STATUS); + addTranslatedTypeToStatement(statement, cursor, 8, SystemColumns.TYPE); + addIntToStatement(statement, cursor, 9, SystemColumns.REPLY_PATH_PRESENT); + addStringToStatement(statement, cursor, 10, SystemColumns.SUBJECT); + addStringToStatement(statement, cursor, 11, SystemColumns.BODY); + addStringToStatement(statement, cursor, 12, SystemColumns.SERVICE_CENTER); + + statement.bindLong(13, threadId); + } + + private static String getTheirCanonicalAddress(Context context, String theirRecipientId) { + Uri uri = Uri.parse("content://mms-sms/canonical-address/" + theirRecipientId); + Cursor cursor = null; + + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(0); + } else { + return null; + } + } catch (IllegalStateException iae) { + Log.w(TAG, iae); + return null; + } finally { + if (cursor != null) + cursor.close(); + } + } + + private static @Nullable Set getOurRecipients(Context context, String theirRecipients) { + StringTokenizer tokenizer = new StringTokenizer(theirRecipients.trim(), " "); + Set recipientList = new HashSet<>(); + + while (tokenizer.hasMoreTokens()) { + String theirRecipientId = tokenizer.nextToken(); + String address = getTheirCanonicalAddress(context, theirRecipientId); + + if (address != null) { + recipientList.add(Recipient.external(context, address)); + } + } + + if (recipientList.isEmpty()) return null; + else return recipientList; + } + + private static void migrateConversation(Context context, SmsMigrationProgressListener listener, + ProgressDescription progress, + long theirThreadId, long ourThreadId) + { + MessageDatabase ourSmsDatabase = DatabaseFactory.getSmsDatabase(context); + Cursor cursor = null; + SQLiteStatement statement = null; + + try { + Uri uri = Uri.parse("content://sms/conversations/" + theirThreadId); + + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); + } catch (SQLiteException e) { + /// Work around for weird sony-specific (?) bug: #4309 + Log.w(TAG, e); + return; + } + + SQLiteDatabase transaction = ourSmsDatabase.beginTransaction(); + statement = ourSmsDatabase.createInsertStatement(transaction); + + while (cursor != null && cursor.moveToNext()) { + int addressColumn = cursor.getColumnIndexOrThrow(SystemColumns.ADDRESS); + int typeColumn = cursor.getColumnIndex(SmsDatabase.TYPE); + + if (!cursor.isNull(addressColumn) && (cursor.isNull(typeColumn) || isAppropriateTypeForMigration(cursor, typeColumn))) { + getContentValuesForRow(context, cursor, ourThreadId, statement); + statement.execute(); + } + + listener.progressUpdate(new ProgressDescription(progress, cursor.getCount(), cursor.getPosition())); + } + + ourSmsDatabase.endTransaction(transaction); + DatabaseFactory.getThreadDatabase(context).update(ourThreadId, true); + DatabaseFactory.getThreadDatabase(context).notifyConversationListeners(ourThreadId); + + } finally { + if (statement != null) + statement.close(); + if (cursor != null) + cursor.close(); + } + } + + public static void migrateDatabase(Context context, SmsMigrationProgressListener listener) + { +// if (context.getSharedPreferences("SecureSMS", Context.MODE_PRIVATE).getBoolean("migrated", false)) +// return; + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Cursor cursor = null; + + try { + Uri threadListUri = Uri.parse("content://mms-sms/conversations?simple=true"); + cursor = context.getContentResolver().query(threadListUri, null, null, null, "date ASC"); + + while (cursor != null && cursor.moveToNext()) { + long theirThreadId = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + String theirRecipients = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); + Set ourRecipients = getOurRecipients(context, theirRecipients); + ProgressDescription progress = new ProgressDescription(cursor.getCount(), cursor.getPosition(), 100, 0); + + if (ourRecipients != null) { + if (ourRecipients.size() == 1) { + long ourThreadId = threadDatabase.getThreadIdFor(ourRecipients.iterator().next()); + migrateConversation(context, listener, progress, theirThreadId, ourThreadId); + } else if (ourRecipients.size() > 1) { + ourRecipients.add(Recipient.self()); + + List recipientIds = Stream.of(ourRecipients).map(Recipient::getId).toList(); + + GroupId.Mms ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateMmsGroupForMembers(recipientIds); + RecipientId ourGroupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(ourGroupId); + Recipient ourGroupRecipient = Recipient.resolved(ourGroupRecipientId); + long ourThreadId = threadDatabase.getThreadIdFor(ourGroupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); + + migrateConversation(context, listener, progress, theirThreadId, ourThreadId); + } + } + + progress.incrementPrimaryComplete(); + listener.progressUpdate(progress); + } + } finally { + if (cursor != null) + cursor.close(); + } + + context.getSharedPreferences("SecureSMS", Context.MODE_PRIVATE).edit() + .putBoolean("migrated", true).apply(); + } + + public interface SmsMigrationProgressListener { + void progressUpdate(ProgressDescription description); + } + + public static class ProgressDescription { + public final int primaryTotal; + public int primaryComplete; + public final int secondaryTotal; + public final int secondaryComplete; + + ProgressDescription(int primaryTotal, int primaryComplete, + int secondaryTotal, int secondaryComplete) + { + this.primaryTotal = primaryTotal; + this.primaryComplete = primaryComplete; + this.secondaryTotal = secondaryTotal; + this.secondaryComplete = secondaryComplete; + } + + ProgressDescription(ProgressDescription that, int secondaryTotal, int secondaryComplete) { + this.primaryComplete = that.primaryComplete; + this.primaryTotal = that.primaryTotal; + this.secondaryComplete = secondaryComplete; + this.secondaryTotal = secondaryTotal; + } + + void incrementPrimaryComplete() { + primaryComplete += 1; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java b/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java new file mode 100644 index 00000000..88e5c735 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.database; + +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteDatabaseHook; + +/** + * Standard hook for setting common SQLCipher PRAGMAs. + */ +public final class SqlCipherDatabaseHook implements SQLiteDatabaseHook { + + @Override + public void preKey(SQLiteDatabase db) { + db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;"); + db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;"); + } + + @Override + public void postKey(SQLiteDatabase db) { + db.rawExecSQL("PRAGMA kdf_iter = '1';"); + db.rawExecSQL("PRAGMA cipher_page_size = 4096;"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java new file mode 100644 index 00000000..0f96dc61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java @@ -0,0 +1,532 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.IncomingSticker; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SqlUtil; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class StickerDatabase extends Database { + + private static final String TAG = Log.tag(StickerDatabase.class); + + public static final String TABLE_NAME = "sticker"; + public static final String _ID = "_id"; + static final String PACK_ID = "pack_id"; + private static final String PACK_KEY = "pack_key"; + private static final String PACK_TITLE = "pack_title"; + private static final String PACK_AUTHOR = "pack_author"; + private static final String STICKER_ID = "sticker_id"; + private static final String EMOJI = "emoji"; + public static final String CONTENT_TYPE = "content_type"; + private static final String COVER = "cover"; + private static final String PACK_ORDER = "pack_order"; + private static final String INSTALLED = "installed"; + private static final String LAST_USED = "last_used"; + public static final String FILE_PATH = "file_path"; + public static final String FILE_LENGTH = "file_length"; + public static final String FILE_RANDOM = "file_random"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + _ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + PACK_ID + " TEXT NOT NULL, " + + PACK_KEY + " TEXT NOT NULL, " + + PACK_TITLE + " TEXT NOT NULL, " + + PACK_AUTHOR + " TEXT NOT NULL, " + + STICKER_ID + " INTEGER, " + + COVER + " INTEGER, " + + PACK_ORDER + " INTEGER, " + + EMOJI + " TEXT NOT NULL, " + + CONTENT_TYPE + " TEXT DEFAULT NULL, " + + LAST_USED + " INTEGER, " + + INSTALLED + " INTEGER," + + FILE_PATH + " TEXT NOT NULL, " + + FILE_LENGTH + " INTEGER, " + + FILE_RANDOM + " BLOB, " + + "UNIQUE(" + PACK_ID + ", " + STICKER_ID + ", " + COVER + ") ON CONFLICT IGNORE)"; + + public static final String[] CREATE_INDEXES = { + "CREATE INDEX IF NOT EXISTS sticker_pack_id_index ON " + TABLE_NAME + " (" + PACK_ID + ");", + "CREATE INDEX IF NOT EXISTS sticker_sticker_id_index ON " + TABLE_NAME + " (" + STICKER_ID + ");" + }; + + public static final String DIRECTORY = "stickers"; + + private final AttachmentSecret attachmentSecret; + + public StickerDatabase(Context context, SQLCipherOpenHelper databaseHelper, AttachmentSecret attachmentSecret) { + super(context, databaseHelper); + this.attachmentSecret = attachmentSecret; + } + + public void insertSticker(@NonNull IncomingSticker sticker, @NonNull InputStream dataStream, boolean notify) throws IOException { + FileInfo fileInfo = saveStickerImage(dataStream); + ContentValues contentValues = new ContentValues(); + + contentValues.put(PACK_ID, sticker.getPackId()); + contentValues.put(PACK_KEY, sticker.getPackKey()); + contentValues.put(PACK_TITLE, sticker.getPackTitle()); + contentValues.put(PACK_AUTHOR, sticker.getPackAuthor()); + contentValues.put(STICKER_ID, sticker.getStickerId()); + contentValues.put(EMOJI, sticker.getEmoji()); + contentValues.put(CONTENT_TYPE, sticker.getContentType()); + contentValues.put(COVER, sticker.isCover() ? 1 : 0); + contentValues.put(INSTALLED, sticker.isInstalled() ? 1 : 0); + contentValues.put(FILE_PATH, fileInfo.getFile().getAbsolutePath()); + contentValues.put(FILE_LENGTH, fileInfo.getLength()); + contentValues.put(FILE_RANDOM, fileInfo.getRandom()); + + long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); + if (id == -1) { + String selection = PACK_ID + " = ? AND " + STICKER_ID + " = ? AND " + COVER + " = ?"; + String[] args = SqlUtil.buildArgs(sticker.getPackId(), sticker.getStickerId(), (sticker.isCover() ? 1 : 0)); + + id = databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, selection, args); + } + + if (id > 0) { + notifyStickerListeners(); + + if (sticker.isCover()) { + notifyStickerPackListeners(); + + if (sticker.isInstalled() && notify) { + broadcastInstallEvent(sticker.getPackId()); + } + } + } + } + + public @Nullable StickerRecord getSticker(@NonNull String packId, int stickerId, boolean isCover) { + String selection = PACK_ID + " = ? AND " + STICKER_ID + " = ? AND " + COVER + " = ?"; + String[] args = new String[] { packId, String.valueOf(stickerId), String.valueOf(isCover ? 1 : 0) }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, "1")) { + return new StickerRecordReader(cursor).getNext(); + } + } + + public @Nullable StickerPackRecord getStickerPack(@NonNull String packId) { + String query = PACK_ID + " = ? AND " + COVER + " = ?"; + String[] args = new String[] { packId, "1" }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null, "1")) { + return new StickerPackRecordReader(cursor).getNext(); + } + } + + public @Nullable Cursor getInstalledStickerPacks() { + String selection = COVER + " = ? AND " + INSTALLED + " = ?"; + String[] args = new String[] { "1", "1" }; + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, PACK_ORDER + " ASC"); + + setNotifyStickerPackListeners(cursor); + return cursor; + } + + public @Nullable Cursor getStickersByEmoji(@NonNull String emoji) { + String selection = EMOJI + " LIKE ? AND " + COVER + " = ?"; + String[] args = new String[] { "%"+emoji+"%", "0" }; + + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null); + setNotifyStickerListeners(cursor); + + return cursor; + } + + public @Nullable Cursor getAllStickerPacks() { + return getAllStickerPacks(null); + } + + public @Nullable Cursor getAllStickerPacks(@Nullable String limit) { + String query = COVER + " = ?"; + String[] args = new String[] { "1" }; + Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, PACK_ORDER + " ASC", limit); + setNotifyStickerPackListeners(cursor); + + return cursor; + } + + public @Nullable Cursor getStickersForPack(@NonNull String packId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String selection = PACK_ID + " = ? AND " + COVER + " = ?"; + String[] args = new String[] { packId, "0" }; + + Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null); + setNotifyStickerListeners(cursor); + + return cursor; + } + + public @Nullable Cursor getRecentlyUsedStickers(int limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String selection = LAST_USED + " > ? AND " + COVER + " = ?"; + String[] args = new String[] { "0", "0" }; + + Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, LAST_USED + " DESC", String.valueOf(limit)); + setNotifyStickerListeners(cursor); + + return cursor; + } + + public @NonNull Set getAllStickerFiles() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + Set files = new HashSet<>(); + try (Cursor cursor = db.query(TABLE_NAME, new String[] { FILE_PATH }, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + files.add(CursorUtil.requireString(cursor, FILE_PATH)); + } + } + + return files; + } + + public @Nullable InputStream getStickerStream(long rowId) throws IOException { + String selection = _ID + " = ?"; + String[] args = new String[] { String.valueOf(rowId) }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + String path = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH)); + byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(FILE_RANDOM)); + + if (path != null) { + return ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(path), 0); + } else { + Log.w(TAG, "getStickerStream("+rowId+") - No sticker data"); + } + } else { + Log.i(TAG, "getStickerStream("+rowId+") - Sticker not found."); + } + } + + return null; + } + + public boolean isPackInstalled(@NonNull String packId) { + StickerPackRecord record = getStickerPack(packId); + + return (record != null && record.isInstalled()); + } + + public boolean isPackAvailableAsReference(@NonNull String packId) { + return getStickerPack(packId) != null; + } + + public void updateStickerLastUsedTime(long rowId, long lastUsed) { + String selection = _ID + " = ?"; + String[] args = new String[] { String.valueOf(rowId) }; + ContentValues values = new ContentValues(); + + values.put(LAST_USED, lastUsed); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, selection, args); + + notifyStickerListeners(); + notifyStickerPackListeners(); + } + + public void markPackAsInstalled(@NonNull String packKey, boolean notify) { + updatePackInstalled(databaseHelper.getWritableDatabase(), packKey, true, notify); + notifyStickerPackListeners(); + } + + public void deleteOrphanedPacks() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String query = "SELECT " + PACK_ID + " FROM " + TABLE_NAME + " WHERE " + INSTALLED + " = ? AND " + + PACK_ID + " NOT IN (" + + "SELECT DISTINCT " + AttachmentDatabase.STICKER_PACK_ID + " FROM " + AttachmentDatabase.TABLE_NAME + " " + + "WHERE " + AttachmentDatabase.STICKER_PACK_ID + " NOT NULL" + + ")"; + String[] args = new String[] { "0" }; + + db.beginTransaction(); + + try { + boolean performedDelete = false; + + try (Cursor cursor = db.rawQuery(query, args)) { + while (cursor != null && cursor.moveToNext()) { + String packId = cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID)); + + if (!BlessedPacks.contains(packId)) { + deletePack(db, packId); + performedDelete = true; + } + } + } + + db.setTransactionSuccessful(); + + if (performedDelete) { + notifyStickerPackListeners(); + notifyStickerListeners(); + } + } finally { + db.endTransaction(); + } + } + + public void uninstallPack(@NonNull String packId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + updatePackInstalled(db, packId, false, false); + deleteStickersInPackExceptCover(db, packId); + + db.setTransactionSuccessful(); + notifyStickerPackListeners(); + notifyStickerListeners(); + } finally { + db.endTransaction(); + } + } + + public void updatePackOrder(@NonNull List packsInOrder) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + String selection = PACK_ID + " = ? AND " + COVER + " = ?"; + + for (int i = 0; i < packsInOrder.size(); i++) { + String[] args = new String[]{ packsInOrder.get(i).getPackId(), "1" }; + ContentValues values = new ContentValues(); + + values.put(PACK_ORDER, i); + + db.update(TABLE_NAME, values, selection, args); + } + + db.setTransactionSuccessful(); + notifyStickerPackListeners(); + } finally { + db.endTransaction(); + } + } + + private void updatePackInstalled(@NonNull SQLiteDatabase db, @NonNull String packId, boolean installed, boolean notify) { + StickerPackRecord existing = getStickerPack(packId); + + if (existing != null && existing.isInstalled() == installed) { + return; + } + + String selection = PACK_ID + " = ?"; + String[] args = new String[]{ packId }; + ContentValues values = new ContentValues(1); + + values.put(INSTALLED, installed ? 1 : 0); + db.update(TABLE_NAME, values, selection, args); + + if (installed && notify) { + broadcastInstallEvent(packId); + } + } + + private FileInfo saveStickerImage(@NonNull InputStream inputStream) throws IOException { + File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File file = File.createTempFile("sticker", ".mms", partsDirectory); + Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, file, false); + long length = StreamUtil.copy(inputStream, out.second); + + return new FileInfo(file, length, out.first); + } + + private void deleteSticker(@NonNull SQLiteDatabase db, long rowId, @Nullable String filePath) { + String selection = _ID + " = ?"; + String[] args = new String[] { String.valueOf(rowId) }; + + db.delete(TABLE_NAME, selection, args); + + if (!TextUtils.isEmpty(filePath)) { + new File(filePath).delete(); + } + } + + private void deletePack(@NonNull SQLiteDatabase db, @NonNull String packId) { + String selection = PACK_ID + " = ?"; + String[] args = new String[] { packId }; + + db.delete(TABLE_NAME, selection, args); + + deleteStickersInPack(db, packId); + } + + private void deleteStickersInPack(@NonNull SQLiteDatabase db, @NonNull String packId) { + String selection = PACK_ID + " = ?"; + String[] args = new String[] { packId }; + + db.beginTransaction(); + + try { + try (Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String filePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH)); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(_ID)); + + deleteSticker(db, rowId, filePath); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + db.delete(TABLE_NAME, selection, args); + } + + private void deleteStickersInPackExceptCover(@NonNull SQLiteDatabase db, @NonNull String packId) { + String selection = PACK_ID + " = ? AND " + COVER + " = ?"; + String[] args = new String[] { packId, "0" }; + + db.beginTransaction(); + + try { + try (Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(_ID)); + String filePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH)); + + deleteSticker(db, rowId, filePath); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void broadcastInstallEvent(@NonNull String packId) { + StickerPackRecord pack = getStickerPack(packId); + + if (pack != null) { + EventBus.getDefault().postSticky(new StickerPackInstallEvent(new DecryptableUri(pack.getCover().getUri()))); + } + } + + private static final class FileInfo { + private final File file; + private final long length; + private final byte[] random; + + private FileInfo(@NonNull File file, long length, @NonNull byte[] random) { + this.file = file; + this.length = length; + this.random = random; + } + + public File getFile() { + return file; + } + + public long getLength() { + return length; + } + + public byte[] getRandom() { + return random; + } + } + + public static final class StickerRecordReader implements Closeable { + + private final Cursor cursor; + + public StickerRecordReader(@Nullable Cursor cursor) { + this.cursor = cursor; + } + + public @Nullable StickerRecord getNext() { + if (cursor == null || !cursor.moveToNext()) { + return null; + } + + return getCurrent(); + } + + public @NonNull StickerRecord getCurrent() { + return new StickerRecord(cursor.getLong(cursor.getColumnIndexOrThrow(_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(PACK_KEY)), + cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)), + cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE)), + cursor.getLong(cursor.getColumnIndexOrThrow(FILE_LENGTH)), + cursor.getInt(cursor.getColumnIndexOrThrow(COVER)) == 1); + } + + @Override + public void close() { + if (cursor != null) { + cursor.close(); + } + } + } + + public static final class StickerPackRecordReader implements Closeable { + + private final Cursor cursor; + + public StickerPackRecordReader(@Nullable Cursor cursor) { + this.cursor = cursor; + } + + public @Nullable StickerPackRecord getNext() { + if (cursor == null || !cursor.moveToNext()) { + return null; + } + + return getCurrent(); + } + + public @NonNull StickerPackRecord getCurrent() { + StickerRecord cover = new StickerRecordReader(cursor).getCurrent(); + + return new StickerPackRecord(cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(PACK_KEY)), + cursor.getString(cursor.getColumnIndexOrThrow(PACK_TITLE)), + cursor.getString(cursor.getColumnIndexOrThrow(PACK_AUTHOR)), + cover, + cursor.getInt(cursor.getColumnIndexOrThrow(INSTALLED)) == 1); + } + + @Override + public void close() { + if (cursor != null) { + cursor.close(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java new file mode 100644 index 00000000..ddada974 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * A list of storage keys whose types we do not currently have syncing logic for. We need to + * remember that these keys exist so that we don't blast any data away. + */ +public class StorageKeyDatabase extends Database { + + private static final String TABLE_NAME = "storage_key"; + private static final String ID = "_id"; + private static final String TYPE = "type"; + private static final String STORAGE_ID = "key"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TYPE + " INTEGER, " + + STORAGE_ID + " TEXT UNIQUE)"; + + public static final String[] CREATE_INDEXES = new String[] { + "CREATE INDEX IF NOT EXISTS storage_key_type_index ON " + TABLE_NAME + " (" + TYPE + ");" + }; + + public StorageKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public List getAllKeys() { + List keys = new ArrayList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_ID)); + int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); + try { + keys.add(StorageId.forType(Base64.decode(keyEncoded), type)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } + + return keys; + } + + public @Nullable SignalStorageRecord getById(@NonNull byte[] rawId) { + String query = STORAGE_ID + " = ?"; + String[] args = new String[] { Base64.encodeBytes(rawId) }; + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); + return SignalStorageRecord.forUnknown(StorageId.forType(rawId, type)); + } else { + return null; + } + } + } + + public void applyStorageSyncUpdates(@NonNull Collection inserts, + @NonNull Collection deletes) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (SignalStorageRecord insert : inserts) { + ContentValues values = new ContentValues(); + values.put(TYPE, insert.getType()); + values.put(STORAGE_ID, Base64.encodeBytes(insert.getId().getRaw())); + + db.insert(TABLE_NAME, null, values); + } + + String deleteQuery = STORAGE_ID + " = ?"; + + for (SignalStorageRecord delete : deletes) { + String[] args = new String[] { Base64.encodeBytes(delete.getId().getRaw()) }; + db.delete(TABLE_NAME, deleteQuery, args); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + } + + public void deleteByType(int type) { + String query = TYPE + " = ?"; + String[] args = new String[]{String.valueOf(type)}; + + databaseHelper.getWritableDatabase().delete(TABLE_NAME, query, args); + } + + public void deleteAll() { + databaseHelper.getWritableDatabase().delete(TABLE_NAME, null, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java new file mode 100644 index 00000000..8bb5dd07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiStrings; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Objects; + +public final class ThreadBodyUtil { + + private static final String TAG = Log.tag(ThreadBodyUtil.class); + + private ThreadBodyUtil() { + } + + public static @NonNull String getFormattedBodyFor(@NonNull Context context, @NonNull MessageRecord record) { + if (record.isMms()) { + return getFormattedBodyForMms(context, (MmsMessageRecord) record); + } + + return record.getBody(); + } + + private static @NonNull String getFormattedBodyForMms(@NonNull Context context, @NonNull MmsMessageRecord record) { + if (record.getSharedContacts().size() > 0) { + Contact contact = record.getSharedContacts().get(0); + + return ContactUtil.getStringSummary(context, contact).toString(); + } else if (record.getSlideDeck().getDocumentSlide() != null) { + return format(context, record, EmojiStrings.FILE, R.string.ThreadRecord_file); + } else if (record.getSlideDeck().getAudioSlide() != null) { + return format(context, record, EmojiStrings.AUDIO, R.string.ThreadRecord_voice_message); + } else if (MessageRecordUtil.hasSticker(record)) { + String emoji = getStickerEmoji(record); + return format(context, record, emoji, R.string.ThreadRecord_sticker); + } + + boolean hasImage = false; + boolean hasVideo = false; + boolean hasGif = false; + + for (Slide slide : record.getSlideDeck().getSlides()) { + hasVideo |= slide.hasVideo(); + hasImage |= slide.hasImage(); + hasGif |= slide instanceof GifSlide; + } + + if (hasGif) { + return format(context, record, EmojiStrings.GIF, R.string.ThreadRecord_gif); + } else if (hasVideo) { + return format(context, record, EmojiStrings.VIDEO, R.string.ThreadRecord_video); + } else if (hasImage) { + return format(context, record, EmojiStrings.PHOTO, R.string.ThreadRecord_photo); + } else if (TextUtils.isEmpty(record.getBody())) { + return context.getString(R.string.ThreadRecord_media_message); + } else { + return getBody(context, record); + } + } + + private static @NonNull String format(@NonNull Context context, @NonNull MessageRecord record, @NonNull String emoji, @StringRes int defaultStringRes) { + return String.format("%s %s", emoji, getBodyOrDefault(context, record, defaultStringRes)); + } + + private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) { + return TextUtils.isEmpty(record.getBody()) ? context.getString(defaultStringRes) : getBody(context, record); + } + + private static @NonNull String getBody(@NonNull Context context, @NonNull MessageRecord record) { + return MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()).toString(); + } + + private static @NonNull String getStickerEmoji(@NonNull MessageRecord record) { + StickerSlide slide = Objects.requireNonNull(((MmsMessageRecord) record).getSlideDeck().getStickerSlide()); + + return Util.isEmpty(slide.getEmoji()) ? EmojiStrings.STICKER + : slide.getEmoji(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java new file mode 100644 index 00000000..6e122103 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -0,0 +1,1766 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.MergeCursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.jsoup.helper.StringUtil; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientDetails; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public class ThreadDatabase extends Database { + + private static final String TAG = ThreadDatabase.class.getSimpleName(); + + public static final long NO_TRIM_BEFORE_DATE_SET = 0; + public static final int NO_TRIM_MESSAGE_COUNT_SET = Integer.MAX_VALUE; + + public static final String TABLE_NAME = "thread"; + public static final String ID = "_id"; + public static final String DATE = "date"; + public static final String MESSAGE_COUNT = "message_count"; + public static final String RECIPIENT_ID = "recipient_ids"; + public static final String SNIPPET = "snippet"; + private static final String SNIPPET_CHARSET = "snippet_cs"; + public static final String READ = "read"; + public static final String UNREAD_COUNT = "unread_count"; + public static final String TYPE = "type"; + private static final String ERROR = "error"; + public static final String SNIPPET_TYPE = "snippet_type"; + public static final String SNIPPET_URI = "snippet_uri"; + public static final String SNIPPET_CONTENT_TYPE = "snippet_content_type"; + public static final String SNIPPET_EXTRAS = "snippet_extras"; + public static final String ARCHIVED = "archived"; + public static final String STATUS = "status"; + public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; + public static final String READ_RECEIPT_COUNT = "read_receipt_count"; + public static final String EXPIRES_IN = "expires_in"; + public static final String LAST_SEEN = "last_seen"; + public static final String HAS_SENT = "has_sent"; + private static final String LAST_SCROLLED = "last_scrolled"; + static final String PINNED = "pinned"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + DATE + " INTEGER DEFAULT 0, " + + MESSAGE_COUNT + " INTEGER DEFAULT 0, " + + RECIPIENT_ID + " INTEGER, " + + SNIPPET + " TEXT, " + + SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + + READ + " INTEGER DEFAULT " + ReadStatus.READ.serialize() + ", " + + TYPE + " INTEGER DEFAULT 0, " + + ERROR + " INTEGER DEFAULT 0, " + + SNIPPET_TYPE + " INTEGER DEFAULT 0, " + + SNIPPET_URI + " TEXT DEFAULT NULL, " + + SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " + + SNIPPET_EXTRAS + " TEXT DEFAULT NULL, " + + ARCHIVED + " INTEGER DEFAULT 0, " + + STATUS + " INTEGER DEFAULT 0, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + EXPIRES_IN + " INTEGER DEFAULT 0, " + + LAST_SEEN + " INTEGER DEFAULT 0, " + + HAS_SENT + " INTEGER DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + UNREAD_COUNT + " INTEGER DEFAULT 0, " + + LAST_SCROLLED + " INTEGER DEFAULT 0, " + + PINNED + " INTEGER DEFAULT 0);"; + + public static final String[] CREATE_INDEXS = { + "CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", + "CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");", + "CREATE INDEX IF NOT EXISTS thread_pinned_index ON " + TABLE_NAME + " (" + PINNED + ");", + }; + + private static final String[] THREAD_PROJECTION = { + ID, DATE, MESSAGE_COUNT, RECIPIENT_ID, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE, + SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, LAST_SCROLLED, PINNED + }; + + private static final List TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION) + .map(columnName -> TABLE_NAME + "." + columnName) + .toList(); + + private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION), + Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION_NO_ID)), + Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)) + .toList(); + + private static final String ORDER_BY_DEFAULT = TABLE_NAME + "." + DATE + " DESC"; + + public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + private long createThreadForRecipient(@NonNull RecipientId recipientId, boolean group, int distributionType) { + if (recipientId.isUnknown()) { + throw new AssertionError("Cannot create a thread for an unknown recipient!"); + } + + ContentValues contentValues = new ContentValues(4); + long date = System.currentTimeMillis(); + + contentValues.put(DATE, date - date % 1000); + contentValues.put(RECIPIENT_ID, recipientId.serialize()); + + if (group) + contentValues.put(TYPE, distributionType); + + contentValues.put(MESSAGE_COUNT, 0); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + return db.insert(TABLE_NAME, null, contentValues); + } + + private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, + @Nullable String contentType, @Nullable Extra extra, + long date, int status, int deliveryReceiptCount, long type, boolean unarchive, + long expiresIn, int readReceiptCount) + { + String extraSerialized = null; + + if (extra != null) { + try { + extraSerialized = JsonUtils.toJson(extra); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(DATE, date - date % 1000); + contentValues.put(SNIPPET, body); + contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); + contentValues.put(SNIPPET_TYPE, type); + contentValues.put(SNIPPET_CONTENT_TYPE, contentType); + contentValues.put(SNIPPET_EXTRAS, extraSerialized); + contentValues.put(MESSAGE_COUNT, count); + contentValues.put(STATUS, status); + contentValues.put(DELIVERY_RECEIPT_COUNT, deliveryReceiptCount); + contentValues.put(READ_RECEIPT_COUNT, readReceiptCount); + contentValues.put(EXPIRES_IN, expiresIn); + + if (unarchive) { + contentValues.put(ARCHIVED, 0); + } + + if (count != getConversationMessageCount(threadId)) { + contentValues.put(LAST_SCROLLED, 0); + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); + notifyConversationListListeners(); + } + + public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) { + if (isSilentType(type)) { + return; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(DATE, date - date % 1000); + contentValues.put(SNIPPET, snippet); + contentValues.put(SNIPPET_TYPE, type); + contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); + + if (unarchive) { + contentValues.put(ARCHIVED, 0); + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); + notifyConversationListListeners(); + } + + public void trimAllThreads(int length, long trimBeforeDate) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return; + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + GroupReceiptDatabase groupReceiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] { ID }, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + trimThreadInternal(CursorUtil.requireLong(cursor, ID), length, trimBeforeDate); + } + } + + db.beginTransaction(); + + try { + mmsSmsDatabase.deleteAbandonedMessages(); + attachmentDatabase.trimAllAbandonedAttachments(); + groupReceiptDatabase.deleteAbandonedRows(); + mentionDatabase.deleteAbandonedMentions(); + attachmentDatabase.deleteAbandonedAttachmentFiles(); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + + notifyAttachmentListeners(); + notifyStickerListeners(); + notifyStickerPackListeners(); + } + + public void trimThread(long threadId, int length, long trimBeforeDate) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return; + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + GroupReceiptDatabase groupReceiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + + db.beginTransaction(); + + try { + trimThreadInternal(threadId, length, trimBeforeDate); + mmsSmsDatabase.deleteAbandonedMessages(); + attachmentDatabase.trimAllAbandonedAttachments(); + groupReceiptDatabase.deleteAbandonedRows(); + mentionDatabase.deleteAbandonedMentions(); + attachmentDatabase.deleteAbandonedAttachmentFiles(); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + + notifyAttachmentListeners(); + notifyStickerListeners(); + notifyStickerPackListeners(); + } + + private void trimThreadInternal(long threadId, int length, long trimBeforeDate) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return; + } + + if (length != NO_TRIM_MESSAGE_COUNT_SET) { + try (Cursor cursor = DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId)) { + if (cursor != null && length > 0 && cursor.getCount() > length) { + cursor.moveToPosition(length - 1); + trimBeforeDate = Math.max(trimBeforeDate, cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED))); + } + } + } + + if (trimBeforeDate != NO_TRIM_BEFORE_DATE_SET) { + Log.i(TAG, "Trimming thread: " + threadId + " before: " + trimBeforeDate); + + DatabaseFactory.getMmsSmsDatabase(context).deleteMessagesInThreadBeforeDate(threadId, trimBeforeDate); + + update(threadId, false); + notifyConversationListeners(threadId); + } + } + + public List setAllThreadsRead() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + contentValues.put(READ, ReadStatus.READ.serialize()); + contentValues.put(UNREAD_COUNT, 0); + + db.update(TABLE_NAME, contentValues, null, null); + + final List smsRecords = DatabaseFactory.getSmsDatabase(context).setAllMessagesRead(); + final List mmsRecords = DatabaseFactory.getMmsDatabase(context).setAllMessagesRead(); + + DatabaseFactory.getSmsDatabase(context).setAllReactionsSeen(); + DatabaseFactory.getMmsDatabase(context).setAllReactionsSeen(); + + notifyConversationListListeners(); + + return Util.concatenatedList(smsRecords, mmsRecords); + } + + public boolean hasCalledSince(@NonNull Recipient recipient, long timestamp) { + return hasReceivedAnyCallsSince(getThreadIdFor(recipient), timestamp); + } + + public boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { + return DatabaseFactory.getMmsSmsDatabase(context).hasReceivedAnyCallsSince(threadId, timestamp); + } + + public List setEntireThreadRead(long threadId) { + setRead(threadId, false); + + final List smsRecords = DatabaseFactory.getSmsDatabase(context).setEntireThreadRead(threadId); + final List mmsRecords = DatabaseFactory.getMmsDatabase(context).setEntireThreadRead(threadId); + + return Util.concatenatedList(smsRecords, mmsRecords); + } + + public List setRead(long threadId, boolean lastSeen) { + return setReadInternal(Collections.singletonList(threadId), lastSeen, -1); + } + + public List setReadSince(long threadId, boolean lastSeen, long sinceTimestamp) { + return setReadInternal(Collections.singletonList(threadId), lastSeen, sinceTimestamp); + } + + public List setRead(Collection threadIds, boolean lastSeen) { + return setReadInternal(threadIds, lastSeen, -1); + } + + private List setReadInternal(Collection threadIds, boolean lastSeen, long sinceTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + List smsRecords = new LinkedList<>(); + List mmsRecords = new LinkedList<>(); + boolean needsSync = false; + + db.beginTransaction(); + + try { + ContentValues contentValues = new ContentValues(2); + contentValues.put(READ, ReadStatus.READ.serialize()); + + if (lastSeen) { + contentValues.put(LAST_SEEN, sinceTimestamp == -1 ? System.currentTimeMillis() : sinceTimestamp); + } + + for (long threadId : threadIds) { + ThreadRecord previous = getThreadRecord(threadId); + + smsRecords.addAll(DatabaseFactory.getSmsDatabase(context).setMessagesReadSince(threadId, sinceTimestamp)); + mmsRecords.addAll(DatabaseFactory.getMmsDatabase(context).setMessagesReadSince(threadId, sinceTimestamp)); + + DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId, sinceTimestamp); + DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId, sinceTimestamp); + + int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId); + + contentValues.put(UNREAD_COUNT, unreadCount); + + db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); + + if (previous != null && previous.isForcedUnread()) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(previous.getRecipient().getId()); + needsSync = true; + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(new HashSet<>(threadIds)); + notifyConversationListListeners(); + + if (needsSync) { + StorageSyncHelper.scheduleSyncForDataChange(); + } + + return Util.concatenatedList(smsRecords, mmsRecords); + } + + public void setForcedUnread(@NonNull Collection threadIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + List recipientIds = getRecipientIdsForThreadIds(threadIds); + SqlUtil.Query query = SqlUtil.buildCollectionQuery(ID, threadIds); + ContentValues contentValues = new ContentValues(); + + contentValues.put(READ, ReadStatus.FORCED_UNREAD.serialize()); + + db.update(TABLE_NAME, contentValues, query.getWhere(), query.getWhereArgs()); + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipientIds); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + + StorageSyncHelper.scheduleSyncForDataChange(); + notifyConversationListListeners(); + } + } + + + public void incrementUnread(long threadId, int amount) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = " + ReadStatus.UNREAD.serialize() + ", " + + UNREAD_COUNT + " = " + UNREAD_COUNT + " + ? WHERE " + ID + " = ?", + new String[] {String.valueOf(amount), + String.valueOf(threadId)}); + } + + public void setDistributionType(long threadId, int distributionType) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(TYPE, distributionType); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); + notifyConversationListListeners(); + } + + public int getDistributionType(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); + + try { + if (cursor != null && cursor.moveToNext()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); + } + + return DistributionTypes.DEFAULT; + } finally { + if (cursor != null) cursor.close(); + } + + } + + public Cursor getFilteredConversationList(@Nullable List filter) { + if (filter == null || filter.size() == 0) + return null; + + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List> splitRecipientIds = Util.partition(filter, 900); + List cursors = new LinkedList<>(); + + for (List recipientIds : splitRecipientIds) { + String selection = TABLE_NAME + "." + RECIPIENT_ID + " = ?"; + String[] selectionArgs = new String[recipientIds.size()]; + + for (int i=0;i 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); + setNotifyConversationListListeners(cursor); + return cursor; + } + + public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean hideV1Groups) { + return getRecentConversationList(limit, includeInactiveGroups, false, hideV1Groups, false); + } + + public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean groupsOnly, boolean hideV1Groups, boolean hideSms) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = !includeInactiveGroups ? MESSAGE_COUNT + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)" + : MESSAGE_COUNT + " != 0"; + + if (groupsOnly) { + query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL"; + } + + if (hideV1Groups) { + query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId(); + } + + if (hideSms) { + query += " AND (" + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL OR " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + ")"; + query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.FORCE_SMS_SELECTION + " = 0"; + } + + query += " AND " + ARCHIVED + " = 0"; + + return db.rawQuery(createQuery(query, 0, limit, true), null); + } + + public Cursor getRecentPushConversationList(int limit, boolean includeInactiveGroups) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String activeGroupQuery = !includeInactiveGroups ? " AND " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1" : ""; + String where = MESSAGE_COUNT + " != 0 AND " + + "(" + + RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + " OR " + + "(" + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID + " NOT NULL AND " + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" + + activeGroupQuery + + ")" + + ")"; + String query = createQuery(where, 0, limit, true); + + return db.rawQuery(query, null); + } + + public @NonNull List getRecentV1Groups(int limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = MESSAGE_COUNT + " != 0 AND " + + "(" + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1 AND " + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY + " IS NULL AND " + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" + + ")"; + String query = createQuery(where, 0, limit, true); + + List threadRecords = new ArrayList<>(); + + try (Reader reader = readerFor(db.rawQuery(query, null))) { + ThreadRecord record; + + while ((record = reader.getNext()) != null) { + threadRecords.add(record); + } + } + return threadRecords; + } + + public Cursor getArchivedConversationList() { + return getConversationList("1"); + } + + public boolean isArchived(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = RECIPIENT_ID + " = ?"; + String[] args = new String[]{ recipientId.serialize() }; + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ARCHIVED }, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(ARCHIVED)) == 1; + } + } + + return false; + } + + public void setArchived(@NonNull RecipientId recipientId, boolean status) { + setArchived(Collections.singletonMap(recipientId, status)); + } + + public void setArchived(@NonNull Map status) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + db.beginTransaction(); + try { + String query = RECIPIENT_ID + " = ?"; + + for (Map.Entry entry : status.entrySet()) { + ContentValues values = new ContentValues(2); + + if (entry.getValue()) { + values.put(PINNED, "0"); + } + + values.put(ARCHIVED, entry.getValue() ? "1" : "0"); + db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + notifyConversationListListeners(); + } + } + + public void setArchived(Set threadIds, boolean archive) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + db.beginTransaction(); + try { + for (long threadId : threadIds) { + ContentValues values = new ContentValues(2); + + if (archive) { + values.put(PINNED, "0"); + } + + values.put(ARCHIVED, archive ? "1" : "0"); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(threadId)); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + notifyConversationListListeners(); + } + } + + public @NonNull Set getArchivedRecipients() { + Set archived = new HashSet<>(); + + try (Cursor cursor = getArchivedConversationList()) { + while (cursor != null && cursor.moveToNext()) { + archived.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)))); + } + } + + return archived; + } + + public @NonNull Map getInboxPositions() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = createQuery(MESSAGE_COUNT + " != ?", 0); + + Map positions = new HashMap<>(); + + try (Cursor cursor = db.rawQuery(query, new String[] { "0" })) { + int i = 0; + while (cursor != null && cursor.moveToNext()) { + RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID))); + positions.put(recipientId, i); + i++; + } + } + + return positions; + } + + public Cursor getArchivedConversationList(long offset, long limit) { + return getConversationList("1", offset, limit); + } + + private Cursor getConversationList(String archived) { + return getConversationList(archived, 0, 0); + } + + public Cursor getUnarchivedConversationList(boolean pinned, long offset, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String pinnedWhere = PINNED + (pinned ? " != 0" : " = 0"); + String where = ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + pinnedWhere; + + final String query; + + if (pinned) { + query = createQuery(where, PINNED + " ASC", offset, limit); + } else { + query = createQuery(where, offset, limit, false); + } + + Cursor cursor = db.rawQuery(query, new String[]{}); + + setNotifyConversationListListeners(cursor); + + return cursor; + } + + private Cursor getConversationList(@NonNull String archived, long offset, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", offset, limit, false); + Cursor cursor = db.rawQuery(query, new String[]{archived}); + + setNotifyConversationListListeners(cursor); + + return cursor; + } + + public int getArchivedConversationListCount() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[] { "COUNT(*)" }; + String query = ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0"; + String[] args = new String[] {"1"}; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + public int getPinnedConversationListCount() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[] { "COUNT(*)" }; + String query = ARCHIVED + " = 0 AND " + PINNED + " != 0 AND " + MESSAGE_COUNT + " != 0"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + public int getUnarchivedConversationListCount() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[] { "COUNT(*)" }; + String query = ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + /** + * @return Pinned recipients, in order from top to bottom. + */ + public @NonNull List getPinnedRecipientIds() { + String[] projection = new String[]{ID, RECIPIENT_ID}; + List pinned = new LinkedList<>(); + + try (Cursor cursor = getPinned(projection)) { + while (cursor.moveToNext()) { + pinned.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); + } + } + + return pinned; + } + + /** + * @return Pinned thread ids, in order from top to bottom. + */ + public @NonNull List getPinnedThreadIds() { + String[] projection = new String[]{ID}; + List pinned = new LinkedList<>(); + + try (Cursor cursor = getPinned(projection)) { + while (cursor.moveToNext()) { + pinned.add(CursorUtil.requireLong(cursor, ID)); + } + } + + return pinned; + } + + /** + * @return Pinned recipients, in order from top to bottom. + */ + private @NonNull Cursor getPinned(String[] projection) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = PINNED + " > ?"; + String[] args = SqlUtil.buildArgs(0); + + return db.query(TABLE_NAME, projection, query, args, null, null, PINNED + " ASC"); + } + + public void restorePins(@NonNull Collection threadIds) { + Log.d(TAG, "Restoring pinned threads " + StringUtil.join(threadIds, ",")); + pinConversations(threadIds, true); + } + + public void pinConversations(@NonNull Collection threadIds) { + Log.d(TAG, "Pinning threads " + StringUtil.join(threadIds, ",")); + pinConversations(threadIds, false); + } + + private void pinConversations(@NonNull Collection threadIds, boolean clearFirst) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + threadIds = new LinkedHashSet<>(threadIds); + + try { + db.beginTransaction(); + + if (clearFirst) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(PINNED, 0); + String query = PINNED + " > ?"; + String[] args = SqlUtil.buildArgs(0); + db.update(TABLE_NAME, contentValues, query, args); + } + + int pinnedCount = getPinnedConversationListCount(); + + if (pinnedCount > 0 && clearFirst) { + throw new AssertionError(); + } + + for (long threadId : threadIds) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(PINNED, ++pinnedCount); + + db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); + + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + notifyConversationListListeners(); + } + + notifyConversationListListeners(); + + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + + public void unpinConversations(@NonNull Set threadIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + String placeholders = StringUtil.join(Stream.of(threadIds).map(unused -> "?").toList(), ","); + String selection = ID + " IN (" + placeholders + ")"; + + contentValues.put(PINNED, 0); + + db.update(TABLE_NAME, contentValues, selection, SqlUtil.buildArgs(Stream.of(threadIds).toArray())); + notifyConversationListListeners(); + + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + + public void archiveConversation(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + contentValues.put(PINNED, 0); + contentValues.put(ARCHIVED, 1); + + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); + notifyConversationListListeners(); + + Recipient recipient = getRecipientForThreadId(threadId); + if (recipient != null) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipient.getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void unarchiveConversation(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + contentValues.put(ARCHIVED, 0); + + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); + notifyConversationListListeners(); + + Recipient recipient = getRecipientForThreadId(threadId); + if (recipient != null) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipient.getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void setLastSeen(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + contentValues.put(LAST_SEEN, System.currentTimeMillis()); + + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); + notifyConversationListListeners(); + } + + public void setLastScrolled(long threadId, long lastScrolledTimestamp) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + + contentValues.put(LAST_SCROLLED, lastScrolledTimestamp); + + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); + } + + public ConversationMetadata getConversationMetadata(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT, LAST_SCROLLED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return new ConversationMetadata(cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN)), + cursor.getLong(cursor.getColumnIndexOrThrow(HAS_SENT)) == 1, + cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SCROLLED))); + } + + return new ConversationMetadata(-1L, false, -1); + } + } + + public int getConversationMessageCount(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[]{MESSAGE_COUNT}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return CursorUtil.requireInt(cursor, MESSAGE_COUNT); + } + } + return 0; + } + + public void deleteConversation(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + DatabaseFactory.getSmsDatabase(context).deleteThread(threadId); + DatabaseFactory.getMmsDatabase(context).deleteThread(threadId); + DatabaseFactory.getDraftDatabase(context).clearDrafts(threadId); + + db.delete(TABLE_NAME, ID_WHERE, new String[]{threadId + ""}); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListListeners(); + notifyConversationListeners(threadId); + ConversationUtil.clearShortcuts(context, Collections.singleton(threadId)); + } + + public void deleteConversations(Set selectedConversations) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + DatabaseFactory.getSmsDatabase(context).deleteThreads(selectedConversations); + DatabaseFactory.getMmsDatabase(context).deleteThreads(selectedConversations); + DatabaseFactory.getDraftDatabase(context).clearDrafts(selectedConversations); + + StringBuilder where = new StringBuilder(); + + for (long threadId : selectedConversations) { + if (where.length() > 0) { + where.append(" OR "); + } + where.append(ID + " = '").append(threadId).append("'"); + } + + db.delete(TABLE_NAME, where.toString(), null); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListListeners(); + notifyConversationListeners(selectedConversations); + ConversationUtil.clearShortcuts(context, selectedConversations); + } + + public void deleteAllConversations() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + DatabaseFactory.getSmsDatabase(context).deleteAllThreads(); + DatabaseFactory.getMmsDatabase(context).deleteAllThreads(); + DatabaseFactory.getDraftDatabase(context).clearAllDrafts(); + + db.delete(TABLE_NAME, null, null); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListListeners(); + ConversationUtil.clearAllShortcuts(context); + } + + public long getThreadIdIfExistsFor(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = RECIPIENT_ID + " = ?"; + String[] recipientsArg = new String[] {recipientId.serialize()}; + + try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, where, recipientsArg, null, null, null, "1")) { + if (cursor != null && cursor.moveToFirst()) { + return CursorUtil.requireLong(cursor, ID); + } else { + return -1; + } + } + } + + public Map getThreadIdsIfExistsFor(@NonNull RecipientId ... recipientIds) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SqlUtil.Query query = SqlUtil.buildCollectionQuery(RECIPIENT_ID, Arrays.asList(recipientIds)); + + Map results = new HashMap<>(); + try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID, RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null, "1")) { + while (cursor != null && cursor.moveToNext()) { + results.put(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, ID)); + } + } + return results; + } + + public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId) { + return getOrCreateValidThreadId(recipient, candidateId, DistributionTypes.DEFAULT); + } + + public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId, int distributionType) { + if (candidateId != -1) { + Optional remapped = RemappedRecords.getInstance().getThread(context, candidateId); + return remapped.isPresent() ? remapped.get() : candidateId; + } else { + return getThreadIdFor(recipient, distributionType); + } + } + + public long getThreadIdFor(@NonNull Recipient recipient) { + return getThreadIdFor(recipient, DistributionTypes.DEFAULT); + } + + public long getThreadIdFor(@NonNull Recipient recipient, int distributionType) { + Long threadId = getThreadIdFor(recipient.getId()); + if (threadId != null) { + return threadId; + } else { + return createThreadForRecipient(recipient.getId(), recipient.isGroup(), distributionType); + } + } + + public @Nullable Long getThreadIdFor(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = RECIPIENT_ID + " = ?"; + String[] recipientsArg = new String[]{recipientId.serialize()}; + + try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, where, recipientsArg, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + } else { + return null; + } + } + } + + public @Nullable RecipientId getRecipientIdForThreadId(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, null, ID + " = ?", new String[]{ threadId + "" }, null, null, null)) { + + if (cursor != null && cursor.moveToFirst()) { + return RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); + } + } + + return null; + } + + public @Nullable Recipient getRecipientForThreadId(long threadId) { + RecipientId id = getRecipientIdForThreadId(threadId); + if (id == null) return null; + return Recipient.resolved(id); + } + + public @NonNull List getRecipientIdsForThreadIds(Collection threadIds) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SqlUtil.Query query = SqlUtil.buildCollectionQuery(ID, threadIds); + List ids = new ArrayList<>(threadIds.size()); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + ids.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); + } + } + + return ids; + } + + public boolean hasThread(@NonNull RecipientId recipientId) { + return getThreadIdIfExistsFor(recipientId) > -1; + } + + public void setHasSent(long threadId, boolean hasSent) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(HAS_SENT, hasSent ? 1 : 0); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, + new String[] {String.valueOf(threadId)}); + + notifyConversationListeners(threadId); + } + + void updateReadState(long threadId) { + ThreadRecord previous = getThreadRecord(threadId); + int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId); + + ContentValues contentValues = new ContentValues(); + contentValues.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize()); + contentValues.put(UNREAD_COUNT, unreadCount); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); + + notifyConversationListListeners(); + + if (previous != null && previous.isForcedUnread()) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(previous.getRecipient().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } + + public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalContactRecord record) { + applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); + } + + public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV1Record record) { + applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); + } + + public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV2Record record) { + applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); + } + + public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalAccountRecord record) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + applyStorageSyncUpdate(recipientId, record.isNoteToSelfArchived(), record.isNoteToSelfForcedUnread()); + + ContentValues clearPinnedValues = new ContentValues(); + clearPinnedValues.put(PINNED, 0); + db.update(TABLE_NAME, clearPinnedValues, null, null); + + int pinnedPosition = 1; + for (SignalAccountRecord.PinnedConversation pinned : record.getPinnedConversations()) { + ContentValues pinnedValues = new ContentValues(); + pinnedValues.put(PINNED, pinnedPosition); + + Recipient pinnedRecipient; + + if (pinned.getContact().isPresent()) { + pinnedRecipient = Recipient.externalPush(context, pinned.getContact().get()); + } else if (pinned.getGroupV1Id().isPresent()) { + try { + pinnedRecipient = Recipient.externalGroupExact(context, GroupId.v1(pinned.getGroupV1Id().get())); + } catch (BadGroupIdException e) { + Log.w(TAG, "Failed to parse pinned groupV1 ID!", e); + pinnedRecipient = null; + } + } else if (pinned.getGroupV2MasterKey().isPresent()) { + try { + pinnedRecipient = Recipient.externalGroupExact(context, GroupId.v2(new GroupMasterKey(pinned.getGroupV2MasterKey().get()))); + } catch (InvalidInputException e) { + Log.w(TAG, "Failed to parse pinned groupV2 master key!", e); + pinnedRecipient = null; + } + } else { + Log.w(TAG, "Empty pinned conversation on the AccountRecord?"); + pinnedRecipient = null; + } + + if (pinnedRecipient != null) { + db.update(TABLE_NAME, pinnedValues, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(pinnedRecipient.getId())); + } + + pinnedPosition++; + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListListeners(); + } + + private void applyStorageSyncUpdate(@NonNull RecipientId recipientId, boolean archived, boolean forcedUnread) { + ContentValues values = new ContentValues(); + values.put(ARCHIVED, archived); + + if (forcedUnread) { + values.put(READ, ReadStatus.FORCED_UNREAD.serialize()); + } else { + Long threadId = getThreadIdFor(recipientId); + if (threadId != null) { + int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId); + + values.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize()); + values.put(UNREAD_COUNT, unreadCount); + } + } + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(recipientId)); + } + + public boolean update(long threadId, boolean unarchive) { + return update(threadId, unarchive, true); + } + + public boolean update(long threadId, boolean unarchive, boolean allowDeletion) { + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + long count = mmsSmsDatabase.getConversationCountForThreadSummary(threadId); + + if (count == 0) { + if (allowDeletion) { + deleteConversation(threadId); + } + return true; + } + + MmsSmsDatabase.Reader reader = null; + + try { + reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId)); + MessageRecord record; + + if (reader != null && (record = reader.getNext()) != null) { + updateThread(threadId, count, ThreadBodyUtil.getFormattedBodyFor(context, record), getAttachmentUriFor(record), + getContentTypeFor(record), getExtrasFor(record), + record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), + record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); + notifyConversationListListeners(); + return false; + } else { + deleteConversation(threadId); + return true; + } + } finally { + if (reader != null) + reader.close(); + } + } + + public @NonNull ThreadRecord getThreadRecordFor(@NonNull Recipient recipient) { + return Objects.requireNonNull(getThreadRecord(getThreadIdFor(recipient))); + } + + public @NonNull Set getAllThreadRecipients() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Set ids = new HashSet<>(); + + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID }, null, null, null, null, null)) { + while (cursor.moveToNext()) { + ids.add(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID))); + } + } + + return ids; + } + + + @NonNull MergeResult merge(@NonNull RecipientId primaryRecipientId, @NonNull RecipientId secondaryRecipientId) { + if (!databaseHelper.getWritableDatabase().inTransaction()) { + throw new IllegalStateException("Must be in a transaction!"); + } + + Log.w(TAG, "Merging threads. Primary: " + primaryRecipientId + ", Secondary: " + secondaryRecipientId); + + ThreadRecord primary = getThreadRecord(getThreadIdFor(primaryRecipientId)); + ThreadRecord secondary = getThreadRecord(getThreadIdFor(secondaryRecipientId)); + + if (primary != null && secondary == null) { + Log.w(TAG, "[merge] Only had a thread for primary. Returning that."); + return new MergeResult(primary.getThreadId(), -1, false); + } else if (primary == null && secondary != null) { + Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary."); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, primaryRecipientId.serialize()); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); + return new MergeResult(secondary.getThreadId(), -1, false); + } else if (primary == null && secondary == null) { + Log.w(TAG, "[merge] No thread for either."); + return new MergeResult(-1, -1, false); + } else { + Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together."); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); + + if (primary.getExpiresIn() != secondary.getExpiresIn()) { + ContentValues values = new ContentValues(); + if (primary.getExpiresIn() == 0) { + values.put(EXPIRES_IN, secondary.getExpiresIn()); + } else if (secondary.getExpiresIn() == 0) { + values.put(EXPIRES_IN, primary.getExpiresIn()); + } else { + values.put(EXPIRES_IN, Math.min(primary.getExpiresIn(), secondary.getExpiresIn())); + } + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(primary.getThreadId())); + } + + ContentValues draftValues = new ContentValues(); + draftValues.put(DraftDatabase.THREAD_ID, primary.getThreadId()); + db.update(DraftDatabase.TABLE_NAME, draftValues, DraftDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + + ContentValues searchValues = new ContentValues(); + searchValues.put(SearchDatabase.THREAD_ID, primary.getThreadId()); + db.update(SearchDatabase.SMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + db.update(SearchDatabase.MMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + + RemappedRecords.getInstance().addThread(context, secondary.getThreadId(), primary.getThreadId()); + + return new MergeResult(primary.getThreadId(), secondary.getThreadId(), true); + } + } + + private @Nullable ThreadRecord getThreadRecord(@Nullable Long threadId) { + if (threadId == null) { + return null; + } + + String query = createQuery(TABLE_NAME + "." + ID + " = ?", 1); + + try (Cursor cursor = databaseHelper.getReadableDatabase().rawQuery(query, SqlUtil.buildArgs(threadId))) { + if (cursor != null && cursor.moveToFirst()) { + return readerFor(cursor).getCurrent(); + } + } + + return null; + } + + private @Nullable Uri getAttachmentUriFor(MessageRecord record) { + if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null; + + SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); + Slide thumbnail = Optional.fromNullable(slideDeck.getThumbnailSlide()).or(Optional.fromNullable(slideDeck.getStickerSlide())).orNull(); + + if (thumbnail != null && !((MmsMessageRecord) record).isViewOnce()) { + return thumbnail.getUri(); + } + + return null; + } + + private @Nullable String getContentTypeFor(MessageRecord record) { + if (record.isMms()) { + SlideDeck slideDeck = ((MmsMessageRecord) record).getSlideDeck(); + + if (slideDeck.getSlides().size() > 0) { + return slideDeck.getSlides().get(0).getContentType(); + } + } + + return null; + } + + private @Nullable Extra getExtrasFor(@NonNull MessageRecord record) { + boolean messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, record.getThreadId()); + RecipientId threadRecipientId = getRecipientIdForThreadId(record.getThreadId()); + RecipientId individualRecipient = record.getIndividualRecipient().getId(); + + if (!messageRequestAccepted && threadRecipientId != null) { + Recipient resolved = Recipient.resolved(threadRecipientId); + if (resolved.isPushGroup()) { + if (resolved.isPushV2Group()) { + MessageRecord.InviteAddState inviteAddState = record.getGv2AddInviteState(); + if (inviteAddState != null) { + RecipientId from = RecipientId.from(inviteAddState.getAddedOrInvitedBy(), null); + if (inviteAddState.isInvited()) { + Log.i(TAG, "GV2 invite message request from " + from); + return Extra.forGroupV2invite(from, individualRecipient); + } else { + Log.i(TAG, "GV2 message request from " + from); + return Extra.forGroupMessageRequest(from, individualRecipient); + } + } + Log.w(TAG, "Falling back to unknown message request state for GV2 message"); + return Extra.forMessageRequest(individualRecipient); + } else { + RecipientId recipientId = DatabaseFactory.getMmsSmsDatabase(context).getGroupAddedBy(record.getThreadId()); + + if (recipientId != null) { + return Extra.forGroupMessageRequest(recipientId, individualRecipient); + } + } + } + + return Extra.forMessageRequest(individualRecipient); + } + + if (record.isRemoteDelete()) { + return Extra.forRemoteDelete(individualRecipient); + } else if (record.isViewOnce()) { + return Extra.forViewOnce(individualRecipient); + } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { + StickerSlide slide = Objects.requireNonNull(((MmsMessageRecord) record).getSlideDeck().getStickerSlide()); + return Extra.forSticker(slide.getEmoji(), individualRecipient); + } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) { + return Extra.forAlbum(individualRecipient); + } + + if (threadRecipientId != null) { + Recipient resolved = Recipient.resolved(threadRecipientId); + if (resolved.isGroup()) { + return Extra.forDefault(individualRecipient); + } + } + + return null; + } + + private @NonNull String createQuery(@NonNull String where, long limit) { + return createQuery(where, 0, limit, false); + } + + private @NonNull String createQuery(@NonNull String where, long offset, long limit, boolean preferPinned) { + String orderBy = (preferPinned ? TABLE_NAME + "." + PINNED + " DESC, " : "") + TABLE_NAME + "." + DATE + " DESC"; + + return createQuery(where, orderBy, offset, limit); + } + + private @NonNull String createQuery(@NonNull String where, @NonNull String orderBy, long offset, long limit) { + String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); + + String query = + "SELECT " + projection + " FROM " + TABLE_NAME + + " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ID + + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.RECIPIENT_ID + + " WHERE " + where + + " ORDER BY " + orderBy; + + if (limit > 0) { + query += " LIMIT " + limit; + } + + if (offset > 0) { + query += " OFFSET " + offset; + } + + return query; + } + + private boolean isSilentType(long type) { + return MmsSmsColumns.Types.isProfileChange(type) || + MmsSmsColumns.Types.isGroupV1MigrationEvent(type); + } + + public Reader readerFor(Cursor cursor) { + return new Reader(cursor); + } + + public static class DistributionTypes { + public static final int DEFAULT = 2; + public static final int BROADCAST = 1; + public static final int CONVERSATION = 2; + public static final int ARCHIVE = 3; + public static final int INBOX_ZERO = 4; + } + + public class Reader extends StaticReader { + public Reader(Cursor cursor) { + super(cursor, context); + } + } + + public static class StaticReader implements Closeable { + + private final Cursor cursor; + private final Context context; + + public StaticReader(Cursor cursor, Context context) { + this.cursor = cursor; + this.context = context; + } + + public ThreadRecord getNext() { + if (cursor == null || !cursor.moveToNext()) + return null; + + return getCurrent(); + } + + public ThreadRecord getCurrent() { + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)); + RecipientSettings recipientSettings = RecipientDatabase.getRecipientSettings(context, cursor, ThreadDatabase.RECIPIENT_ID); + + Recipient recipient; + + if (recipientSettings.getGroupId() != null) { + GroupDatabase.GroupRecord group = new GroupDatabase.Reader(cursor).getCurrent(); + + if (group != null) { + RecipientDetails details = new RecipientDetails(group.getTitle(), + group.hasAvatar() ? Optional.of(group.getAvatarId()) : Optional.absent(), + false, + false, + recipientSettings, + null); + recipient = new Recipient(recipientId, details, false); + } else { + recipient = Recipient.live(recipientId).get(); + } + } else { + RecipientDetails details = RecipientDetails.forIndividual(context, recipientSettings); + recipient = new Recipient(recipientId, details, true); + } + + int readReceiptCount = TextSecurePreferences.isReadReceiptsEnabled(context) ? cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)) + : 0; + + String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS)); + Extra extra = null; + + if (extraString != null) { + try { + extra = JsonUtils.fromJson(extraString, Extra.class); + } catch (IOException e) { + Log.w(TAG, "Failed to decode extras!"); + } + } + + return new ThreadRecord.Builder(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID))) + .setRecipient(recipient) + .setType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE))) + .setDistributionType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE))) + .setBody(Util.emptyIfNull(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET)))) + .setDate(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE))) + .setArchived(CursorUtil.requireInt(cursor, ThreadDatabase.ARCHIVED) != 0) + .setDeliveryStatus(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS))) + .setDeliveryReceiptCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT))) + .setReadReceiptCount(readReceiptCount) + .setExpiresIn(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN))) + .setLastSeen(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN))) + .setSnippetUri(getSnippetUri(cursor)) + .setContentType(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE))) + .setCount(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT))) + .setUnreadCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT))) + .setForcedUnread(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ)) == ReadStatus.FORCED_UNREAD.serialize()) + .setPinned(CursorUtil.requireBoolean(cursor, ThreadDatabase.PINNED)) + .setExtra(extra) + .build(); + } + + private @Nullable Uri getSnippetUri(Cursor cursor) { + if (cursor.isNull(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI))) { + return null; + } + + try { + return Uri.parse(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI))); + } catch (IllegalArgumentException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + public void close() { + if (cursor != null) { + cursor.close(); + } + } + } + + public static final class Extra { + + @JsonProperty private final boolean isRevealable; + @JsonProperty private final boolean isSticker; + @JsonProperty private final String stickerEmoji; + @JsonProperty private final boolean isAlbum; + @JsonProperty private final boolean isRemoteDelete; + @JsonProperty private final boolean isMessageRequestAccepted; + @JsonProperty private final boolean isGv2Invite; + @JsonProperty private final String groupAddedBy; + @JsonProperty private final String individualRecipientId; + + public Extra(@JsonProperty("isRevealable") boolean isRevealable, + @JsonProperty("isSticker") boolean isSticker, + @JsonProperty("stickerEmoji") String stickerEmoji, + @JsonProperty("isAlbum") boolean isAlbum, + @JsonProperty("isRemoteDelete") boolean isRemoteDelete, + @JsonProperty("isMessageRequestAccepted") boolean isMessageRequestAccepted, + @JsonProperty("isGv2Invite") boolean isGv2Invite, + @JsonProperty("groupAddedBy") String groupAddedBy, + @JsonProperty("individualRecipientId") String individualRecipientId) + { + this.isRevealable = isRevealable; + this.isSticker = isSticker; + this.stickerEmoji = stickerEmoji; + this.isAlbum = isAlbum; + this.isRemoteDelete = isRemoteDelete; + this.isMessageRequestAccepted = isMessageRequestAccepted; + this.isGv2Invite = isGv2Invite; + this.groupAddedBy = groupAddedBy; + this.individualRecipientId = individualRecipientId; + } + + public static @NonNull Extra forViewOnce(@NonNull RecipientId individualRecipient) { + return new Extra(true, false, null, false, false, true, false, null, individualRecipient.serialize()); + } + + public static @NonNull Extra forSticker(@Nullable String emoji, @NonNull RecipientId individualRecipient) { + return new Extra(false, true, emoji, false, false, true, false, null, individualRecipient.serialize()); + } + + public static @NonNull Extra forAlbum(@NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, true, false, true, false, null, individualRecipient.serialize()); + } + + public static @NonNull Extra forRemoteDelete(@NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, false, true, true, false, null, individualRecipient.serialize()); + } + + public static @NonNull Extra forMessageRequest(@NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, false, false, false, false, null, individualRecipient.serialize()); + } + + public static @NonNull Extra forGroupMessageRequest(@NonNull RecipientId recipientId, @NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, false, false, false, false, recipientId.serialize(), individualRecipient.serialize()); + } + + public static @NonNull Extra forGroupV2invite(@NonNull RecipientId recipientId, @NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, false, false, false, true, recipientId.serialize(), individualRecipient.serialize()); + } + + public static @NonNull Extra forDefault(@NonNull RecipientId individualRecipient) { + return new Extra(false, false, null, false, false, true, false, null, individualRecipient.serialize()); + } + + public boolean isViewOnce() { + return isRevealable; + } + + public boolean isSticker() { + return isSticker; + } + + public @Nullable String getStickerEmoji() { + return stickerEmoji; + } + + public boolean isAlbum() { + return isAlbum; + } + + public boolean isRemoteDelete() { + return isRemoteDelete; + } + + public boolean isMessageRequestAccepted() { + return isMessageRequestAccepted; + } + + public boolean isGv2Invite() { + return isGv2Invite; + } + + public @Nullable String getGroupAddedBy() { + return groupAddedBy; + } + + public @Nullable String getIndividualRecipientId() { + return individualRecipientId; + } + } + + enum ReadStatus { + READ(1), UNREAD(0), FORCED_UNREAD(2); + + private final int value; + + ReadStatus(int value) { + this.value = value; + } + + public static ReadStatus deserialize(int value) { + for (ReadStatus status : ReadStatus.values()) { + if (status.value == value) { + return status; + } + } + throw new IllegalArgumentException("No matching status for value " + value); + } + + public int serialize() { + return value; + } + } + + public static class ConversationMetadata { + private final long lastSeen; + private final boolean hasSent; + private final long lastScrolled; + + public ConversationMetadata(long lastSeen, boolean hasSent, long lastScrolled) { + this.lastSeen = lastSeen; + this.hasSent = hasSent; + this.lastScrolled = lastScrolled; + } + + public long getLastSeen() { + return lastSeen; + } + + public boolean hasSent() { + return hasSent; + } + + public long getLastScrolled() { + return lastScrolled; + } + } + + static final class MergeResult { + final long threadId; + final long previousThreadId; + final boolean neededMerge; + + private MergeResult(long threadId, long previousThreadId, boolean neededMerge) { + this.threadId = threadId; + this.previousThreadId = previousThreadId; + this.neededMerge = neededMerge; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java b/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java new file mode 100644 index 00000000..a206825a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java @@ -0,0 +1,245 @@ +package org.thoughtcrime.securesms.database; + +import android.text.TextUtils; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.BufferedWriter; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class XmlBackup { + + private static final String PROTOCOL = "protocol"; + private static final String ADDRESS = "address"; + private static final String CONTACT_NAME = "contact_name"; + private static final String DATE = "date"; + private static final String READABLE_DATE = "readable_date"; + private static final String TYPE = "type"; + private static final String SUBJECT = "subject"; + private static final String BODY = "body"; + private static final String SERVICE_CENTER = "service_center"; + private static final String READ = "read"; + private static final String STATUS = "status"; + private static final String TOA = "toa"; + private static final String SC_TOA = "sc_toa"; + private static final String LOCKED = "locked"; + + private static final SimpleDateFormat dateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z"); + + private final XmlPullParser parser; + + public XmlBackup(String path) throws XmlPullParserException, FileNotFoundException { + this.parser = XmlPullParserFactory.newInstance().newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(new FileInputStream(path), null); + } + + public XmlBackupItem getNext() throws IOException, XmlPullParserException { + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + String name = parser.getName(); + + if (!name.equalsIgnoreCase("sms")) { + continue; + } + + int attributeCount = parser.getAttributeCount(); + + if (attributeCount <= 0) { + continue; + } + + XmlBackupItem item = new XmlBackupItem(); + + for (int i=0;i"; + private static final String CREATED_BY = ""; + private static final String OPEN_TAG_SMSES = ""; + private static final String CLOSE_TAG_SMSES = ""; + private static final String OPEN_TAG_SMS = " void appendAttribute(StringBuilder stringBuilder, String name, T value) { + stringBuilder.append(name).append(OPEN_ATTRIBUTE).append(value).append(CLOSE_ATTRIBUTE); + } + + public void close() throws IOException { + bufferedWriter.newLine(); + bufferedWriter.write(CLOSE_TAG_SMSES); + bufferedWriter.close(); + } + + private String escapeXML(String s) { + if (TextUtils.isEmpty(s)) return s; + + Matcher matcher = PATTERN.matcher( s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'")); + StringBuffer st = new StringBuffer(); + + while (matcher.find()) { + String escaped=""; + for (char ch: matcher.group(0).toCharArray()) { + escaped += ("&#" + ((int) ch) + ";"); + } + matcher.appendReplacement(st, escaped); + } + matcher.appendTail(st); + return st.toString(); + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/Document.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/Document.java new file mode 100644 index 00000000..2b226f66 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/Document.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.database.documents; + +import java.util.List; + +public interface Document { + + public int size(); + public List getList(); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java new file mode 100644 index 00000000..f9622a13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.database.documents; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; + +import java.io.IOException; +import java.util.Objects; + +public class IdentityKeyMismatch { + + private static final String TAG = IdentityKeyMismatch.class.getSimpleName(); + + /** DEPRECATED */ + @JsonProperty(value = "a") + private String address; + + @JsonProperty(value = "r") + private String recipientId; + + @JsonProperty(value = "k") + @JsonSerialize(using = IdentityKeySerializer.class) + @JsonDeserialize(using = IdentityKeyDeserializer.class) + private IdentityKey identityKey; + + public IdentityKeyMismatch() {} + + public IdentityKeyMismatch(RecipientId recipientId, IdentityKey identityKey) { + this.recipientId = recipientId.serialize(); + this.address = ""; + this.identityKey = identityKey; + } + + @JsonIgnore + public RecipientId getRecipientId(@NonNull Context context) { + if (!TextUtils.isEmpty(recipientId)) { + return RecipientId.from(recipientId); + } else { + return Recipient.external(context, address).getId(); + } + } + + public IdentityKey getIdentityKey() { + return identityKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IdentityKeyMismatch that = (IdentityKeyMismatch) o; + return Objects.equals(address, that.address) && + Objects.equals(recipientId, that.recipientId) && + Objects.equals(identityKey, that.identityKey); + } + + @Override + public int hashCode() { + return Objects.hash(address, recipientId, identityKey); + } + + private static class IdentityKeySerializer extends JsonSerializer { + @Override + public void serialize(IdentityKey value, JsonGenerator jsonGenerator, SerializerProvider serializers) + throws IOException + { + jsonGenerator.writeString(Base64.encodeBytes(value.serialize())); + } + } + + private static class IdentityKeyDeserializer extends JsonDeserializer { + @Override + public IdentityKey deserialize(JsonParser jsonParser, DeserializationContext ctxt) + throws IOException + { + try { + return new IdentityKey(Base64.decode(jsonParser.getValueAsString()), 0); + } catch (InvalidKeyException e) { + Log.w(TAG, e); + throw new IOException(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatchList.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatchList.java new file mode 100644 index 00000000..eaceb4d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatchList.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.database.documents; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.LinkedList; +import java.util.List; + +public class IdentityKeyMismatchList implements Document { + + @JsonProperty(value = "m") + private List mismatches; + + public IdentityKeyMismatchList() { + this.mismatches = new LinkedList<>(); + } + + public IdentityKeyMismatchList(List mismatches) { + this.mismatches = mismatches; + } + + @Override + public int size() { + if (mismatches == null) return 0; + else return mismatches.size(); + } + + @Override + @JsonIgnore + public List getList() { + return mismatches; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java new file mode 100644 index 00000000..119c6c14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.database.documents; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public class NetworkFailure { + + /** DEPRECATED */ + @JsonProperty(value = "a") + private String address; + + @JsonProperty(value = "r") + private String recipientId; + + public NetworkFailure(@NonNull RecipientId recipientId) { + this.recipientId = recipientId.serialize(); + this.address = ""; + } + + public NetworkFailure() {} + + @JsonIgnore + public RecipientId getRecipientId(@NonNull Context context) { + if (!TextUtils.isEmpty(recipientId)) { + return RecipientId.from(recipientId); + } else { + return Recipient.external(context, address).getId(); + } + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NetworkFailure that = (NetworkFailure) o; + return Objects.equals(address, that.address) && + Objects.equals(recipientId, that.recipientId); + } + + @Override + public int hashCode() { + return Objects.hash(address, recipientId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailureList.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailureList.java new file mode 100644 index 00000000..3347c428 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailureList.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.database.documents; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.LinkedList; +import java.util.List; + +public class NetworkFailureList implements Document { + + @JsonProperty(value = "l") + private List failures; + + public NetworkFailureList() { + this.failures = new LinkedList<>(); + } + + public NetworkFailureList(List failures) { + this.failures = failures; + } + + @Override + public int size() { + if (failures == null) return 0; + else return failures.size(); + } + + @Override + @JsonIgnore + public List getList() { + return failures; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java new file mode 100644 index 00000000..71b0edbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java @@ -0,0 +1,1529 @@ +package org.thoughtcrime.securesms.database.helpers; + + +import android.Manifest; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.i18n.phonenumbers.ShortNumberInfo; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.MasterCipher; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DraftDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.DelimiterUtil; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidMessageException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public class ClassicOpenHelper extends SQLiteOpenHelper { + + static final String NAME = "messages.db"; + + private static final int INTRODUCED_IDENTITIES_VERSION = 2; + private static final int INTRODUCED_INDEXES_VERSION = 3; + private static final int INTRODUCED_DATE_SENT_VERSION = 4; + private static final int INTRODUCED_DRAFTS_VERSION = 5; + private static final int INTRODUCED_NEW_TYPES_VERSION = 6; + private static final int INTRODUCED_MMS_BODY_VERSION = 7; + private static final int INTRODUCED_MMS_FROM_VERSION = 8; + private static final int INTRODUCED_TOFU_IDENTITY_VERSION = 9; + private static final int INTRODUCED_PUSH_DATABASE_VERSION = 10; + private static final int INTRODUCED_GROUP_DATABASE_VERSION = 11; + private static final int INTRODUCED_PUSH_FIX_VERSION = 12; + private static final int INTRODUCED_DELIVERY_RECEIPTS = 13; + private static final int INTRODUCED_PART_DATA_SIZE_VERSION = 14; + private static final int INTRODUCED_THUMBNAILS_VERSION = 15; + private static final int INTRODUCED_IDENTITY_COLUMN_VERSION = 16; + private static final int INTRODUCED_UNIQUE_PART_IDS_VERSION = 17; + private static final int INTRODUCED_RECIPIENT_PREFS_DB = 18; + private static final int INTRODUCED_ENVELOPE_CONTENT_VERSION = 19; + private static final int INTRODUCED_COLOR_PREFERENCE_VERSION = 20; + private static final int INTRODUCED_DB_OPTIMIZATIONS_VERSION = 21; + private static final int INTRODUCED_INVITE_REMINDERS_VERSION = 22; + private static final int INTRODUCED_CONVERSATION_LIST_THUMBNAILS_VERSION = 23; + private static final int INTRODUCED_ARCHIVE_VERSION = 24; + private static final int INTRODUCED_CONVERSATION_LIST_STATUS_VERSION = 25; + private static final int MIGRATED_CONVERSATION_LIST_STATUS_VERSION = 26; + private static final int INTRODUCED_SUBSCRIPTION_ID_VERSION = 27; + private static final int INTRODUCED_EXPIRE_MESSAGES_VERSION = 28; + private static final int INTRODUCED_LAST_SEEN = 29; + private static final int INTRODUCED_DIGEST = 30; + private static final int INTRODUCED_NOTIFIED = 31; + private static final int INTRODUCED_DOCUMENTS = 32; + private static final int INTRODUCED_FAST_PREFLIGHT = 33; + private static final int INTRODUCED_VOICE_NOTES = 34; + private static final int INTRODUCED_IDENTITY_TIMESTAMP = 35; + private static final int SANIFY_ATTACHMENT_DOWNLOAD = 36; + private static final int NO_MORE_CANONICAL_ADDRESS_DATABASE = 37; + private static final int NO_MORE_RECIPIENTS_PLURAL = 38; + private static final int INTERNAL_DIRECTORY = 39; + private static final int INTERNAL_SYSTEM_DISPLAY_NAME = 40; + private static final int PROFILES = 41; + private static final int PROFILE_SHARING_APPROVAL = 42; + private static final int UNSEEN_NUMBER_OFFER = 43; + private static final int READ_RECEIPTS = 44; + private static final int GROUP_RECEIPT_TRACKING = 45; + private static final int UNREAD_COUNT_VERSION = 46; + private static final int MORE_RECIPIENT_FIELDS = 47; + private static final int DATABASE_VERSION = 47; + + private static final String TAG = ClassicOpenHelper.class.getSimpleName(); + + private final Context context; + + public ClassicOpenHelper(Context context) { + super(context, NAME, null, DATABASE_VERSION); + this.context = context.getApplicationContext(); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SmsDatabase.CREATE_TABLE); + db.execSQL(MmsDatabase.CREATE_TABLE); + db.execSQL(AttachmentDatabase.CREATE_TABLE); + db.execSQL(ThreadDatabase.CREATE_TABLE); + db.execSQL(IdentityDatabase.CREATE_TABLE); + db.execSQL(DraftDatabase.CREATE_TABLE); + db.execSQL(PushDatabase.CREATE_TABLE); + db.execSQL(GroupDatabase.CREATE_TABLE); + db.execSQL(RecipientDatabase.CREATE_TABLE); + db.execSQL(GroupReceiptDatabase.CREATE_TABLE); + + executeStatements(db, SmsDatabase.CREATE_INDEXS); + executeStatements(db, MmsDatabase.CREATE_INDEXS); + executeStatements(db, AttachmentDatabase.CREATE_INDEXS); + executeStatements(db, ThreadDatabase.CREATE_INDEXS); + executeStatements(db, DraftDatabase.CREATE_INDEXS); + executeStatements(db, GroupDatabase.CREATE_INDEXS); + executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); + } + + public void onApplicationLevelUpgrade(Context context, MasterSecret masterSecret, int fromVersion, + LegacyMigrationJob.DatabaseUpgradeListener listener) + { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + + if (fromVersion < LegacyMigrationJob.NO_MORE_KEY_EXCHANGE_PREFIX_VERSION) { + String KEY_EXCHANGE = "?TextSecureKeyExchange"; + String PROCESSED_KEY_EXCHANGE = "?TextSecureKeyExchangd"; + String STALE_KEY_EXCHANGE = "?TextSecureKeyExchangs"; + int ROW_LIMIT = 500; + + MasterCipher masterCipher = new MasterCipher(masterSecret); + int smsCount = 0; + int threadCount = 0; + int skip = 0; + + Cursor cursor = db.query("sms", new String[] {"COUNT(*)"}, "type & " + 0x80000000 + " != 0", + null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + smsCount = cursor.getInt(0); + cursor.close(); + } + + cursor = db.query("thread", new String[] {"COUNT(*)"}, "snippet_type & " + 0x80000000 + " != 0", + null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + threadCount = cursor.getInt(0); + cursor.close(); + } + + Cursor smsCursor = null; + + Log.i(TAG, "Upgrade count: " + (smsCount + threadCount)); + + do { + Log.i(TAG, "Looping SMS cursor..."); + if (smsCursor != null) + smsCursor.close(); + + smsCursor = db.query("sms", new String[] {"_id", "type", "body"}, + "type & " + 0x80000000 + " != 0", + null, null, null, "_id", skip + "," + ROW_LIMIT); + + while (smsCursor != null && smsCursor.moveToNext()) { + listener.setProgress(smsCursor.getPosition() + skip, smsCount + threadCount); + + try { + String body = masterCipher.decryptBody(smsCursor.getString(smsCursor.getColumnIndexOrThrow("body"))); + long type = smsCursor.getLong(smsCursor.getColumnIndexOrThrow("type")); + long id = smsCursor.getLong(smsCursor.getColumnIndexOrThrow("_id")); + + if (body.startsWith(KEY_EXCHANGE)) { + body = body.substring(KEY_EXCHANGE.length()); + body = masterCipher.encryptBody(body); + type |= 0x8000; + + db.execSQL("UPDATE sms SET body = ?, type = ? WHERE _id = ?", + new String[] {body, type+"", id+""}); + } else if (body.startsWith(PROCESSED_KEY_EXCHANGE)) { + body = body.substring(PROCESSED_KEY_EXCHANGE.length()); + body = masterCipher.encryptBody(body); + type |= (0x8000 | 0x2000); + + db.execSQL("UPDATE sms SET body = ?, type = ? WHERE _id = ?", + new String[] {body, type+"", id+""}); + } else if (body.startsWith(STALE_KEY_EXCHANGE)) { + body = body.substring(STALE_KEY_EXCHANGE.length()); + body = masterCipher.encryptBody(body); + type |= (0x8000 | 0x4000); + + db.execSQL("UPDATE sms SET body = ?, type = ? WHERE _id = ?", + new String[] {body, type+"", id+""}); + } + } catch (InvalidMessageException e) { + Log.w(TAG, e); + } + } + + skip += ROW_LIMIT; + } while (smsCursor != null && smsCursor.getCount() > 0); + + + + Cursor threadCursor = null; + skip = 0; + + do { + Log.i(TAG, "Looping thread cursor..."); + + if (threadCursor != null) + threadCursor.close(); + + threadCursor = db.query("thread", new String[] {"_id", "snippet_type", "snippet"}, + "snippet_type & " + 0x80000000 + " != 0", + null, null, null, "_id", skip + "," + ROW_LIMIT); + + while (threadCursor != null && threadCursor.moveToNext()) { + listener.setProgress(smsCount + threadCursor.getPosition(), smsCount + threadCount); + + try { + String snippet = threadCursor.getString(threadCursor.getColumnIndexOrThrow("snippet")); + long snippetType = threadCursor.getLong(threadCursor.getColumnIndexOrThrow("snippet_type")); + long id = threadCursor.getLong(threadCursor.getColumnIndexOrThrow("_id")); + + if (!TextUtils.isEmpty(snippet)) { + snippet = masterCipher.decryptBody(snippet); + } + + if (snippet.startsWith(KEY_EXCHANGE)) { + snippet = snippet.substring(KEY_EXCHANGE.length()); + snippet = masterCipher.encryptBody(snippet); + snippetType |= 0x8000; + + db.execSQL("UPDATE thread SET snippet = ?, snippet_type = ? WHERE _id = ?", + new String[] {snippet, snippetType+"", id+""}); + } else if (snippet.startsWith(PROCESSED_KEY_EXCHANGE)) { + snippet = snippet.substring(PROCESSED_KEY_EXCHANGE.length()); + snippet = masterCipher.encryptBody(snippet); + snippetType |= (0x8000 | 0x2000); + + db.execSQL("UPDATE thread SET snippet = ?, snippet_type = ? WHERE _id = ?", + new String[] {snippet, snippetType+"", id+""}); + } else if (snippet.startsWith(STALE_KEY_EXCHANGE)) { + snippet = snippet.substring(STALE_KEY_EXCHANGE.length()); + snippet = masterCipher.encryptBody(snippet); + snippetType |= (0x8000 | 0x4000); + + db.execSQL("UPDATE thread SET snippet = ?, snippet_type = ? WHERE _id = ?", + new String[] {snippet, snippetType+"", id+""}); + } + } catch (InvalidMessageException e) { + Log.w(TAG, e); + } + } + + skip += ROW_LIMIT; + } while (threadCursor != null && threadCursor.getCount() > 0); + + if (smsCursor != null) + smsCursor.close(); + + if (threadCursor != null) + threadCursor.close(); + } + + if (fromVersion < LegacyMigrationJob.MMS_BODY_VERSION) { + Log.i(TAG, "Update MMS bodies..."); + MasterCipher masterCipher = new MasterCipher(masterSecret); + Cursor mmsCursor = db.query("mms", new String[] {"_id"}, + "msg_box & " + 0x80000000L + " != 0", + null, null, null, null); + + Log.i(TAG, "Got MMS rows: " + (mmsCursor == null ? "null" : mmsCursor.getCount())); + + while (mmsCursor != null && mmsCursor.moveToNext()) { + listener.setProgress(mmsCursor.getPosition(), mmsCursor.getCount()); + + long mmsId = mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow("_id")); + String body = null; + int partCount = 0; + Cursor partCursor = db.query("part", new String[] {"_id", "ct", "_data", "encrypted"}, + "mid = ?", new String[] {mmsId+""}, null, null, null); + + while (partCursor != null && partCursor.moveToNext()) { + String contentType = partCursor.getString(partCursor.getColumnIndexOrThrow("ct")); + + if (MediaUtil.isTextType(contentType)) { + try { + long partId = partCursor.getLong(partCursor.getColumnIndexOrThrow("_id")); + String dataLocation = partCursor.getString(partCursor.getColumnIndexOrThrow("_data")); + boolean encrypted = partCursor.getInt(partCursor.getColumnIndexOrThrow("encrypted")) == 1; + File dataFile = new File(dataLocation); + + InputStream is; + + AttachmentSecret attachmentSecret = new AttachmentSecret(masterSecret.getEncryptionKey().getEncoded(), + masterSecret.getMacKey().getEncoded(), null); + if (encrypted) is = ClassicDecryptingPartInputStream.createFor(attachmentSecret, dataFile); + else is = new FileInputStream(dataFile); + + body = (body == null) ? StreamUtil.readFullyAsString(is) : body + " " + StreamUtil.readFullyAsString(is); + + //noinspection ResultOfMethodCallIgnored + dataFile.delete(); + db.delete("part", "_id = ?", new String[] {partId+""}); + } catch (IOException e) { + Log.w(TAG, e); + } + } else if (MediaUtil.isAudioType(contentType) || + MediaUtil.isImageType(contentType) || + MediaUtil.isVideoType(contentType)) + { + partCount++; + } + } + + if (!TextUtils.isEmpty(body)) { + body = masterCipher.encryptBody(body); + db.execSQL("UPDATE mms SET body = ?, part_count = ? WHERE _id = ?", + new String[] {body, partCount+"", mmsId+""}); + } else { + db.execSQL("UPDATE mms SET part_count = ? WHERE _id = ?", + new String[] {partCount+"", mmsId+""}); + } + + Log.i(TAG, "Updated body: " + body + " and part_count: " + partCount); + } + } + + if (fromVersion < LegacyMigrationJob.TOFU_IDENTITIES_VERSION) { + File sessionDirectory = new File(context.getFilesDir() + File.separator + "sessions"); + + if (sessionDirectory.exists() && sessionDirectory.isDirectory()) { + File[] sessions = sessionDirectory.listFiles(); + + if (sessions != null) { + for (File session : sessions) { + String name = session.getName(); + + if (name.matches("[0-9]+")) { + long recipientId = Long.parseLong(name); + IdentityKey identityKey = null; + // NOTE (4/21/14) -- At this moment in time, we're forgetting the ability to parse + // V1 session records. Despite our usual attempts to avoid using shared code in the + // upgrade path, this is too complex to put here directly. Thus, unfortunately + // this operation is now lost to the ages. From the git log, it seems to have been + // almost exactly a year since this went in, so hopefully the bulk of people have + // already upgraded. +// IdentityKey identityKey = Session.getRemoteIdentityKey(context, masterSecret, recipientId); + + if (identityKey != null) { + MasterCipher masterCipher = new MasterCipher(masterSecret); + String identityKeyString = Base64.encodeBytes(identityKey.serialize()); + String macString = Base64.encodeBytes(masterCipher.getMacFor(recipientId + + identityKeyString)); + + db.execSQL("REPLACE INTO identities (recipient, key, mac) VALUES (?, ?, ?)", + new String[] {recipientId+"", identityKeyString, macString}); + } + } + } + } + } + } + + if (fromVersion < LegacyMigrationJob.ASYMMETRIC_MASTER_SECRET_FIX_VERSION) { + if (!MasterSecretUtil.hasAsymmericMasterSecret(context)) { + MasterSecretUtil.generateAsymmetricMasterSecret(context, masterSecret); + + MasterCipher masterCipher = new MasterCipher(masterSecret); + Cursor cursor = null; + + try { + cursor = db.query(SmsDatabase.TABLE_NAME, + new String[] {SmsDatabase.ID, SmsDatabase.BODY, SmsDatabase.TYPE}, + SmsDatabase.TYPE + " & ? == 0", + new String[] {String.valueOf(SmsDatabase.Types.ENCRYPTION_MASK)}, + null, null, null); + + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String body = cursor.getString(1); + long type = cursor.getLong(2); + + String encryptedBody = masterCipher.encryptBody(body); + + ContentValues update = new ContentValues(); + update.put(SmsDatabase.BODY, encryptedBody); + update.put(SmsDatabase.TYPE, type | 0x80000000); // Inline now deprecated symmetric encryption type + + db.update(SmsDatabase.TABLE_NAME, update, SmsDatabase.ID + " = ?", + new String[] {String.valueOf(id)}); + } + } finally { + if (cursor != null) + cursor.close(); + } + } + } + + db.setTransactionSuccessful(); + db.endTransaction(); + +// DecryptingQueue.schedulePendingDecrypts(context, masterSecret); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.beginTransaction(); + + if (oldVersion < INTRODUCED_IDENTITIES_VERSION) { + db.execSQL("CREATE TABLE identities (_id INTEGER PRIMARY KEY, key TEXT UNIQUE, name TEXT UNIQUE, mac TEXT);"); + } + + if (oldVersion < INTRODUCED_INDEXES_VERSION) { + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON sms (thread_id);", + "CREATE INDEX IF NOT EXISTS sms_read_index ON sms (read);", + "CREATE INDEX IF NOT EXISTS sms_read_and_thread_id_index ON sms (read,thread_id);", + "CREATE INDEX IF NOT EXISTS sms_type_index ON sms (type);" + }); + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON mms (thread_id);", + "CREATE INDEX IF NOT EXISTS mms_read_index ON mms (read);", + "CREATE INDEX IF NOT EXISTS mms_read_and_thread_id_index ON mms (read,thread_id);", + "CREATE INDEX IF NOT EXISTS mms_message_box_index ON mms (msg_box);" + }); + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS part_mms_id_index ON part (mid);" + }); + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON thread (recipient_ids);", + }); + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS mms_addresses_mms_id_index ON mms_addresses (mms_id);", + }); + } + + if (oldVersion < INTRODUCED_DATE_SENT_VERSION) { + db.execSQL("ALTER TABLE sms ADD COLUMN date_sent INTEGER;"); + db.execSQL("UPDATE sms SET date_sent = date;"); + + db.execSQL("ALTER TABLE mms ADD COLUMN date_received INTEGER;"); + db.execSQL("UPDATE mms SET date_received = date;"); + } + + if (oldVersion < INTRODUCED_DRAFTS_VERSION) { + db.execSQL("CREATE TABLE drafts (_id INTEGER PRIMARY KEY, thread_id INTEGER, type TEXT, value TEXT);"); + executeStatements(db, new String[] { + "CREATE INDEX IF NOT EXISTS draft_thread_index ON drafts (thread_id);", + }); + } + + if (oldVersion < INTRODUCED_NEW_TYPES_VERSION) { + String KEY_EXCHANGE = "?TextSecureKeyExchange"; + String SYMMETRIC_ENCRYPT = "?TextSecureLocalEncrypt"; + String ASYMMETRIC_ENCRYPT = "?TextSecureAsymmetricEncrypt"; + String ASYMMETRIC_LOCAL_ENCRYPT = "?TextSecureAsymmetricLocalEncrypt"; + String PROCESSED_KEY_EXCHANGE = "?TextSecureKeyExchangd"; + String STALE_KEY_EXCHANGE = "?TextSecureKeyExchangs"; + + // SMS Updates + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {20L+"", 1L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {21L+"", 43L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {22L+"", 4L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {23L+"", 2L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {24L+"", 5L+""}); + + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(21L | 0x800000L)+"", 42L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(23L | 0x800000L)+"", 44L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(20L | 0x800000L)+"", 45L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(20L | 0x800000L | 0x10000000L)+"", 46L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(20L)+"", 47L+""}); + db.execSQL("UPDATE sms SET type = ? WHERE type = ?", new String[] {(20L | 0x800000L | 0x08000000L)+"", 48L+""}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(SYMMETRIC_ENCRYPT.length()+1)+"", + 0x80000000L+"", + SYMMETRIC_ENCRYPT + "%"}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(ASYMMETRIC_LOCAL_ENCRYPT.length()+1)+"", + 0x40000000L+"", + ASYMMETRIC_LOCAL_ENCRYPT + "%"}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(ASYMMETRIC_ENCRYPT.length()+1)+"", + (0x800000L | 0x20000000L)+"", + ASYMMETRIC_ENCRYPT + "%"}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(KEY_EXCHANGE.length()+1)+"", + 0x8000L+"", + KEY_EXCHANGE + "%"}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(PROCESSED_KEY_EXCHANGE.length()+1)+"", + (0x8000L | 0x2000L)+"", + PROCESSED_KEY_EXCHANGE + "%"}); + + db.execSQL("UPDATE sms SET body = substr(body, ?), type = type | ? WHERE body LIKE ?", + new String[] {(STALE_KEY_EXCHANGE.length()+1)+"", + (0x8000L | 0x4000L)+"", + STALE_KEY_EXCHANGE + "%"}); + + // MMS Updates + + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(20L | 0x80000000L)+"", 1+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(23L | 0x80000000L)+"", 2+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(21L | 0x80000000L)+"", 4+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(24L | 0x80000000L)+"", 12+""}); + + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(21L | 0x80000000L | 0x800000L) +"", 5+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(23L | 0x80000000L | 0x800000L) +"", 6+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(20L | 0x20000000L | 0x800000L) +"", 7+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(20L | 0x80000000L | 0x800000L) +"", 8+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(20L | 0x08000000L | 0x800000L) +"", 9+""}); + db.execSQL("UPDATE mms SET msg_box = ? WHERE msg_box = ?", new String[] {(20L | 0x10000000L | 0x800000L) +"", 10+""}); + + // Thread Updates + + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_type INTEGER;"); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(SYMMETRIC_ENCRYPT.length()+1)+"", + 0x80000000L+"", + SYMMETRIC_ENCRYPT + "%"}); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(ASYMMETRIC_LOCAL_ENCRYPT.length()+1)+"", + 0x40000000L+"", + ASYMMETRIC_LOCAL_ENCRYPT + "%"}); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(ASYMMETRIC_ENCRYPT.length()+1)+"", + (0x800000L | 0x20000000L)+"", + ASYMMETRIC_ENCRYPT + "%"}); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(KEY_EXCHANGE.length()+1)+"", + 0x8000L+"", + KEY_EXCHANGE + "%"}); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(STALE_KEY_EXCHANGE.length()+1)+"", + (0x8000L | 0x4000L)+"", + STALE_KEY_EXCHANGE + "%"}); + + db.execSQL("UPDATE thread SET snippet = substr(snippet, ?), " + + "snippet_type = ? WHERE snippet LIKE ?", + new String[] {(PROCESSED_KEY_EXCHANGE.length()+1)+"", + (0x8000L | 0x2000L)+"", + PROCESSED_KEY_EXCHANGE + "%"}); + } + + if (oldVersion < INTRODUCED_MMS_BODY_VERSION) { + db.execSQL("ALTER TABLE mms ADD COLUMN body TEXT"); + db.execSQL("ALTER TABLE mms ADD COLUMN part_count INTEGER"); + } + + if (oldVersion < INTRODUCED_MMS_FROM_VERSION) { + db.execSQL("ALTER TABLE mms ADD COLUMN address TEXT"); + + Cursor cursor = db.query("mms_addresses", null, "type = ?", new String[] {0x89+""}, + null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long mmsId = cursor.getLong(cursor.getColumnIndexOrThrow("mms_id")); + String address = cursor.getString(cursor.getColumnIndexOrThrow("address")); + + if (!TextUtils.isEmpty(address)) { + db.execSQL("UPDATE mms SET address = ? WHERE _id = ?", new String[]{address, mmsId+""}); + } + } + + if (cursor != null) + cursor.close(); + } + + if (oldVersion < INTRODUCED_TOFU_IDENTITY_VERSION) { + db.execSQL("DROP TABLE identities"); + db.execSQL("CREATE TABLE identities (_id INTEGER PRIMARY KEY, recipient INTEGER UNIQUE, key TEXT, mac TEXT);"); + } + + if (oldVersion < INTRODUCED_PUSH_DATABASE_VERSION) { + db.execSQL("CREATE TABLE push (_id INTEGER PRIMARY KEY, type INTEGER, source TEXT, destinations TEXT, body TEXT, TIMESTAMP INTEGER);"); + db.execSQL("ALTER TABLE part ADD COLUMN pending_push INTEGER;"); + db.execSQL("CREATE INDEX IF NOT EXISTS pending_push_index ON part (pending_push);"); + } + + if (oldVersion < INTRODUCED_GROUP_DATABASE_VERSION) { + db.execSQL("CREATE TABLE groups (_id INTEGER PRIMARY KEY, group_id TEXT, title TEXT, members TEXT, avatar BLOB, avatar_id INTEGER, avatar_key BLOB, avatar_content_type TEXT, avatar_relay TEXT, timestamp INTEGER, active INTEGER DEFAULT 1);"); + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON groups (GROUP_ID);"); + db.execSQL("ALTER TABLE push ADD COLUMN device_id INTEGER DEFAULT 1;"); + db.execSQL("ALTER TABLE sms ADD COLUMN address_device_id INTEGER DEFAULT 1;"); + db.execSQL("ALTER TABLE mms ADD COLUMN address_device_id INTEGER DEFAULT 1;"); + } + + if (oldVersion < INTRODUCED_PUSH_FIX_VERSION) { + db.execSQL("CREATE TEMPORARY table push_backup (_id INTEGER PRIMARY KEY, type INTEGER, source, TEXT, destinations TEXT, body TEXT, timestamp INTEGER, device_id INTEGER DEFAULT 1);"); + db.execSQL("INSERT INTO push_backup(_id, type, source, body, timestamp, device_id) SELECT _id, type, source, body, timestamp, device_id FROM push;"); + db.execSQL("DROP TABLE push"); + db.execSQL("CREATE TABLE push (_id INTEGER PRIMARY KEY, type INTEGER, source TEXT, body TEXT, timestamp INTEGER, device_id INTEGER DEFAULT 1);"); + db.execSQL("INSERT INTO push (_id, type, source, body, timestamp, device_id) SELECT _id, type, source, body, timestamp, device_id FROM push_backup;"); + db.execSQL("DROP TABLE push_backup;"); + } + + if (oldVersion < INTRODUCED_DELIVERY_RECEIPTS) { + db.execSQL("ALTER TABLE sms ADD COLUMN delivery_receipt_count INTEGER DEFAULT 0;"); + db.execSQL("ALTER TABLE mms ADD COLUMN delivery_receipt_count INTEGER DEFAULT 0;"); + db.execSQL("CREATE INDEX IF NOT EXISTS sms_date_sent_index ON sms (date_sent);"); + db.execSQL("CREATE INDEX IF NOT EXISTS mms_date_sent_index ON mms (date);"); + } + + if (oldVersion < INTRODUCED_PART_DATA_SIZE_VERSION) { + db.execSQL("ALTER TABLE part ADD COLUMN data_size INTEGER DEFAULT 0;"); + } + + if (oldVersion < INTRODUCED_THUMBNAILS_VERSION) { + db.execSQL("ALTER TABLE part ADD COLUMN thumbnail TEXT;"); + db.execSQL("ALTER TABLE part ADD COLUMN aspect_ratio REAL;"); + } + + if (oldVersion < INTRODUCED_IDENTITY_COLUMN_VERSION) { + db.execSQL("ALTER TABLE sms ADD COLUMN mismatched_identities TEXT"); + db.execSQL("ALTER TABLE mms ADD COLUMN mismatched_identities TEXT"); + db.execSQL("ALTER TABLE mms ADD COLUMN network_failures TEXT"); + } + + if (oldVersion < INTRODUCED_UNIQUE_PART_IDS_VERSION) { + db.execSQL("ALTER TABLE part ADD COLUMN unique_id INTEGER NOT NULL DEFAULT 0"); + } + + if (oldVersion < INTRODUCED_RECIPIENT_PREFS_DB) { + db.execSQL("CREATE TABLE recipient_preferences " + + "(_id INTEGER PRIMARY KEY, recipient_ids TEXT UNIQUE, block INTEGER DEFAULT 0, " + + "notification TEXT DEFAULT NULL, vibrate INTEGER DEFAULT 0, mute_until INTEGER DEFAULT 0)"); + } + + if (oldVersion < INTRODUCED_ENVELOPE_CONTENT_VERSION) { + db.execSQL("ALTER TABLE push ADD COLUMN content TEXT"); + } + + if (oldVersion < INTRODUCED_COLOR_PREFERENCE_VERSION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN color TEXT DEFAULT NULL"); + } + + if (oldVersion < INTRODUCED_DB_OPTIMIZATIONS_VERSION) { + db.execSQL("UPDATE mms SET date_received = (date_received * 1000), date = (date * 1000);"); + db.execSQL("CREATE INDEX IF NOT EXISTS sms_thread_date_index ON sms (thread_id, date);"); + db.execSQL("CREATE INDEX IF NOT EXISTS mms_thread_date_index ON mms (thread_id, date_received);"); + } + + if (oldVersion < INTRODUCED_INVITE_REMINDERS_VERSION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN seen_invite_reminder INTEGER DEFAULT 0"); + } + + if (oldVersion < INTRODUCED_CONVERSATION_LIST_THUMBNAILS_VERSION) { + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_uri TEXT DEFAULT NULL"); + } + + if (oldVersion < INTRODUCED_ARCHIVE_VERSION) { + db.execSQL("ALTER TABLE thread ADD COLUMN archived INTEGER DEFAULT 0"); + db.execSQL("CREATE INDEX IF NOT EXISTS archived_index ON thread (archived)"); + } + + if (oldVersion < INTRODUCED_CONVERSATION_LIST_STATUS_VERSION) { + db.execSQL("ALTER TABLE thread ADD COLUMN status INTEGER DEFAULT -1"); + db.execSQL("ALTER TABLE thread ADD COLUMN delivery_receipt_count INTEGER DEFAULT 0"); + } + + if (oldVersion < MIGRATED_CONVERSATION_LIST_STATUS_VERSION) { + Cursor threadCursor = db.query("thread", new String[] {"_id"}, null, null, null, null, null); + + while (threadCursor != null && threadCursor.moveToNext()) { + long threadId = threadCursor.getLong(threadCursor.getColumnIndexOrThrow("_id")); + + Cursor cursor = db.rawQuery("SELECT DISTINCT date AS date_received, status, " + + "delivery_receipt_count FROM sms WHERE (thread_id = ?1) " + + "UNION ALL SELECT DISTINCT date_received, -1 AS status, " + + "delivery_receipt_count FROM mms WHERE (thread_id = ?1) " + + "ORDER BY date_received DESC LIMIT 1", new String[]{threadId + ""}); + + if (cursor != null && cursor.moveToNext()) { + int status = cursor.getInt(cursor.getColumnIndexOrThrow("status")); + int receiptCount = cursor.getInt(cursor.getColumnIndexOrThrow("delivery_receipt_count")); + + db.execSQL("UPDATE thread SET status = ?, delivery_receipt_count = ? WHERE _id = ?", + new String[]{status + "", receiptCount + "", threadId + ""}); + } + } + } + + if (oldVersion < INTRODUCED_SUBSCRIPTION_ID_VERSION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN default_subscription_id INTEGER DEFAULT -1"); + db.execSQL("ALTER TABLE sms ADD COLUMN subscription_id INTEGER DEFAULT -1"); + db.execSQL("ALTER TABLE mms ADD COLUMN subscription_id INTEGER DEFAULT -1"); + } + + if (oldVersion < INTRODUCED_EXPIRE_MESSAGES_VERSION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN expire_messages INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE sms ADD COLUMN expires_in INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN expires_in INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE sms ADD COLUMN expire_started INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN expire_started INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE thread ADD COLUMN expires_in INTEGER DEFAULT 0"); + } + + if (oldVersion < INTRODUCED_LAST_SEEN) { + db.execSQL("ALTER TABLE thread ADD COLUMN last_seen INTEGER DEFAULT 0"); + } + + if (oldVersion < INTRODUCED_DIGEST) { + db.execSQL("ALTER TABLE part ADD COLUMN digest BLOB"); + db.execSQL("ALTER TABLE groups ADD COLUMN avatar_digest BLOB"); + } + + if (oldVersion < INTRODUCED_NOTIFIED) { + db.execSQL("ALTER TABLE sms ADD COLUMN notified INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN notified INTEGER DEFAULT 0"); + + db.execSQL("DROP INDEX sms_read_and_thread_id_index"); + db.execSQL("CREATE INDEX IF NOT EXISTS sms_read_and_notified_and_thread_id_index ON sms(read,notified,thread_id)"); + + db.execSQL("DROP INDEX mms_read_and_thread_id_index"); + db.execSQL("CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON mms(read,notified,thread_id)"); + } + + if (oldVersion < INTRODUCED_DOCUMENTS) { + db.execSQL("ALTER TABLE part ADD COLUMN file_name TEXT"); + } + + if (oldVersion < INTRODUCED_FAST_PREFLIGHT) { + db.execSQL("ALTER TABLE part ADD COLUMN fast_preflight_id TEXT"); + } + + if (oldVersion < INTRODUCED_VOICE_NOTES) { + db.execSQL("ALTER TABLE part ADD COLUMN voice_note INTEGER DEFAULT 0"); + } + + if (oldVersion < INTRODUCED_IDENTITY_TIMESTAMP) { + db.execSQL("ALTER TABLE identities ADD COLUMN timestamp INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE identities ADD COLUMN first_use INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE identities ADD COLUMN nonblocking_approval INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE identities ADD COLUMN verified INTEGER DEFAULT 0"); + + db.execSQL("DROP INDEX archived_index"); + db.execSQL("CREATE INDEX IF NOT EXISTS archived_count_index ON thread (archived, message_count)"); + } + + if (oldVersion < SANIFY_ATTACHMENT_DOWNLOAD) { + db.execSQL("UPDATE part SET pending_push = '2' WHERE pending_push = '1'"); + } + + if (oldVersion < NO_MORE_CANONICAL_ADDRESS_DATABASE && isValidNumber(TextSecurePreferences.getLocalNumber(context))) { + SQLiteOpenHelper canonicalAddressDatabaseHelper = new SQLiteOpenHelper(context, "canonical_address.db", null, 1) { + @Override + public void onCreate(SQLiteDatabase db) { + throw new AssertionError("No canonical address DB?"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {} + }; + + SQLiteDatabase canonicalAddressDatabase = canonicalAddressDatabaseHelper.getReadableDatabase(); + NumberMigrator numberMigrator = new NumberMigrator(TextSecurePreferences.getLocalNumber(context)); + + // Migrate Thread Database + Cursor cursor = db.query("thread", new String[] {"_id", "recipient_ids"}, null, null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long threadId = cursor.getLong(0); + String recipientIdsList = cursor.getString(1); + String[] recipientIds = recipientIdsList.split(" "); + String[] addresses = new String[recipientIds.length]; + + for (int i=0;i newDocumentList = new LinkedList<>(); + + for (PreCanonicalAddressIdentityMismatchDocument oldDocument : oldDocumentList.list) { + Cursor resolved = canonicalAddressDatabase.query("canonical_addresses", new String[] {"address"}, "_id = ?", new String[] {String.valueOf(oldDocument.recipientId)}, null, null, null); + + if (resolved != null && resolved.moveToFirst()) { + String address = resolved.getString(0); + newDocumentList.add(new PostCanonicalAddressIdentityMismatchDocument(numberMigrator.migrate(address), oldDocument.identityKey)); + } else { + throw new AssertionError("Unable to resolve: " + oldDocument.recipientId); + } + + if (resolved != null) resolved.close(); + } + + ContentValues values = new ContentValues(1); + values.put("mismatched_identities", JsonUtils.toJson(new PostCanonicalAddressIdentityMismatchList(newDocumentList))); + db.update("sms", values, "_id = ?", new String[] {String.valueOf(id)}); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + if (cursor != null) cursor.close(); + + // Migrate MMS mismatched identities + cursor = db.query("mms", new String[] {"_id", "mismatched_identities"}, "mismatched_identities IS NOT NULL", null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String document = cursor.getString(1); + + if (!TextUtils.isEmpty(document)) { + try { + PreCanonicalAddressIdentityMismatchList oldDocumentList = JsonUtils.fromJson(document, PreCanonicalAddressIdentityMismatchList.class); + List newDocumentList = new LinkedList<>(); + + for (PreCanonicalAddressIdentityMismatchDocument oldDocument : oldDocumentList.list) { + Cursor resolved = canonicalAddressDatabase.query("canonical_addresses", new String[] {"address"}, "_id = ?", new String[] {String.valueOf(oldDocument.recipientId)}, null, null, null); + + if (resolved != null && resolved.moveToFirst()) { + String address = resolved.getString(0); + newDocumentList.add(new PostCanonicalAddressIdentityMismatchDocument(numberMigrator.migrate(address), oldDocument.identityKey)); + } else { + throw new AssertionError("Unable to resolve: " + oldDocument.recipientId); + } + + if (resolved != null) resolved.close(); + } + + ContentValues values = new ContentValues(1); + values.put("mismatched_identities", JsonUtils.toJson(new PostCanonicalAddressIdentityMismatchList(newDocumentList))); + db.update("mms", values, "_id = ?", new String[] {String.valueOf(id)}); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + if (cursor != null) cursor.close(); + + // Migrate MMS network failures + cursor = db.query("mms", new String[] {"_id", "network_failures"}, "network_failures IS NOT NULL", null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String document = cursor.getString(1); + + if (!TextUtils.isEmpty(document)) { + try { + PreCanonicalAddressNetworkFailureList oldDocumentList = JsonUtils.fromJson(document, PreCanonicalAddressNetworkFailureList.class); + List newDocumentList = new LinkedList<>(); + + for (PreCanonicalAddressNetworkFailureDocument oldDocument : oldDocumentList.list) { + Cursor resolved = canonicalAddressDatabase.query("canonical_addresses", new String[] {"address"}, "_id = ?", new String[] {String.valueOf(oldDocument.recipientId)}, null, null, null); + + if (resolved != null && resolved.moveToFirst()) { + String address = resolved.getString(0); + newDocumentList.add(new PostCanonicalAddressNetworkFailureDocument(numberMigrator.migrate(address))); + } else { + throw new AssertionError("Unable to resolve: " + oldDocument.recipientId); + } + + if (resolved != null) resolved.close(); + } + + ContentValues values = new ContentValues(1); + values.put("network_failures", JsonUtils.toJson(new PostCanonicalAddressNetworkFailureList(newDocumentList))); + db.update("mms", values, "_id = ?", new String[] {String.valueOf(id)}); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + // Migrate sessions + File sessionsDirectory = new File(context.getFilesDir(), "sessions-v2"); + + if (sessionsDirectory.exists() && sessionsDirectory.isDirectory()) { + File[] sessions = sessionsDirectory.listFiles(); + + for (File session : sessions) { + try { + String[] sessionParts = session.getName().split("[.]"); + long recipientId = Long.parseLong(sessionParts[0]); + + int deviceId; + + if (sessionParts.length > 1) deviceId = Integer.parseInt(sessionParts[1]); + else deviceId = 1; + + Cursor resolved = canonicalAddressDatabase.query("canonical_addresses", new String[] {"address"}, "_id = ?", new String[] {String.valueOf(recipientId)}, null, null, null); + + if (resolved != null && resolved.moveToNext()) { + String address = resolved.getString(0); + File destination = new File(session.getParentFile(), address + (deviceId != 1 ? "." + deviceId : "")); + + if (!session.renameTo(destination)) { + Log.w(TAG, "Session rename failed: " + destination); + } + } + + if (resolved != null) resolved.close(); + } catch (NumberFormatException e) { + Log.w(TAG, e); + } + } + } + + } + + if (oldVersion < NO_MORE_RECIPIENTS_PLURAL) { + db.execSQL("ALTER TABLE groups ADD COLUMN mms INTEGER DEFAULT 0"); + + Cursor cursor = db.query("thread", new String[] {"_id", "recipient_ids"}, null, null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long threadId = cursor.getLong(0); + String addressListString = cursor.getString(1); + String[] addressList = DelimiterUtil.split(addressListString, ' '); + + if (addressList.length == 1) { + ContentValues contentValues = new ContentValues(); + contentValues.put("recipient_ids", DelimiterUtil.unescape(addressListString, ' ')); + db.update("thread", contentValues, "_id = ?", new String[] {String.valueOf(threadId)}); + } else { + byte[] groupId = new byte[16]; + List members = new LinkedList<>(); + + new SecureRandom().nextBytes(groupId); + + for (String address : addressList) { + members.add(DelimiterUtil.escape(DelimiterUtil.unescape(address, ' '), ',')); + } + + members.add(DelimiterUtil.escape(TextSecurePreferences.getLocalNumber(context), ',')); + + Collections.sort(members); + + String encodedGroupId = "__signal_mms_group__!" + Hex.toStringCondensed(groupId); + ContentValues groupValues = new ContentValues(); + ContentValues threadValues = new ContentValues(); + + groupValues.put("group_id", encodedGroupId); + groupValues.put("members", Util.join(members, ",")); + groupValues.put("mms", 1); + + threadValues.put("recipient_ids", encodedGroupId); + + db.insert("groups", null, groupValues); + db.update("thread", threadValues, "_id = ?", new String[] {String.valueOf(threadId)}); + db.update("recipient_preferences", threadValues, "recipient_ids = ?", new String[] {addressListString}); + } + } + + if (cursor != null) cursor.close(); + + cursor = db.query("recipient_preferences", new String[] {"_id", "recipient_ids"}, null, null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String addressListString = cursor.getString(1); + String[] addressList = DelimiterUtil.split(addressListString, ' '); + + if (addressList.length == 1) { + ContentValues contentValues = new ContentValues(); + contentValues.put("recipient_ids", DelimiterUtil.unescape(addressListString, ' ')); + db.update("recipient_preferences", contentValues, "_id = ?", new String[] {String.valueOf(id)}); + } else { + Log.w(TAG, "Found preferences for MMS thread that appears to be gone: " + addressListString); + db.delete("recipient_preferences", "_id = ?", new String[] {String.valueOf(id)}); + } + } + + if (cursor != null) cursor.close(); + + cursor = db.rawQuery("SELECT mms._id, thread.recipient_ids FROM mms, thread WHERE mms.address IS NULL AND mms.thread_id = thread._id", null); + + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + ContentValues contentValues = new ContentValues(1); + + contentValues.put("address", cursor.getString(1)); + db.update("mms", contentValues, "_id = ?", new String[] {String.valueOf(id)}); + } + + if (cursor != null) cursor.close(); + } + + if (oldVersion < INTERNAL_DIRECTORY) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN registered INTEGER DEFAULT 0"); + + if (isValidNumber(TextSecurePreferences.getLocalNumber(context))) { + OldDirectoryDatabaseHelper directoryDatabaseHelper = new OldDirectoryDatabaseHelper(context); + SQLiteDatabase directoryDatabase = directoryDatabaseHelper.getWritableDatabase(); + + Cursor cursor = directoryDatabase.query("directory", new String[] {"number", "registered"}, null, null, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + String address = new NumberMigrator(TextSecurePreferences.getLocalNumber(context)).migrate(cursor.getString(0)); + ContentValues contentValues = new ContentValues(1); + + contentValues.put("registered", cursor.getInt(1) == 1 ? 1 : 2); + + if (db.update("recipient_preferences", contentValues, "recipient_ids = ?", new String[] {address}) < 1) { + contentValues.put("recipient_ids", address); + db.insert("recipient_preferences", null, contentValues); + } + } + + if (cursor != null) cursor.close(); + } + } + + if (oldVersion < INTERNAL_SYSTEM_DISPLAY_NAME) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN system_display_name TEXT DEFAULT NULL"); + } + + if (oldVersion < PROFILES) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN profile_key TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN signal_profile_name TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN signal_profile_avatar TEXT DEFAULT NULL"); + } + + if (oldVersion < PROFILE_SHARING_APPROVAL) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN profile_sharing_approval INTEGER DEFAULT 0"); + } + + if (oldVersion < UNSEEN_NUMBER_OFFER) { + db.execSQL("ALTER TABLE thread ADD COLUMN has_sent INTEGER DEFAULT 0"); + } + + if (oldVersion < READ_RECEIPTS) { + db.execSQL("ALTER TABLE sms ADD COLUMN read_receipt_count INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN read_receipt_count INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE thread ADD COLUMN read_receipt_count INTEGER DEFAULT 0"); + } + + if (oldVersion < GROUP_RECEIPT_TRACKING) { + db.execSQL("CREATE TABLE group_receipts (_id INTEGER PRIMARY KEY, mms_id INTEGER, address TEXT, status INTEGER, timestamp INTEGER)"); + db.execSQL("CREATE INDEX IF NOT EXISTS group_receipt_mms_id_index ON group_receipts (mms_id)"); + } + + if (oldVersion < UNREAD_COUNT_VERSION) { + db.execSQL("ALTER TABLE thread ADD COLUMN unread_count INTEGER DEFAULT 0"); + + try (Cursor cursor = db.query("thread", new String[] {"_id"}, "read = 0", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long threadId = cursor.getLong(0); + int unreadCount = 0; + + try (Cursor smsCursor = db.rawQuery("SELECT COUNT(*) FROM sms WHERE thread_id = ? AND read = '0'", new String[] {String.valueOf(threadId)})) { + if (smsCursor != null && smsCursor.moveToFirst()) { + unreadCount += smsCursor.getInt(0); + } + } + + try (Cursor mmsCursor = db.rawQuery("SELECT COUNT(*) FROM mms WHERE thread_id = ? AND read = '0'", new String[] {String.valueOf(threadId)})) { + if (mmsCursor != null && mmsCursor.moveToFirst()) { + unreadCount += mmsCursor.getInt(0); + } + } + + db.execSQL("UPDATE thread SET unread_count = ? WHERE _id = ?", + new String[] {String.valueOf(unreadCount), + String.valueOf(threadId)}); + } + } + } + + if (oldVersion < MORE_RECIPIENT_FIELDS) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN system_contact_photo TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN system_phone_label TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN system_contact_uri TEXT DEFAULT NULL"); + + if (Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + try (Cursor cursor = db.query("recipient_preferences", null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String address = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); + + if (!TextUtils.isEmpty(address) && !GroupId.isEncodedGroup(address) && !NumberUtil.isValidEmail(address)) { + Uri lookup = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)); + + try (Cursor contactCursor = context.getContentResolver().query(lookup, new String[] {ContactsContract.PhoneLookup.DISPLAY_NAME, + ContactsContract.PhoneLookup.LOOKUP_KEY, + ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.NUMBER, + ContactsContract.PhoneLookup.LABEL, + ContactsContract.PhoneLookup.PHOTO_URI}, + null, null, null)) + { + if (contactCursor != null && contactCursor.moveToFirst()) { + ContentValues contentValues = new ContentValues(3); + contentValues.put("system_contact_photo", contactCursor.getString(5)); + contentValues.put("system_phone_label", contactCursor.getString(4)); + contentValues.put("system_contact_uri", ContactsContract.Contacts.getLookupUri(contactCursor.getLong(2), contactCursor.getString(1)).toString()); + + db.update("recipient_preferences", contentValues, "recipient_ids = ?", new String[] {address}); + } + } + } + } + } + } + } + + db.setTransactionSuccessful(); + db.endTransaction(); + } + + private void executeStatements(SQLiteDatabase db, String[] statements) { + for (String statement : statements) + db.execSQL(statement); + } + + private static class PreCanonicalAddressIdentityMismatchList { + @JsonProperty(value = "m") + private List list; + } + + private static class PostCanonicalAddressIdentityMismatchList { + @JsonProperty(value = "m") + private List list; + + public PostCanonicalAddressIdentityMismatchList(List list) { + this.list = list; + } + } + + private static class PreCanonicalAddressIdentityMismatchDocument { + @JsonProperty(value = "r") + private long recipientId; + + @JsonProperty(value = "k") + private String identityKey; + } + + private static class PostCanonicalAddressIdentityMismatchDocument { + @JsonProperty(value = "a") + private String address; + + @JsonProperty(value = "k") + private String identityKey; + + public PostCanonicalAddressIdentityMismatchDocument() {} + + public PostCanonicalAddressIdentityMismatchDocument(String address, String identityKey) { + this.address = address; + this.identityKey = identityKey; + } + } + + private static class PreCanonicalAddressNetworkFailureList { + @JsonProperty(value = "l") + private List list; + } + + private static class PostCanonicalAddressNetworkFailureList { + @JsonProperty(value = "l") + private List list; + + public PostCanonicalAddressNetworkFailureList(List list) { + this.list = list; + } + } + + private static class PreCanonicalAddressNetworkFailureDocument { + @JsonProperty(value = "r") + private long recipientId; + } + + private static class PostCanonicalAddressNetworkFailureDocument { + @JsonProperty(value = "a") + private String address; + + public PostCanonicalAddressNetworkFailureDocument() {} + + public PostCanonicalAddressNetworkFailureDocument(String address) { + this.address = address; + } + } + + private static boolean isValidNumber(@Nullable String number) { + if (TextUtils.isEmpty(number)) { + return false; + } + + try { + PhoneNumberUtil.getInstance().parse(number, null); + return true; + } catch (NumberParseException e) { + return false; + } + } + + private static class NumberMigrator { + + private static final String TAG = NumberMigrator.class.getSimpleName(); + + private static final Set SHORT_COUNTRIES = new HashSet() {{ + add("NU"); + add("TK"); + add("NC"); + add("AC"); + }}; + + private final Phonenumber.PhoneNumber localNumber; + private final String localNumberString; + private final String localCountryCode; + + private final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + private final Pattern ALPHA_PATTERN = Pattern.compile("[a-zA-Z]"); + + + public NumberMigrator(String localNumber) { + try { + this.localNumberString = localNumber; + this.localNumber = phoneNumberUtil.parse(localNumber, null); + this.localCountryCode = phoneNumberUtil.getRegionCodeForNumber(this.localNumber); + } catch (NumberParseException e) { + throw new AssertionError(e); + } + } + + public String migrate(@Nullable String number) { + if (number == null) return "Unknown"; + if (number.startsWith("__textsecure_group__!")) return number; + if (ALPHA_PATTERN.matcher(number).find()) return number.trim(); + + String bareNumber = number.replaceAll("[^0-9+]", ""); + + if (bareNumber.length() == 0) { + if (TextUtils.isEmpty(number.trim())) return "Unknown"; + else return number.trim(); + } + + // libphonenumber doesn't seem to be correct for Germany and Finland + if (bareNumber.length() <= 6 && ("DE".equals(localCountryCode) || "FI".equals(localCountryCode) || "SK".equals(localCountryCode))) { + return bareNumber; + } + + // libphonenumber seems incorrect for Russia and a few other countries with 4 digit short codes. + if (bareNumber.length() <= 4 && !SHORT_COUNTRIES.contains(localCountryCode)) { + return bareNumber; + } + + try { + Phonenumber.PhoneNumber parsedNumber = phoneNumberUtil.parse(bareNumber, localCountryCode); + + if (ShortNumberInfo.getInstance().isPossibleShortNumberForRegion(parsedNumber, localCountryCode)) { + return bareNumber; + } + + return phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } catch (NumberParseException e) { + Log.w(TAG, e); + if (bareNumber.charAt(0) == '+') + return bareNumber; + + String localNumberImprecise = localNumberString; + + if (localNumberImprecise.charAt(0) == '+') + localNumberImprecise = localNumberImprecise.substring(1); + + if (localNumberImprecise.length() == bareNumber.length() || bareNumber.length() > localNumberImprecise.length()) + return "+" + number; + + int difference = localNumberImprecise.length() - bareNumber.length(); + + return "+" + localNumberImprecise.substring(0, difference) + bareNumber; + } + } + } + + private static class OldDirectoryDatabaseHelper extends SQLiteOpenHelper { + + private static final int INTRODUCED_CHANGE_FROM_TOKEN_TO_E164_NUMBER = 2; + private static final int INTRODUCED_VOICE_COLUMN = 4; + private static final int INTRODUCED_VIDEO_COLUMN = 5; + + private static final String DATABASE_NAME = "whisper_directory.db"; + private static final int DATABASE_VERSION = 5; + + private static final String TABLE_NAME = "directory"; + private static final String ID = "_id"; + private static final String NUMBER = "number"; + private static final String REGISTERED = "registered"; + private static final String RELAY = "relay"; + private static final String TIMESTAMP = "timestamp"; + private static final String VOICE = "voice"; + private static final String VIDEO = "video"; + + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY, " + + NUMBER + " TEXT UNIQUE, " + + REGISTERED + " INTEGER, " + + RELAY + " TEXT, " + + TIMESTAMP + " INTEGER, " + + VOICE + " INTEGER, " + + VIDEO + " INTEGER);"; + + public OldDirectoryDatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion < INTRODUCED_CHANGE_FROM_TOKEN_TO_E164_NUMBER) { + db.execSQL("DROP TABLE directory;"); + db.execSQL("CREATE TABLE directory ( _id INTEGER PRIMARY KEY, " + + "number TEXT UNIQUE, " + + "registered INTEGER, " + + "relay TEXT, " + + "supports_sms INTEGER, " + + "timestamp INTEGER);"); + } + + if (oldVersion < INTRODUCED_VOICE_COLUMN) { + db.execSQL("ALTER TABLE directory ADD COLUMN voice INTEGER;"); + } + + if (oldVersion < INTRODUCED_VIDEO_COLUMN) { + db.execSQL("ALTER TABLE directory ADD COLUMN video INTEGER;"); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/PreKeyMigrationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/PreKeyMigrationHelper.java new file mode 100644 index 00000000..7c2b1561 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/PreKeyMigrationHelper.java @@ -0,0 +1,226 @@ +package org.thoughtcrime.securesms.database.helpers; + + +import android.content.ContentValues; +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.Conversions; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; +import org.thoughtcrime.securesms.database.SignedPreKeyDatabase; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; + +class PreKeyMigrationHelper { + + private static final String PREKEY_DIRECTORY = "prekeys"; + private static final String SIGNED_PREKEY_DIRECTORY = "signed_prekeys"; + + private static final int PLAINTEXT_VERSION = 2; + private static final int CURRENT_VERSION_MARKER = 2; + + private static final String TAG = PreKeyMigrationHelper.class.getSimpleName(); + + static boolean migratePreKeys(Context context, SQLiteDatabase database) { + File[] preKeyFiles = getPreKeyDirectory(context).listFiles(); + boolean clean = true; + + if (preKeyFiles != null) { + for (File preKeyFile : preKeyFiles) { + if (!"index.dat".equals(preKeyFile.getName())) { + try { + PreKeyRecord preKey = new PreKeyRecord(loadSerializedRecord(preKeyFile)); + + ContentValues contentValues = new ContentValues(); + contentValues.put(OneTimePreKeyDatabase.KEY_ID, preKey.getId()); + contentValues.put(OneTimePreKeyDatabase.PUBLIC_KEY, Base64.encodeBytes(preKey.getKeyPair().getPublicKey().serialize())); + contentValues.put(OneTimePreKeyDatabase.PRIVATE_KEY, Base64.encodeBytes(preKey.getKeyPair().getPrivateKey().serialize())); + database.insert(OneTimePreKeyDatabase.TABLE_NAME, null, contentValues); + Log.i(TAG, "Migrated one-time prekey: " + preKey.getId()); + } catch (IOException | InvalidMessageException e) { + Log.w(TAG, e); + clean = false; + } + } + } + } + + File[] signedPreKeyFiles = getSignedPreKeyDirectory(context).listFiles(); + + if (signedPreKeyFiles != null) { + for (File signedPreKeyFile : signedPreKeyFiles) { + if (!"index.dat".equals(signedPreKeyFile.getName())) { + try { + SignedPreKeyRecord signedPreKey = new SignedPreKeyRecord(loadSerializedRecord(signedPreKeyFile)); + + ContentValues contentValues = new ContentValues(); + contentValues.put(SignedPreKeyDatabase.KEY_ID, signedPreKey.getId()); + contentValues.put(SignedPreKeyDatabase.PUBLIC_KEY, Base64.encodeBytes(signedPreKey.getKeyPair().getPublicKey().serialize())); + contentValues.put(SignedPreKeyDatabase.PRIVATE_KEY, Base64.encodeBytes(signedPreKey.getKeyPair().getPrivateKey().serialize())); + contentValues.put(SignedPreKeyDatabase.SIGNATURE, Base64.encodeBytes(signedPreKey.getSignature())); + contentValues.put(SignedPreKeyDatabase.TIMESTAMP, signedPreKey.getTimestamp()); + database.insert(SignedPreKeyDatabase.TABLE_NAME, null, contentValues); + Log.i(TAG, "Migrated signed prekey: " + signedPreKey.getId()); + } catch (IOException | InvalidMessageException e) { + Log.w(TAG, e); + clean = false; + } + } + } + } + + File oneTimePreKeyIndex = new File(getPreKeyDirectory(context), PreKeyIndex.FILE_NAME); + File signedPreKeyIndex = new File(getSignedPreKeyDirectory(context), SignedPreKeyIndex.FILE_NAME); + + if (oneTimePreKeyIndex.exists()) { + try { + InputStreamReader reader = new InputStreamReader(new FileInputStream(oneTimePreKeyIndex)); + PreKeyIndex index = JsonUtils.fromJson(reader, PreKeyIndex.class); + reader.close(); + + Log.i(TAG, "Setting next prekey id: " + index.nextPreKeyId); + TextSecurePreferences.setNextPreKeyId(context, index.nextPreKeyId); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + if (signedPreKeyIndex.exists()) { + try { + InputStreamReader reader = new InputStreamReader(new FileInputStream(signedPreKeyIndex)); + SignedPreKeyIndex index = JsonUtils.fromJson(reader, SignedPreKeyIndex.class); + reader.close(); + + Log.i(TAG, "Setting next signed prekey id: " + index.nextSignedPreKeyId); + Log.i(TAG, "Setting active signed prekey id: " + index.activeSignedPreKeyId); + TextSecurePreferences.setNextSignedPreKeyId(context, index.nextSignedPreKeyId); + TextSecurePreferences.setActiveSignedPreKeyId(context, index.activeSignedPreKeyId); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + return clean; + } + + static void cleanUpPreKeys(@NonNull Context context) { + File preKeyDirectory = getPreKeyDirectory(context); + File[] preKeyFiles = preKeyDirectory.listFiles(); + + if (preKeyFiles != null) { + for (File preKeyFile : preKeyFiles) { + Log.i(TAG, "Deleting: " + preKeyFile.getAbsolutePath()); + preKeyFile.delete(); + } + + Log.i(TAG, "Deleting: " + preKeyDirectory.getAbsolutePath()); + preKeyDirectory.delete(); + } + + File signedPreKeyDirectory = getSignedPreKeyDirectory(context); + File[] signedPreKeyFiles = signedPreKeyDirectory.listFiles(); + + if (signedPreKeyFiles != null) { + for (File signedPreKeyFile : signedPreKeyFiles) { + Log.i(TAG, "Deleting: " + signedPreKeyFile.getAbsolutePath()); + signedPreKeyFile.delete(); + } + + Log.i(TAG, "Deleting: " + signedPreKeyDirectory.getAbsolutePath()); + signedPreKeyDirectory.delete(); + } + } + + private static byte[] loadSerializedRecord(File recordFile) + throws IOException, InvalidMessageException + { + FileInputStream fin = new FileInputStream(recordFile); + int recordVersion = readInteger(fin); + + if (recordVersion > CURRENT_VERSION_MARKER) { + throw new IOException("Invalid version: " + recordVersion); + } + + byte[] serializedRecord = readBlob(fin); + + if (recordVersion < PLAINTEXT_VERSION) { + throw new IOException("Migration didn't happen! " + recordFile.getAbsolutePath() + ", " + recordVersion); + } + + fin.close(); + return serializedRecord; + } + + private static File getPreKeyDirectory(Context context) { + return getRecordsDirectory(context, PREKEY_DIRECTORY); + } + + private static File getSignedPreKeyDirectory(Context context) { + return getRecordsDirectory(context, SIGNED_PREKEY_DIRECTORY); + } + + private static File getRecordsDirectory(Context context, String directoryName) { + File directory = new File(context.getFilesDir(), directoryName); + + if (!directory.exists()) { + if (!directory.mkdirs()) { + Log.w(TAG, "PreKey directory creation failed!"); + } + } + + return directory; + } + + private static byte[] readBlob(FileInputStream in) throws IOException { + int length = readInteger(in); + byte[] blobBytes = new byte[length]; + + in.read(blobBytes, 0, blobBytes.length); + return blobBytes; + } + + private static int readInteger(FileInputStream in) throws IOException { + byte[] integer = new byte[4]; + in.read(integer, 0, integer.length); + return Conversions.byteArrayToInt(integer); + } + + private static class PreKeyIndex { + static final String FILE_NAME = "index.dat"; + + @JsonProperty + private int nextPreKeyId; + + public PreKeyIndex() {} + } + + private static class SignedPreKeyIndex { + static final String FILE_NAME = "index.dat"; + + @JsonProperty + private int nextSignedPreKeyId; + + @JsonProperty + private int activeSignedPreKeyId = -1; + + public SignedPreKeyIndex() {} + + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdCleanupHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdCleanupHelper.java new file mode 100644 index 00000000..a3efc678 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdCleanupHelper.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.database.helpers; + +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.DelimiterUtil; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +public class RecipientIdCleanupHelper { + + private static final String TAG = Log.tag(RecipientIdCleanupHelper.class); + + public static void execute(@NonNull SQLiteDatabase db) { + Log.i(TAG, "Beginning migration."); + + long startTime = System.currentTimeMillis(); + Pattern pattern = Pattern.compile("^[0-9\\-+]+$"); + Set deletionCandidates = new HashSet<>(); + + try (Cursor cursor = db.query("recipient", new String[] { "_id", "phone" }, "group_id IS NULL AND email IS NULL", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String id = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + String phone = cursor.getString(cursor.getColumnIndexOrThrow("phone")); + + if (TextUtils.isEmpty(phone) || !pattern.matcher(phone).matches()) { + Log.i(TAG, "Recipient ID " + id + " has non-numeric characters and can potentially be deleted."); + + if (!isIdUsed(db, "identities", "address", id) && + !isIdUsed(db, "sessions", "address", id) && + !isIdUsed(db, "thread", "recipient_ids", id) && + !isIdUsed(db, "sms", "address", id) && + !isIdUsed(db, "mms", "address", id) && + !isIdUsed(db, "mms", "quote_author", id) && + !isIdUsed(db, "group_receipts", "address", id) && + !isIdUsed(db, "groups", "recipient_id", id)) + { + Log.i(TAG, "Determined ID " + id + " is unused in non-group membership. Marking for potential deletion."); + deletionCandidates.add(id); + } else { + Log.i(TAG, "Found that ID " + id + " is actually used in another table."); + } + } + } + } + + Set deletions = findUnusedInGroupMembership(db, deletionCandidates); + + for (String deletion : deletions) { + Log.i(TAG, "Deleting ID " + deletion); + db.delete("recipient", "_id = ?", new String[] { String.valueOf(deletion) }); + } + + Log.i(TAG, "Migration took " + (System.currentTimeMillis() - startTime) + " ms."); + } + + private static boolean isIdUsed(@NonNull SQLiteDatabase db, @NonNull String tableName, @NonNull String columnName, String id) { + try (Cursor cursor = db.query(tableName, new String[] { columnName }, columnName + " = ?", new String[] { id }, null, null, null, "1")) { + boolean used = cursor != null && cursor.moveToFirst(); + if (used) { + Log.i(TAG, "Recipient " + id + " was used in (" + tableName + ", " + columnName + ")"); + } + return used; + } + } + + private static Set findUnusedInGroupMembership(@NonNull SQLiteDatabase db, Set candidates) { + Set unused = new HashSet<>(candidates); + + try (Cursor cursor = db.rawQuery("SELECT members FROM groups", null)) { + while (cursor != null && cursor.moveToNext()) { + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members")); + String[] members = DelimiterUtil.split(serializedMembers, ','); + + for (String member : members) { + if (unused.remove(member)) { + Log.i(TAG, "Recipient " + member + " was found in a group membership list."); + } + } + } + } + + return unused; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java new file mode 100644 index 00000000..f1bf305e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java @@ -0,0 +1,282 @@ +package org.thoughtcrime.securesms.database.helpers; + +import android.content.ContentValues; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.util.DelimiterUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.HashSet; +import java.util.Set; + +public class RecipientIdMigrationHelper { + + private static final String TAG = Log.tag(RecipientIdMigrationHelper.class); + + public static void execute(SQLiteDatabase db) { + Log.i(TAG, "Starting the recipient ID migration."); + + long insertStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting inserts for missing recipients."); + db.execSQL(buildInsertMissingRecipientStatement("identities", "address")); + db.execSQL(buildInsertMissingRecipientStatement("sessions", "address")); + db.execSQL(buildInsertMissingRecipientStatement("thread", "recipient_ids")); + db.execSQL(buildInsertMissingRecipientStatement("sms", "address")); + db.execSQL(buildInsertMissingRecipientStatement("mms", "address")); + db.execSQL(buildInsertMissingRecipientStatement("mms", "quote_author")); + db.execSQL(buildInsertMissingRecipientStatement("group_receipts", "address")); + db.execSQL(buildInsertMissingRecipientStatement("groups", "group_id")); + Log.i(TAG, "Finished inserts for missing recipients in " + (System.currentTimeMillis() - insertStart) + " ms."); + + long updateMissingStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting updates for invalid or missing addresses."); + db.execSQL(buildMissingAddressUpdateStatement("sms", "address")); + db.execSQL(buildMissingAddressUpdateStatement("mms", "address")); + db.execSQL(buildMissingAddressUpdateStatement("mms", "quote_author")); + Log.i(TAG, "Finished updates for invalid or missing addresses in " + (System.currentTimeMillis() - updateMissingStart) + " ms."); + + db.execSQL("ALTER TABLE groups ADD COLUMN recipient_id INTEGER DEFAULT 0"); + + long updateStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting recipient ID updates."); + db.execSQL(buildUpdateAddressToRecipientIdStatement("identities", "address")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("sessions", "address")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("thread", "recipient_ids")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("sms", "address")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("mms", "address")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("mms", "quote_author")); + db.execSQL(buildUpdateAddressToRecipientIdStatement("group_receipts", "address")); + db.execSQL("UPDATE groups SET recipient_id = (SELECT _id FROM recipient_preferences WHERE recipient_preferences.recipient_ids = groups.group_id)"); + Log.i(TAG, "Finished recipient ID updates in " + (System.currentTimeMillis() - updateStart) + " ms."); + + // NOTE: Because there's an open cursor on the same table, inserts and updates aren't visible + // until afterwards, which is why this group stuff is split into multiple loops + + long findGroupStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting to find missing group recipients."); + Set missingGroupMembers = new HashSet<>(); + + try (Cursor cursor = db.rawQuery("SELECT members FROM groups", null)) { + while (cursor != null && cursor.moveToNext()) { + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members")); + String[] members = DelimiterUtil.split(serializedMembers, ','); + + for (String rawMember : members) { + String member = DelimiterUtil.unescape(rawMember, ','); + + if (!TextUtils.isEmpty(member) && !recipientExists(db, member)) { + missingGroupMembers.add(member); + } + } + } + } + Log.i(TAG, "Finished finding " + missingGroupMembers.size() + " missing group recipients in " + (System.currentTimeMillis() - findGroupStart) + " ms."); + + long insertGroupStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting the insert of missing group recipients."); + for (String member : missingGroupMembers) { + ContentValues values = new ContentValues(); + values.put("recipient_ids", member); + db.insert("recipient_preferences", null, values); + } + Log.i(TAG, "Finished inserting missing group recipients in " + (System.currentTimeMillis() - insertGroupStart) + " ms."); + + long updateGroupStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting group recipient ID updates."); + try (Cursor cursor = db.rawQuery("SELECT _id, members FROM groups", null)) { + while (cursor != null && cursor.moveToNext()) { + long groupId = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members")); + String[] members = DelimiterUtil.split(serializedMembers, ','); + long[] memberIds = new long[members.length]; + + for (int i = 0; i < members.length; i++) { + String member = DelimiterUtil.unescape(members[i], ','); + memberIds[i] = requireRecipientId(db, member); + } + + String serializedMemberIds = Util.join(memberIds, ","); + + db.execSQL("UPDATE groups SET members = ? WHERE _id = ?", new String[]{ serializedMemberIds, String.valueOf(groupId) }); + } + } + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON groups (recipient_id)"); + Log.i(TAG, "Finished group recipient ID updates in " + (System.currentTimeMillis() - updateGroupStart) + " ms."); + + + long tableCopyStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting to copy the recipient table."); + db.execSQL("CREATE TABLE recipient (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "uuid TEXT UNIQUE DEFAULT NULL, " + + "phone TEXT UNIQUE DEFAULT NULL, " + + "email TEXT UNIQUE DEFAULT NULL, " + + "group_id TEXT UNIQUE DEFAULT NULL, " + + "blocked INTEGER DEFAULT 0, " + + "message_ringtone TEXT DEFAULT NULL, " + + "message_vibrate INTEGER DEFAULT 0, " + + "call_ringtone TEXT DEFAULT NULL, " + + "call_vibrate INTEGER DEFAULT 0, " + + "notification_channel TEXT DEFAULT NULL, " + + "mute_until INTEGER DEFAULT 0, " + + "color TEXT DEFAULT NULL, " + + "seen_invite_reminder INTEGER DEFAULT 0, " + + "default_subscription_id INTEGER DEFAULT -1, " + + "message_expiration_time INTEGER DEFAULT 0, " + + "registered INTEGER DEFAULT 0, " + + "system_display_name TEXT DEFAULT NULL, " + + "system_photo_uri TEXT DEFAULT NULL, " + + "system_phone_label TEXT DEFAULT NULL, " + + "system_contact_uri TEXT DEFAULT NULL, " + + "profile_key TEXT DEFAULT NULL, " + + "signal_profile_name TEXT DEFAULT NULL, " + + "signal_profile_avatar TEXT DEFAULT NULL, " + + "profile_sharing INTEGER DEFAULT 0, " + + "unidentified_access_mode INTEGER DEFAULT 0, " + + "force_sms_selection INTEGER DEFAULT 0)"); + + try (Cursor cursor = db.query("recipient_preferences", null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String address = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); + boolean isGroup = GroupId.isEncodedGroup(address); + boolean isEmail = !isGroup && NumberUtil.isValidEmail(address); + boolean isPhone = !isGroup && !isEmail; + + ContentValues values = new ContentValues(); + + values.put("_id", cursor.getLong(cursor.getColumnIndexOrThrow("_id"))); + values.put("uuid", (String) null); + values.put("phone", isPhone ? address : null); + values.put("email", isEmail ? address : null); + values.put("group_id", isGroup ? address : null); + values.put("blocked", cursor.getInt(cursor.getColumnIndexOrThrow("block"))); + values.put("message_ringtone", cursor.getString(cursor.getColumnIndexOrThrow("notification"))); + values.put("message_vibrate", cursor.getString(cursor.getColumnIndexOrThrow("vibrate"))); + values.put("call_ringtone", cursor.getString(cursor.getColumnIndexOrThrow("call_ringtone"))); + values.put("call_vibrate", cursor.getString(cursor.getColumnIndexOrThrow("call_vibrate"))); + values.put("notification_channel", cursor.getString(cursor.getColumnIndexOrThrow("notification_channel"))); + values.put("mute_until", cursor.getLong(cursor.getColumnIndexOrThrow("mute_until"))); + values.put("color", cursor.getString(cursor.getColumnIndexOrThrow("color"))); + values.put("seen_invite_reminder", cursor.getInt(cursor.getColumnIndexOrThrow("seen_invite_reminder"))); + values.put("default_subscription_id", cursor.getInt(cursor.getColumnIndexOrThrow("default_subscription_id"))); + values.put("message_expiration_time", cursor.getInt(cursor.getColumnIndexOrThrow("expire_messages"))); + values.put("registered", cursor.getInt(cursor.getColumnIndexOrThrow("registered"))); + values.put("system_display_name", cursor.getString(cursor.getColumnIndexOrThrow("system_display_name"))); + values.put("system_photo_uri", cursor.getString(cursor.getColumnIndexOrThrow("system_contact_photo"))); + values.put("system_phone_label", cursor.getString(cursor.getColumnIndexOrThrow("system_phone_label"))); + values.put("system_contact_uri", cursor.getString(cursor.getColumnIndexOrThrow("system_contact_uri"))); + values.put("profile_key", cursor.getString(cursor.getColumnIndexOrThrow("profile_key"))); + values.put("signal_profile_name", cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_name"))); + values.put("signal_profile_avatar", cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_avatar"))); + values.put("profile_sharing", cursor.getInt(cursor.getColumnIndexOrThrow("profile_sharing_approval"))); + values.put("unidentified_access_mode", cursor.getInt(cursor.getColumnIndexOrThrow("unidentified_access_mode"))); + values.put("force_sms_selection", cursor.getInt(cursor.getColumnIndexOrThrow("force_sms_selection"))); + + db.insert("recipient", null, values); + } + } + + db.execSQL("DROP TABLE recipient_preferences"); + Log.i(TAG, "Finished copying the recipient table in " + (System.currentTimeMillis() - tableCopyStart) + " ms."); + + long sanityCheckStart = System.currentTimeMillis(); + + Log.i(TAG, "Starting DB integrity sanity checks."); + assertEmptyQuery(db, "identities", buildSanityCheckQuery("identities", "address")); + assertEmptyQuery(db, "sessions", buildSanityCheckQuery("sessions", "address")); + assertEmptyQuery(db, "groups", buildSanityCheckQuery("groups", "recipient_id")); + assertEmptyQuery(db, "thread", buildSanityCheckQuery("thread", "recipient_ids")); + assertEmptyQuery(db, "sms", buildSanityCheckQuery("sms", "address")); + assertEmptyQuery(db, "mms -- address", buildSanityCheckQuery("mms", "address")); + assertEmptyQuery(db, "mms -- quote_author", buildSanityCheckQuery("mms", "quote_author")); + assertEmptyQuery(db, "group_receipts", buildSanityCheckQuery("group_receipts", "address")); + Log.i(TAG, "Finished DB integrity sanity checks in " + (System.currentTimeMillis() - sanityCheckStart) + " ms."); + + Log.i(TAG, "Finished recipient ID migration in " + (System.currentTimeMillis() - insertStart) + " ms."); + } + + private static String buildUpdateAddressToRecipientIdStatement(@NonNull String table, @NonNull String addressColumn) { + return "UPDATE " + table + " SET " + addressColumn + "=(SELECT _id " + + "FROM recipient_preferences " + + "WHERE recipient_preferences.recipient_ids = " + table + "." + addressColumn + ")"; + } + + private static String buildInsertMissingRecipientStatement(@NonNull String table, @NonNull String addressColumn) { + return "INSERT INTO recipient_preferences(recipient_ids) SELECT DISTINCT " + addressColumn + " " + + "FROM " + table + " " + + "WHERE " + addressColumn + " != '' AND " + + addressColumn + " != 'insert-address-column' AND " + + addressColumn + " NOT NULL AND " + + addressColumn + " NOT IN (SELECT recipient_ids FROM recipient_preferences)"; + } + + private static String buildMissingAddressUpdateStatement(@NonNull String table, @NonNull String addressColumn) { + return "UPDATE " + table + " SET " + addressColumn + " = -1 " + + "WHERE " + addressColumn + " = '' OR " + + addressColumn + " IS NULL OR " + + addressColumn + " = 'insert-address-token'"; + } + + private static boolean recipientExists(@NonNull SQLiteDatabase db, @NonNull String address) { + return getRecipientId(db, address) != null; + } + + private static @Nullable Long getRecipientId(@NonNull SQLiteDatabase db, @NonNull String address) { + try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient_preferences WHERE recipient_ids = ?", new String[]{ address })) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + } else { + return null; + } + } + } + + private static long requireRecipientId(@NonNull SQLiteDatabase db, @NonNull String address) { + Long id = getRecipientId(db, address); + + if (id != null) { + return id; + } else { + throw new MissingRecipientError(address); + } + } + + private static String buildSanityCheckQuery(@NonNull String table, @NonNull String idColumn) { + return "SELECT " + idColumn + " FROM " + table + " WHERE " + idColumn + " != -1 AND " + idColumn + " NOT IN (SELECT _id FROM recipient)"; + } + + private static void assertEmptyQuery(@NonNull SQLiteDatabase db, @NonNull String tag, @NonNull String query) { + try (Cursor cursor = db.rawQuery(query, null)) { + if (cursor != null && cursor.moveToFirst()) { + throw new FailedSanityCheckError(tag); + } + } + } + + private static final class MissingRecipientError extends AssertionError { + MissingRecipientError(@NonNull String address) { + super("Could not find recipient with address " + address); + } + } + + private static final class FailedSanityCheckError extends AssertionError { + FailedSanityCheckError(@NonNull String tableName) { + super("Sanity check failed for tag '" + tableName + "'"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherMigrationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherMigrationHelper.java new file mode 100644 index 00000000..b08de29c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherMigrationHelper.java @@ -0,0 +1,255 @@ +package org.thoughtcrime.securesms.database.helpers; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.function.BiFunction; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.AsymmetricMasterCipher; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.MasterCipher; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidMessageException; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +public class SQLCipherMigrationHelper { + + private static final String TAG = SQLCipherMigrationHelper.class.getSimpleName(); + + private static final long ENCRYPTION_SYMMETRIC_BIT = 0x80000000; + private static final long ENCRYPTION_ASYMMETRIC_BIT = 0x40000000; + + static void migratePlaintext(@NonNull Context context, + @NonNull android.database.sqlite.SQLiteDatabase legacyDb, + @NonNull net.sqlcipher.database.SQLiteDatabase modernDb) + { + modernDb.beginTransaction(); + int foregroundId = GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database)).getId(); + try { + copyTable("identities", legacyDb, modernDb, null); + copyTable("push", legacyDb, modernDb, null); + copyTable("groups", legacyDb, modernDb, null); + copyTable("recipient_preferences", legacyDb, modernDb, null); + copyTable("group_receipts", legacyDb, modernDb, null); + modernDb.setTransactionSuccessful(); + } finally { + modernDb.endTransaction(); + GenericForegroundService.stopForegroundTask(context, foregroundId); + } + } + + public static void migrateCiphertext(@NonNull Context context, + @NonNull MasterSecret masterSecret, + @NonNull android.database.sqlite.SQLiteDatabase legacyDb, + @NonNull net.sqlcipher.database.SQLiteDatabase modernDb, + @Nullable LegacyMigrationJob.DatabaseUpgradeListener listener) + { + MasterCipher legacyCipher = new MasterCipher(masterSecret); + AsymmetricMasterCipher legacyAsymmetricCipher = new AsymmetricMasterCipher(MasterSecretUtil.getAsymmetricMasterSecret(context, masterSecret)); + + modernDb.beginTransaction(); + + int foregroundId = GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database)).getId(); + try { + int total = 5000; + + copyTable("sms", legacyDb, modernDb, (row, progress) -> { + Pair plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher, + row.getAsLong("type"), + row.getAsString("body")); + + row.put("body", plaintext.second); + row.put("type", plaintext.first); + + if (listener != null && (progress.first % 1000 == 0)) { + listener.setProgress(getTotalProgress(0, progress.first, progress.second), total); + } + + return row; + }); + + copyTable("mms", legacyDb, modernDb, (row, progress) -> { + Pair plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher, + row.getAsLong("msg_box"), + row.getAsString("body")); + + row.put("body", plaintext.second); + row.put("msg_box", plaintext.first); + + if (listener != null && (progress.first % 1000 == 0)) { + listener.setProgress(getTotalProgress(1000, progress.first, progress.second), total); + } + + return row; + }); + + copyTable("part", legacyDb, modernDb, (row, progress) -> { + String fileName = row.getAsString("file_name"); + String mediaKey = row.getAsString("cd"); + + try { + if (!TextUtils.isEmpty(fileName)) { + row.put("file_name", legacyCipher.decryptBody(fileName)); + } + } catch (InvalidMessageException e) { + Log.w(TAG, e); + } + + try { + if (!TextUtils.isEmpty(mediaKey)) { + byte[] plaintext; + + if (mediaKey.startsWith("?ASYNC-")) { + plaintext = legacyAsymmetricCipher.decryptBytes(Base64.decode(mediaKey.substring("?ASYNC-".length()))); + } else { + plaintext = legacyCipher.decryptBytes(Base64.decode(mediaKey)); + } + + row.put("cd", Base64.encodeBytes(plaintext)); + } + } catch (IOException | InvalidMessageException e) { + Log.w(TAG, e); + } + + if (listener != null && (progress.first % 1000 == 0)) { + listener.setProgress(getTotalProgress(2000, progress.first, progress.second), total); + } + + return row; + }); + + copyTable("thread", legacyDb, modernDb, (row, progress) -> { + Long snippetType = row.getAsLong("snippet_type"); + if (snippetType == null) snippetType = 0L; + + Pair plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher, + snippetType, row.getAsString("snippet")); + + row.put("snippet", plaintext.second); + row.put("snippet_type", plaintext.first); + + if (listener != null && (progress.first % 1000 == 0)) { + listener.setProgress(getTotalProgress(3000, progress.first, progress.second), total); + } + + return row; + }); + + + copyTable("drafts", legacyDb, modernDb, (row, progress) -> { + String draftType = row.getAsString("type"); + String draft = row.getAsString("value"); + + try { + if (!TextUtils.isEmpty(draftType)) row.put("type", legacyCipher.decryptBody(draftType)); + if (!TextUtils.isEmpty(draft)) row.put("value", legacyCipher.decryptBody(draft)); + } catch (InvalidMessageException e) { + Log.w(TAG, e); + } + + if (listener != null && (progress.first % 1000 == 0)) { + listener.setProgress(getTotalProgress(4000, progress.first, progress.second), total); + } + + return row; + }); + + AttachmentSecretProvider.getInstance(context).setClassicKey(context, masterSecret.getEncryptionKey().getEncoded(), masterSecret.getMacKey().getEncoded()); + TextSecurePreferences.setNeedsSqlCipherMigration(context, false); + modernDb.setTransactionSuccessful(); + } finally { + modernDb.endTransaction(); + GenericForegroundService.stopForegroundTask(context, foregroundId); + } + } + + private static void copyTable(@NonNull String tableName, + @NonNull android.database.sqlite.SQLiteDatabase legacyDb, + @NonNull net.sqlcipher.database.SQLiteDatabase modernDb, + @Nullable BiFunction, ContentValues> transformer) + { + Set destinationColumns = getTableColumns(tableName, modernDb); + + try (Cursor cursor = legacyDb.query(tableName, null, null, null, null, null, null)) { + int count = (cursor != null) ? cursor.getCount() : 0; + int progress = 1; + + while (cursor != null && cursor.moveToNext()) { + ContentValues row = new ContentValues(); + + for (int i=0;i(progress++, count)); + } + + modernDb.insert(tableName, null, row); + } + } + } + + private static Pair getPlaintextBody(@NonNull MasterCipher legacyCipher, + @NonNull AsymmetricMasterCipher legacyAsymmetricCipher, + long type, + @Nullable String body) + { + try { + if (!TextUtils.isEmpty(body)) { + if ((type & ENCRYPTION_SYMMETRIC_BIT) != 0) body = legacyCipher.decryptBody(body); + else if ((type & ENCRYPTION_ASYMMETRIC_BIT) != 0) body = legacyAsymmetricCipher.decryptBody(body); + } + } catch (InvalidMessageException | IOException e) { + Log.w(TAG, e); + } + + type &= ~(ENCRYPTION_SYMMETRIC_BIT); + type &= ~(ENCRYPTION_ASYMMETRIC_BIT); + + return new Pair<>(type, body); + } + + private static Set getTableColumns(String tableName, net.sqlcipher.database.SQLiteDatabase database) { + Set results = new HashSet<>(); + + try (Cursor cursor = database.rawQuery("PRAGMA table_info(" + tableName + ")", null)) { + while (cursor != null && cursor.moveToNext()) { + results.add(cursor.getString(1)); + } + } + + return results; + } + + private static int getTotalProgress(int sectionOffset, int sectionProgress, int sectionTotal) { + double percentOfSectionComplete = ((double)sectionProgress) / ((double)sectionTotal); + return sectionOffset + (int)(((double)1000) * percentOfSectionComplete); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java new file mode 100644 index 00000000..6bc81894 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -0,0 +1,1302 @@ +package org.thoughtcrime.securesms.database.helpers; + + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.SystemClock; +import android.preference.PreferenceManager; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.bumptech.glide.Glide; + +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteDatabaseHook; +import net.sqlcipher.database.SQLiteOpenHelper; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DraftDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.database.MegaphoneDatabase; +import org.thoughtcrime.securesms.database.MentionDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RemappedRecordsDatabase; +import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.SessionDatabase; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.SignedPreKeyDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.SqlCipherDatabaseHook; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.StorageKeyDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Triple; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatabase { + + @SuppressWarnings("unused") + private static final String TAG = SQLCipherOpenHelper.class.getSimpleName(); + + private static final int RECIPIENT_CALL_RINGTONE_VERSION = 2; + private static final int MIGRATE_PREKEYS_VERSION = 3; + private static final int MIGRATE_SESSIONS_VERSION = 4; + private static final int NO_MORE_IMAGE_THUMBNAILS_VERSION = 5; + private static final int ATTACHMENT_DIMENSIONS = 6; + private static final int QUOTED_REPLIES = 7; + private static final int SHARED_CONTACTS = 8; + private static final int FULL_TEXT_SEARCH = 9; + private static final int BAD_IMPORT_CLEANUP = 10; + private static final int QUOTE_MISSING = 11; + private static final int NOTIFICATION_CHANNELS = 12; + private static final int SECRET_SENDER = 13; + private static final int ATTACHMENT_CAPTIONS = 14; + private static final int ATTACHMENT_CAPTIONS_FIX = 15; + private static final int PREVIEWS = 16; + private static final int CONVERSATION_SEARCH = 17; + private static final int SELF_ATTACHMENT_CLEANUP = 18; + private static final int RECIPIENT_FORCE_SMS_SELECTION = 19; + private static final int JOBMANAGER_STRIKES_BACK = 20; + private static final int STICKERS = 21; + private static final int REVEALABLE_MESSAGES = 22; + private static final int VIEW_ONCE_ONLY = 23; + private static final int RECIPIENT_IDS = 24; + private static final int RECIPIENT_SEARCH = 25; + private static final int RECIPIENT_CLEANUP = 26; + private static final int MMS_RECIPIENT_CLEANUP = 27; + private static final int ATTACHMENT_HASHING = 28; + private static final int NOTIFICATION_RECIPIENT_IDS = 29; + private static final int BLUR_HASH = 30; + private static final int MMS_RECIPIENT_CLEANUP_2 = 31; + private static final int ATTACHMENT_TRANSFORM_PROPERTIES = 32; + private static final int ATTACHMENT_CLEAR_HASHES = 33; + private static final int ATTACHMENT_CLEAR_HASHES_2 = 34; + private static final int UUIDS = 35; + private static final int USERNAMES = 36; + private static final int REACTIONS = 37; + private static final int STORAGE_SERVICE = 38; + private static final int REACTIONS_UNREAD_INDEX = 39; + private static final int RESUMABLE_DOWNLOADS = 40; + private static final int KEY_VALUE_STORE = 41; + private static final int ATTACHMENT_DISPLAY_ORDER = 42; + private static final int SPLIT_PROFILE_NAMES = 43; + private static final int STICKER_PACK_ORDER = 44; + private static final int MEGAPHONES = 45; + private static final int MEGAPHONE_FIRST_APPEARANCE = 46; + private static final int PROFILE_KEY_TO_DB = 47; + private static final int PROFILE_KEY_CREDENTIALS = 48; + private static final int ATTACHMENT_FILE_INDEX = 49; + private static final int STORAGE_SERVICE_ACTIVE = 50; + private static final int GROUPS_V2_RECIPIENT_CAPABILITY = 51; + private static final int TRANSFER_FILE_CLEANUP = 52; + private static final int PROFILE_DATA_MIGRATION = 53; + private static final int AVATAR_LOCATION_MIGRATION = 54; + private static final int GROUPS_V2 = 55; + private static final int ATTACHMENT_UPLOAD_TIMESTAMP = 56; + private static final int ATTACHMENT_CDN_NUMBER = 57; + private static final int JOB_INPUT_DATA = 58; + private static final int SERVER_TIMESTAMP = 59; + private static final int REMOTE_DELETE = 60; + private static final int COLOR_MIGRATION = 61; + private static final int LAST_SCROLLED = 62; + private static final int LAST_PROFILE_FETCH = 63; + private static final int SERVER_DELIVERED_TIMESTAMP = 64; + private static final int QUOTE_CLEANUP = 65; + private static final int BORDERLESS = 66; + private static final int REMAPPED_RECORDS = 67; + private static final int MENTIONS = 68; + private static final int PINNED_CONVERSATIONS = 69; + private static final int MENTION_GLOBAL_SETTING_MIGRATION = 70; + private static final int UNKNOWN_STORAGE_FIELDS = 71; + private static final int STICKER_CONTENT_TYPE = 72; + private static final int STICKER_EMOJI_IN_NOTIFICATIONS = 73; + private static final int THUMBNAIL_CLEANUP = 74; + private static final int STICKER_CONTENT_TYPE_CLEANUP = 75; + private static final int MENTION_CLEANUP = 76; + private static final int MENTION_CLEANUP_V2 = 77; + private static final int REACTION_CLEANUP = 78; + private static final int CAPABILITIES_REFACTOR = 79; + private static final int GV1_MIGRATION = 80; + private static final int NOTIFIED_TIMESTAMP = 81; + private static final int GV1_MIGRATION_LAST_SEEN = 82; + private static final int VIEWED_RECEIPTS = 83; + private static final int CLEAN_UP_GV1_IDS = 84; + private static final int GV1_MIGRATION_REFACTOR = 85; + private static final int CLEAR_PROFILE_KEY_CREDENTIALS = 86; + private static final int LAST_RESET_SESSION_TIME = 87; + private static final int WALLPAPER = 88; + private static final int ABOUT = 89; + + private static final int DATABASE_VERSION = 89; + private static final String DATABASE_NAME = "signal.db"; + + private final Context context; + private final DatabaseSecret databaseSecret; + + public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) { + super(context, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook()); + + this.context = context.getApplicationContext(); + this.databaseSecret = databaseSecret; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SmsDatabase.CREATE_TABLE); + db.execSQL(MmsDatabase.CREATE_TABLE); + db.execSQL(AttachmentDatabase.CREATE_TABLE); + db.execSQL(ThreadDatabase.CREATE_TABLE); + db.execSQL(IdentityDatabase.CREATE_TABLE); + db.execSQL(DraftDatabase.CREATE_TABLE); + db.execSQL(PushDatabase.CREATE_TABLE); + db.execSQL(GroupDatabase.CREATE_TABLE); + db.execSQL(RecipientDatabase.CREATE_TABLE); + db.execSQL(GroupReceiptDatabase.CREATE_TABLE); + db.execSQL(OneTimePreKeyDatabase.CREATE_TABLE); + db.execSQL(SignedPreKeyDatabase.CREATE_TABLE); + db.execSQL(SessionDatabase.CREATE_TABLE); + db.execSQL(StickerDatabase.CREATE_TABLE); + db.execSQL(StorageKeyDatabase.CREATE_TABLE); + db.execSQL(MentionDatabase.CREATE_TABLE); + executeStatements(db, SearchDatabase.CREATE_TABLE); + executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); + + executeStatements(db, RecipientDatabase.CREATE_INDEXS); + executeStatements(db, SmsDatabase.CREATE_INDEXS); + executeStatements(db, MmsDatabase.CREATE_INDEXS); + executeStatements(db, AttachmentDatabase.CREATE_INDEXS); + executeStatements(db, ThreadDatabase.CREATE_INDEXS); + executeStatements(db, DraftDatabase.CREATE_INDEXS); + executeStatements(db, GroupDatabase.CREATE_INDEXS); + executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); + executeStatements(db, StickerDatabase.CREATE_INDEXES); + executeStatements(db, StorageKeyDatabase.CREATE_INDEXES); + executeStatements(db, MentionDatabase.CREATE_INDEXES); + + if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) { + ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context); + android.database.sqlite.SQLiteDatabase legacyDb = legacyHelper.getWritableDatabase(); + + SQLCipherMigrationHelper.migratePlaintext(context, legacyDb, db); + + MasterSecret masterSecret = KeyCachingService.getMasterSecret(context); + + if (masterSecret != null) SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret, legacyDb, db, null); + else TextSecurePreferences.setNeedsSqlCipherMigration(context, true); + + if (!PreKeyMigrationHelper.migratePreKeys(context, db)) { + ApplicationDependencies.getJobManager().add(new RefreshPreKeysJob()); + } + + SessionStoreMigrationHelper.migrateSessions(context, db); + PreKeyMigrationHelper.cleanUpPreKeys(context); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.i(TAG, "Upgrading database: " + oldVersion + ", " + newVersion); + long startTime = System.currentTimeMillis(); + + db.beginTransaction(); + + try { + + if (oldVersion < RECIPIENT_CALL_RINGTONE_VERSION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN call_ringtone TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN call_vibrate INTEGER DEFAULT " + RecipientDatabase.VibrateState.DEFAULT.getId()); + } + + if (oldVersion < MIGRATE_PREKEYS_VERSION) { + db.execSQL("CREATE TABLE signed_prekeys (_id INTEGER PRIMARY KEY, key_id INTEGER UNIQUE, public_key TEXT NOT NULL, private_key TEXT NOT NULL, signature TEXT NOT NULL, timestamp INTEGER DEFAULT 0)"); + db.execSQL("CREATE TABLE one_time_prekeys (_id INTEGER PRIMARY KEY, key_id INTEGER UNIQUE, public_key TEXT NOT NULL, private_key TEXT NOT NULL)"); + + if (!PreKeyMigrationHelper.migratePreKeys(context, db)) { + ApplicationDependencies.getJobManager().add(new RefreshPreKeysJob()); + } + } + + if (oldVersion < MIGRATE_SESSIONS_VERSION) { + db.execSQL("CREATE TABLE sessions (_id INTEGER PRIMARY KEY, address TEXT NOT NULL, device INTEGER NOT NULL, record BLOB NOT NULL, UNIQUE(address, device) ON CONFLICT REPLACE)"); + SessionStoreMigrationHelper.migrateSessions(context, db); + } + + if (oldVersion < NO_MORE_IMAGE_THUMBNAILS_VERSION) { + ContentValues update = new ContentValues(); + update.put("thumbnail", (String)null); + update.put("aspect_ratio", (String)null); + update.put("thumbnail_random", (String)null); + + try (Cursor cursor = db.query("part", new String[] {"_id", "ct", "thumbnail"}, "thumbnail IS NOT NULL", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + String contentType = cursor.getString(cursor.getColumnIndexOrThrow("ct")); + + if (contentType != null && !contentType.startsWith("video")) { + String thumbnailPath = cursor.getString(cursor.getColumnIndexOrThrow("thumbnail")); + File thumbnailFile = new File(thumbnailPath); + thumbnailFile.delete(); + + db.update("part", update, "_id = ?", new String[] {String.valueOf(id)}); + } + } + } + } + + if (oldVersion < ATTACHMENT_DIMENSIONS) { + db.execSQL("ALTER TABLE part ADD COLUMN width INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE part ADD COLUMN height INTEGER DEFAULT 0"); + } + + if (oldVersion < QUOTED_REPLIES) { + db.execSQL("ALTER TABLE mms ADD COLUMN quote_id INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN quote_author TEXT"); + db.execSQL("ALTER TABLE mms ADD COLUMN quote_body TEXT"); + db.execSQL("ALTER TABLE mms ADD COLUMN quote_attachment INTEGER DEFAULT -1"); + + db.execSQL("ALTER TABLE part ADD COLUMN quote INTEGER DEFAULT 0"); + } + + if (oldVersion < SHARED_CONTACTS) { + db.execSQL("ALTER TABLE mms ADD COLUMN shared_contacts TEXT"); + } + + if (oldVersion < FULL_TEXT_SEARCH) { + db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, content=sms, content_rowid=_id)"); + db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" + + " INSERT INTO sms_fts(rowid, body) VALUES (new._id, new.body);\n" + + "END;"); + db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" + + " INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" + + "END;\n"); + db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" + + " INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" + + " INSERT INTO sms_fts(rowid, body) VALUES(new._id, new.body);\n" + + "END;"); + + db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, content=mms, content_rowid=_id)"); + db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" + + " INSERT INTO mms_fts(rowid, body) VALUES (new._id, new.body);\n" + + "END;"); + db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" + + " INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" + + "END;\n"); + db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" + + " INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" + + " INSERT INTO mms_fts(rowid, body) VALUES(new._id, new.body);\n" + + "END;"); + + Log.i(TAG, "Beginning to build search index."); + long start = SystemClock.elapsedRealtime(); + + db.execSQL("INSERT INTO sms_fts (rowid, body) SELECT _id, body FROM sms"); + + long smsFinished = SystemClock.elapsedRealtime(); + Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms"); + + db.execSQL("INSERT INTO mms_fts (rowid, body) SELECT _id, body FROM mms"); + + long mmsFinished = SystemClock.elapsedRealtime(); + Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms"); + Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms"); + } + + if (oldVersion < BAD_IMPORT_CLEANUP) { + String trimmedCondition = " NOT IN (SELECT _id FROM mms)"; + + db.delete("group_receipts", "mms_id" + trimmedCondition, null); + + String[] columns = new String[] { "_id", "unique_id", "_data", "thumbnail"}; + + try (Cursor cursor = db.query("part", columns, "mid" + trimmedCondition, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + db.delete("part", "_id = ? AND unique_id = ?", new String[] { String.valueOf(cursor.getLong(0)), String.valueOf(cursor.getLong(1)) }); + + String data = cursor.getString(2); + String thumbnail = cursor.getString(3); + + if (!TextUtils.isEmpty(data)) { + new File(data).delete(); + } + + if (!TextUtils.isEmpty(thumbnail)) { + new File(thumbnail).delete(); + } + } + } + } + + // Note: This column only being checked due to upgrade issues as described in #8184 + if (oldVersion < QUOTE_MISSING && !SqlUtil.columnExists(db, "mms", "quote_missing")) { + db.execSQL("ALTER TABLE mms ADD COLUMN quote_missing INTEGER DEFAULT 0"); + } + + // Note: The column only being checked due to upgrade issues as described in #8184 + if (oldVersion < NOTIFICATION_CHANNELS && !SqlUtil.columnExists(db, "recipient_preferences", "notification_channel")) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN notification_channel TEXT DEFAULT NULL"); + NotificationChannels.create(context); + + try (Cursor cursor = db.rawQuery("SELECT recipient_ids, system_display_name, signal_profile_name, notification, vibrate FROM recipient_preferences WHERE notification NOT NULL OR vibrate != 0", null)) { + while (cursor != null && cursor.moveToNext()) { + String rawAddress = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); + String address = PhoneNumberFormatter.get(context).format(rawAddress); + String systemName = cursor.getString(cursor.getColumnIndexOrThrow("system_display_name")); + String profileName = cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_name")); + String messageSound = cursor.getString(cursor.getColumnIndexOrThrow("notification")); + Uri messageSoundUri = messageSound != null ? Uri.parse(messageSound) : null; + int vibrateState = cursor.getInt(cursor.getColumnIndexOrThrow("vibrate")); + String displayName = NotificationChannels.getChannelDisplayNameFor(context, systemName, profileName, null, address); + boolean vibrateEnabled = vibrateState == 0 ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == 1; + + if (GroupId.isEncodedGroup(address)) { + try(Cursor groupCursor = db.rawQuery("SELECT title FROM groups WHERE group_id = ?", new String[] { address })) { + if (groupCursor != null && groupCursor.moveToFirst()) { + String title = groupCursor.getString(groupCursor.getColumnIndexOrThrow("title")); + + if (!TextUtils.isEmpty(title)) { + displayName = title; + } + } + } + } + + String channelId = NotificationChannels.createChannelFor(context, "contact_" + address + "_" + System.currentTimeMillis(), displayName, messageSoundUri, vibrateEnabled); + + ContentValues values = new ContentValues(1); + values.put("notification_channel", channelId); + db.update("recipient_preferences", values, "recipient_ids = ?", new String[] { rawAddress }); + } + } + } + + if (oldVersion < SECRET_SENDER) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN unidentified_access_mode INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE push ADD COLUMN server_timestamp INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE push ADD COLUMN server_guid TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE group_receipts ADD COLUMN unidentified INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN unidentified INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE sms ADD COLUMN unidentified INTEGER DEFAULT 0"); + } + + if (oldVersion < ATTACHMENT_CAPTIONS) { + db.execSQL("ALTER TABLE part ADD COLUMN caption TEXT DEFAULT NULL"); + } + + // 4.30.8 included a migration, but not a correct CREATE_TABLE statement, so we need to add + // this column if it isn't present. + if (oldVersion < ATTACHMENT_CAPTIONS_FIX) { + if (!SqlUtil.columnExists(db, "part", "caption")) { + db.execSQL("ALTER TABLE part ADD COLUMN caption TEXT DEFAULT NULL"); + } + } + + if (oldVersion < PREVIEWS) { + db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT"); + } + + if (oldVersion < CONVERSATION_SEARCH) { + db.execSQL("DROP TABLE sms_fts"); + db.execSQL("DROP TABLE mms_fts"); + db.execSQL("DROP TRIGGER sms_ai"); + db.execSQL("DROP TRIGGER sms_au"); + db.execSQL("DROP TRIGGER sms_ad"); + db.execSQL("DROP TRIGGER mms_ai"); + db.execSQL("DROP TRIGGER mms_au"); + db.execSQL("DROP TRIGGER mms_ad"); + + db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, thread_id UNINDEXED, content=sms, content_rowid=_id)"); + db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" + + " INSERT INTO sms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" + + "END;"); + db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" + + " INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" + + "END;\n"); + db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" + + " INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" + + " INSERT INTO sms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" + + "END;"); + + db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, thread_id UNINDEXED, content=mms, content_rowid=_id)"); + db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" + + " INSERT INTO mms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" + + "END;"); + db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" + + " INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" + + "END;\n"); + db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" + + " INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" + + " INSERT INTO mms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" + + "END;"); + + Log.i(TAG, "Beginning to build search index."); + long start = SystemClock.elapsedRealtime(); + + db.execSQL("INSERT INTO sms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM sms"); + + long smsFinished = SystemClock.elapsedRealtime(); + Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms"); + + db.execSQL("INSERT INTO mms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM mms"); + + long mmsFinished = SystemClock.elapsedRealtime(); + Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms"); + Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms"); + } + + if (oldVersion < SELF_ATTACHMENT_CLEANUP) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + + if (!TextUtils.isEmpty(localNumber)) { + try (Cursor threadCursor = db.rawQuery("SELECT _id FROM thread WHERE recipient_ids = ?", new String[]{ localNumber })) { + if (threadCursor != null && threadCursor.moveToFirst()) { + long threadId = threadCursor.getLong(0); + ContentValues updateValues = new ContentValues(1); + + updateValues.put("pending_push", 0); + + int count = db.update("part", updateValues, "mid IN (SELECT _id FROM mms WHERE thread_id = ?)", new String[]{ String.valueOf(threadId) }); + Log.i(TAG, "Updated " + count + " self-sent attachments."); + } + } + } + } + + if (oldVersion < RECIPIENT_FORCE_SMS_SELECTION) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN force_sms_selection INTEGER DEFAULT 0"); + } + + if (oldVersion < JOBMANAGER_STRIKES_BACK) { + db.execSQL("CREATE TABLE job_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "job_spec_id TEXT UNIQUE, " + + "factory_key TEXT, " + + "queue_key TEXT, " + + "create_time INTEGER, " + + "next_run_attempt_time INTEGER, " + + "run_attempt INTEGER, " + + "max_attempts INTEGER, " + + "max_backoff INTEGER, " + + "max_instances INTEGER, " + + "lifespan INTEGER, " + + "serialized_data TEXT, " + + "is_running INTEGER)"); + + db.execSQL("CREATE TABLE constraint_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "job_spec_id TEXT, " + + "factory_key TEXT, " + + "UNIQUE(job_spec_id, factory_key))"); + + db.execSQL("CREATE TABLE dependency_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "job_spec_id TEXT, " + + "depends_on_job_spec_id TEXT, " + + "UNIQUE(job_spec_id, depends_on_job_spec_id))"); + } + + if (oldVersion < STICKERS) { + db.execSQL("CREATE TABLE sticker (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "pack_id TEXT NOT NULL, " + + "pack_key TEXT NOT NULL, " + + "pack_title TEXT NOT NULL, " + + "pack_author TEXT NOT NULL, " + + "sticker_id INTEGER, " + + "cover INTEGER, " + + "emoji TEXT NOT NULL, " + + "last_used INTEGER, " + + "installed INTEGER," + + "file_path TEXT NOT NULL, " + + "file_length INTEGER, " + + "file_random BLOB, " + + "UNIQUE(pack_id, sticker_id, cover) ON CONFLICT IGNORE)"); + + db.execSQL("CREATE INDEX IF NOT EXISTS sticker_pack_id_index ON sticker (pack_id);"); + db.execSQL("CREATE INDEX IF NOT EXISTS sticker_sticker_id_index ON sticker (sticker_id);"); + + db.execSQL("ALTER TABLE part ADD COLUMN sticker_pack_id TEXT"); + db.execSQL("ALTER TABLE part ADD COLUMN sticker_pack_key TEXT"); + db.execSQL("ALTER TABLE part ADD COLUMN sticker_id INTEGER DEFAULT -1"); + db.execSQL("CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON part (sticker_pack_id)"); + } + + if (oldVersion < REVEALABLE_MESSAGES) { + db.execSQL("ALTER TABLE mms ADD COLUMN reveal_duration INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN reveal_start_time INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_content_type TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE thread ADD COLUMN snippet_extras TEXT DEFAULT NULL"); + } + + if (oldVersion < VIEW_ONCE_ONLY) { + db.execSQL("UPDATE mms SET reveal_duration = 1 WHERE reveal_duration > 0"); + db.execSQL("UPDATE mms SET reveal_start_time = 0"); + } + + if (oldVersion < RECIPIENT_IDS) { + RecipientIdMigrationHelper.execute(db); + } + + if (oldVersion < RECIPIENT_SEARCH) { + db.execSQL("ALTER TABLE recipient ADD COLUMN system_phone_type INTEGER DEFAULT -1"); + + String localNumber = TextSecurePreferences.getLocalNumber(context); + if (!TextUtils.isEmpty(localNumber)) { + try (Cursor cursor = db.query("recipient", null, "phone = ?", new String[] { localNumber }, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + ContentValues values = new ContentValues(); + values.put("phone", localNumber); + values.put("registered", 1); + values.put("profile_sharing", 1); + db.insert("recipient", null, values); + } else { + db.execSQL("UPDATE recipient SET registered = ?, profile_sharing = ? WHERE phone = ?", + new String[] { "1", "1", localNumber }); + } + } + } + } + + if (oldVersion < RECIPIENT_CLEANUP) { + RecipientIdCleanupHelper.execute(db); + } + + if (oldVersion < MMS_RECIPIENT_CLEANUP) { + ContentValues values = new ContentValues(1); + values.put("address", "-1"); + int count = db.update("mms", values, "address = ?", new String[] { "0" }); + Log.i(TAG, "MMS recipient cleanup updated " + count + " rows."); + } + + if (oldVersion < ATTACHMENT_HASHING) { + db.execSQL("ALTER TABLE part ADD COLUMN data_hash TEXT DEFAULT NULL"); + db.execSQL("CREATE INDEX IF NOT EXISTS part_data_hash_index ON part (data_hash)"); + } + + if (oldVersion < NOTIFICATION_RECIPIENT_IDS && Build.VERSION.SDK_INT >= 26) { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + List channels = Stream.of(notificationManager.getNotificationChannels()) + .filter(c -> c.getId().startsWith("contact_")) + .toList(); + + Log.i(TAG, "Migrating " + channels.size() + " channels to use RecipientId's."); + + for (NotificationChannel oldChannel : channels) { + notificationManager.deleteNotificationChannel(oldChannel.getId()); + + int startIndex = "contact_".length(); + int endIndex = oldChannel.getId().lastIndexOf("_"); + String address = oldChannel.getId().substring(startIndex, endIndex); + + String recipientId; + + try (Cursor cursor = db.query("recipient", new String[] { "_id" }, "phone = ? OR email = ? OR group_id = ?", new String[] { address, address, address}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + recipientId = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + } else { + Log.w(TAG, "Couldn't find recipient for address: " + address); + continue; + } + } + + String newId = "contact_" + recipientId + "_" + System.currentTimeMillis(); + NotificationChannel newChannel = new NotificationChannel(newId, oldChannel.getName(), oldChannel.getImportance()); + + Log.i(TAG, "Updating channel ID from '" + oldChannel.getId() + "' to '" + newChannel.getId() + "'."); + + newChannel.setGroup(oldChannel.getGroup()); + newChannel.setSound(oldChannel.getSound(), oldChannel.getAudioAttributes()); + newChannel.setBypassDnd(oldChannel.canBypassDnd()); + newChannel.enableVibration(oldChannel.shouldVibrate()); + newChannel.setVibrationPattern(oldChannel.getVibrationPattern()); + newChannel.setLockscreenVisibility(oldChannel.getLockscreenVisibility()); + newChannel.setShowBadge(oldChannel.canShowBadge()); + newChannel.setLightColor(oldChannel.getLightColor()); + newChannel.enableLights(oldChannel.shouldShowLights()); + + notificationManager.createNotificationChannel(newChannel); + + ContentValues contentValues = new ContentValues(1); + contentValues.put("notification_channel", newChannel.getId()); + db.update("recipient", contentValues, "_id = ?", new String[] { recipientId }); + } + } + + if (oldVersion < BLUR_HASH) { + db.execSQL("ALTER TABLE part ADD COLUMN blur_hash TEXT DEFAULT NULL"); + } + + if (oldVersion < MMS_RECIPIENT_CLEANUP_2) { + ContentValues values = new ContentValues(1); + values.put("address", "-1"); + int count = db.update("mms", values, "address = ? OR address IS NULL", new String[] { "0" }); + Log.i(TAG, "MMS recipient cleanup 2 updated " + count + " rows."); + } + + if (oldVersion < ATTACHMENT_TRANSFORM_PROPERTIES) { + db.execSQL("ALTER TABLE part ADD COLUMN transform_properties TEXT DEFAULT NULL"); + } + + if (oldVersion < ATTACHMENT_CLEAR_HASHES) { + db.execSQL("UPDATE part SET data_hash = null"); + } + + if (oldVersion < ATTACHMENT_CLEAR_HASHES_2) { + db.execSQL("UPDATE part SET data_hash = null"); + Glide.get(context).clearDiskCache(); + } + + if (oldVersion < UUIDS) { + db.execSQL("ALTER TABLE recipient ADD COLUMN uuid_supported INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE push ADD COLUMN source_uuid TEXT DEFAULT NULL"); + } + + if (oldVersion < USERNAMES) { + db.execSQL("ALTER TABLE recipient ADD COLUMN username TEXT DEFAULT NULL"); + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_username_index ON recipient (username)"); + } + + if (oldVersion < REACTIONS) { + db.execSQL("ALTER TABLE sms ADD COLUMN reactions BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions BLOB DEFAULT NULL"); + + db.execSQL("ALTER TABLE sms ADD COLUMN reactions_unread INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions_unread INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE sms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1"); + } + + if (oldVersion < STORAGE_SERVICE) { + db.execSQL("CREATE TABLE storage_key (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "type INTEGER, " + + "key TEXT UNIQUE)"); + db.execSQL("CREATE INDEX IF NOT EXISTS storage_key_type_index ON storage_key (type)"); + + db.execSQL("ALTER TABLE recipient ADD COLUMN system_info_pending INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE recipient ADD COLUMN storage_service_key TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN dirty INTEGER DEFAULT 0"); + + db.execSQL("CREATE UNIQUE INDEX recipient_storage_service_key ON recipient (storage_service_key)"); + db.execSQL("CREATE INDEX recipient_dirty_index ON recipient (dirty)"); + } + + if (oldVersion < REACTIONS_UNREAD_INDEX) { + db.execSQL("CREATE INDEX IF NOT EXISTS sms_reactions_unread_index ON sms (reactions_unread);"); + db.execSQL("CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON mms (reactions_unread);"); + } + + if (oldVersion < RESUMABLE_DOWNLOADS) { + db.execSQL("ALTER TABLE part ADD COLUMN transfer_file TEXT DEFAULT NULL"); + } + + if (oldVersion < KEY_VALUE_STORE) { + db.execSQL("CREATE TABLE key_value (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "key TEXT UNIQUE, " + + "value TEXT, " + + "type INTEGER)"); + } + + if (oldVersion < ATTACHMENT_DISPLAY_ORDER) { + db.execSQL("ALTER TABLE part ADD COLUMN display_order INTEGER DEFAULT 0"); + } + + if (oldVersion < SPLIT_PROFILE_NAMES) { + db.execSQL("ALTER TABLE recipient ADD COLUMN profile_family_name TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN profile_joined_name TEXT DEFAULT NULL"); + } + + if (oldVersion < STICKER_PACK_ORDER) { + db.execSQL("ALTER TABLE sticker ADD COLUMN pack_order INTEGER DEFAULT 0"); + } + + if (oldVersion < MEGAPHONES) { + db.execSQL("CREATE TABLE megaphone (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "event TEXT UNIQUE, " + + "seen_count INTEGER, " + + "last_seen INTEGER, " + + "finished INTEGER)"); + } + + if (oldVersion < MEGAPHONE_FIRST_APPEARANCE) { + db.execSQL("ALTER TABLE megaphone ADD COLUMN first_visible INTEGER DEFAULT 0"); + } + + if (oldVersion < PROFILE_KEY_TO_DB) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + if (!TextUtils.isEmpty(localNumber)) { + String encodedProfileKey = PreferenceManager.getDefaultSharedPreferences(context).getString("pref_profile_key", null); + byte[] profileKey = encodedProfileKey != null ? Base64.decodeOrThrow(encodedProfileKey) : Util.getSecretBytes(32); + ContentValues values = new ContentValues(1); + + values.put("profile_key", Base64.encodeBytes(profileKey)); + + if (db.update("recipient", values, "phone = ?", new String[]{localNumber}) == 0) { + throw new AssertionError("No rows updated!"); + } + } + } + + if (oldVersion < PROFILE_KEY_CREDENTIALS) { + db.execSQL("ALTER TABLE recipient ADD COLUMN profile_key_credential TEXT DEFAULT NULL"); + } + + if (oldVersion < ATTACHMENT_FILE_INDEX) { + db.execSQL("CREATE INDEX IF NOT EXISTS part_data_index ON part (_data)"); + } + + if (oldVersion < STORAGE_SERVICE_ACTIVE) { + db.execSQL("ALTER TABLE recipient ADD COLUMN group_type INTEGER DEFAULT 0"); + db.execSQL("CREATE INDEX IF NOT EXISTS recipient_group_type_index ON recipient (group_type)"); + + db.execSQL("UPDATE recipient set group_type = 1 WHERE group_id NOT NULL AND group_id LIKE '__signal_mms_group__%'"); + db.execSQL("UPDATE recipient set group_type = 2 WHERE group_id NOT NULL AND group_id LIKE '__textsecure_group__%'"); + + try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1 or group_type = 2", null)) { + while (cursor != null && cursor.moveToNext()) { + String id = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + ContentValues values = new ContentValues(1); + + values.put("dirty", 2); + values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey())); + + db.update("recipient", values, "_id = ?", new String[] { id }); + } + } + } + + if (oldVersion < GROUPS_V2_RECIPIENT_CAPABILITY) { + db.execSQL("ALTER TABLE recipient ADD COLUMN gv2_capability INTEGER DEFAULT 0"); + } + + if (oldVersion < TRANSFER_FILE_CLEANUP) { + File partsDirectory = context.getDir("parts", Context.MODE_PRIVATE); + + if (partsDirectory.exists()) { + File[] transferFiles = partsDirectory.listFiles((dir, name) -> name.startsWith("transfer")); + int deleteCount = 0; + + Log.i(TAG, "Found " + transferFiles.length + " dangling transfer files."); + + for (File file : transferFiles) { + if (file.delete()) { + Log.i(TAG, "Deleted " + file.getName()); + deleteCount++; + } + } + + Log.i(TAG, "Deleted " + deleteCount + " dangling transfer files."); + } else { + Log.w(TAG, "Part directory did not exist. Skipping."); + } + } + + if (oldVersion < PROFILE_DATA_MIGRATION) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + if (localNumber != null) { + String encodedProfileName = PreferenceManager.getDefaultSharedPreferences(context).getString("pref_profile_name", null); + ProfileName profileName = ProfileName.fromSerialized(encodedProfileName); + + db.execSQL("UPDATE recipient SET signal_profile_name = ?, profile_family_name = ?, profile_joined_name = ? WHERE phone = ?", + new String[] { profileName.getGivenName(), profileName.getFamilyName(), profileName.toString(), localNumber }); + } + } + + if (oldVersion < AVATAR_LOCATION_MIGRATION) { + File oldAvatarDirectory = new File(context.getFilesDir(), "avatars"); + File[] results = oldAvatarDirectory.listFiles(); + + if (results != null) { + Log.i(TAG, "Preparing to migrate " + results.length + " avatars."); + + for (File file : results) { + if (Util.isLong(file.getName())) { + try { + AvatarHelper.setAvatar(context, RecipientId.from(file.getName()), new FileInputStream(file)); + } catch(IOException e) { + Log.w(TAG, "Failed to copy file " + file.getName() + "! Skipping."); + } + } else { + Log.w(TAG, "Invalid avatar name '" + file.getName() + "'! Skipping."); + } + } + } else { + Log.w(TAG, "No avatar directory files found."); + } + + if (!FileUtils.deleteDirectory(oldAvatarDirectory)) { + Log.w(TAG, "Failed to delete avatar directory."); + } + + try (Cursor cursor = db.rawQuery("SELECT recipient_id, avatar FROM groups", null)) { + while (cursor != null && cursor.moveToNext()) { + RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow("recipient_id"))); + byte[] avatar = cursor.getBlob(cursor.getColumnIndexOrThrow("avatar")); + + try { + AvatarHelper.setAvatar(context, recipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); + } catch (IOException e) { + Log.w(TAG, "Failed to copy avatar for " + recipientId + "! Skipping.", e); + } + } + } + + db.execSQL("UPDATE groups SET avatar_id = 0 WHERE avatar IS NULL"); + db.execSQL("UPDATE groups SET avatar = NULL"); + } + + if (oldVersion < GROUPS_V2) { + db.execSQL("ALTER TABLE groups ADD COLUMN master_key"); + db.execSQL("ALTER TABLE groups ADD COLUMN revision"); + db.execSQL("ALTER TABLE groups ADD COLUMN decrypted_group"); + } + + if (oldVersion < ATTACHMENT_UPLOAD_TIMESTAMP) { + db.execSQL("ALTER TABLE part ADD COLUMN upload_timestamp DEFAULT 0"); + } + + if (oldVersion < ATTACHMENT_CDN_NUMBER) { + db.execSQL("ALTER TABLE part ADD COLUMN cdn_number INTEGER DEFAULT 0"); + } + + if (oldVersion < JOB_INPUT_DATA) { + db.execSQL("ALTER TABLE job_spec ADD COLUMN serialized_input_data TEXT DEFAULT NULL"); + } + + if (oldVersion < SERVER_TIMESTAMP) { + db.execSQL("ALTER TABLE sms ADD COLUMN date_server INTEGER DEFAULT -1"); + db.execSQL("CREATE INDEX IF NOT EXISTS sms_date_server_index ON sms (date_server)"); + + db.execSQL("ALTER TABLE mms ADD COLUMN date_server INTEGER DEFAULT -1"); + db.execSQL("CREATE INDEX IF NOT EXISTS mms_date_server_index ON mms (date_server)"); + } + + if (oldVersion < REMOTE_DELETE) { + db.execSQL("ALTER TABLE sms ADD COLUMN remote_deleted INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN remote_deleted INTEGER DEFAULT 0"); + } + + if (oldVersion < COLOR_MIGRATION) { + try (Cursor cursor = db.rawQuery("SELECT _id, system_display_name FROM recipient WHERE system_display_name NOT NULL AND color IS NULL", null)) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + String name = cursor.getString(cursor.getColumnIndexOrThrow("system_display_name")); + + ContentValues values = new ContentValues(); + values.put("color", ContactColorsLegacy.generateForV2(name).serialize()); + + db.update("recipient", values, "_id = ?", new String[] { String.valueOf(id) }); + } + } + } + + if (oldVersion < LAST_SCROLLED) { + db.execSQL("ALTER TABLE thread ADD COLUMN last_scrolled INTEGER DEFAULT 0"); + } + + if (oldVersion < LAST_PROFILE_FETCH) { + db.execSQL("ALTER TABLE recipient ADD COLUMN last_profile_fetch INTEGER DEFAULT 0"); + } + + if (oldVersion < SERVER_DELIVERED_TIMESTAMP) { + db.execSQL("ALTER TABLE push ADD COLUMN server_delivered_timestamp INTEGER DEFAULT 0"); + } + + if (oldVersion < QUOTE_CLEANUP) { + String query = "SELECT _data " + + "FROM (SELECT _data, MIN(quote) AS all_quotes " + + "FROM part " + + "WHERE _data NOT NULL AND data_hash NOT NULL " + + "GROUP BY _data) " + + "WHERE all_quotes = 1"; + + int count = 0; + + try (Cursor cursor = db.rawQuery(query, null)) { + while (cursor != null && cursor.moveToNext()) { + String data = cursor.getString(cursor.getColumnIndexOrThrow("_data")); + + if (new File(data).delete()) { + ContentValues values = new ContentValues(); + values.putNull("_data"); + values.putNull("data_random"); + values.putNull("thumbnail"); + values.putNull("thumbnail_random"); + values.putNull("data_hash"); + db.update("part", values, "_data = ?", new String[] { data }); + + count++; + } else { + Log.w(TAG, "[QuoteCleanup] Failed to delete " + data); + } + } + } + + Log.i(TAG, "[QuoteCleanup] Cleaned up " + count + " quotes."); + } + + if (oldVersion < BORDERLESS) { + db.execSQL("ALTER TABLE part ADD COLUMN borderless INTEGER DEFAULT 0"); + } + + if (oldVersion < REMAPPED_RECORDS) { + db.execSQL("CREATE TABLE remapped_recipients (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "old_id INTEGER UNIQUE, " + + "new_id INTEGER)"); + db.execSQL("CREATE TABLE remapped_threads (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "old_id INTEGER UNIQUE, " + + "new_id INTEGER)"); + } + + if (oldVersion < MENTIONS) { + db.execSQL("CREATE TABLE mention (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "thread_id INTEGER, " + + "message_id INTEGER, " + + "recipient_id INTEGER, " + + "range_start INTEGER, " + + "range_length INTEGER)"); + + db.execSQL("CREATE INDEX IF NOT EXISTS mention_message_id_index ON mention (message_id)"); + db.execSQL("CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON mention (recipient_id, thread_id);"); + + db.execSQL("ALTER TABLE mms ADD COLUMN quote_mentions BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE mms ADD COLUMN mentions_self INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE recipient ADD COLUMN mention_setting INTEGER DEFAULT 0"); + } + + if (oldVersion < PINNED_CONVERSATIONS) { + db.execSQL("ALTER TABLE thread ADD COLUMN pinned INTEGER DEFAULT 0"); + db.execSQL("CREATE INDEX IF NOT EXISTS thread_pinned_index ON thread (pinned)"); + } + + if (oldVersion < MENTION_GLOBAL_SETTING_MIGRATION) { + ContentValues updateAlways = new ContentValues(); + updateAlways.put("mention_setting", 0); + db.update("recipient", updateAlways, "mention_setting = 1", null); + + ContentValues updateNever = new ContentValues(); + updateNever.put("mention_setting", 1); + db.update("recipient", updateNever, "mention_setting = 2", null); + } + + if (oldVersion < UNKNOWN_STORAGE_FIELDS) { + db.execSQL("ALTER TABLE recipient ADD COLUMN storage_proto TEXT DEFAULT NULL"); + } + + if (oldVersion < STICKER_CONTENT_TYPE) { + db.execSQL("ALTER TABLE sticker ADD COLUMN content_type TEXT DEFAULT NULL"); + } + + if (oldVersion < STICKER_EMOJI_IN_NOTIFICATIONS) { + db.execSQL("ALTER TABLE part ADD COLUMN sticker_emoji TEXT DEFAULT NULL"); + } + + if (oldVersion < THUMBNAIL_CLEANUP) { + int total = 0; + int deleted = 0; + + try (Cursor cursor = db.rawQuery("SELECT thumbnail FROM part WHERE thumbnail NOT NULL", null)) { + if (cursor != null) { + total = cursor.getCount(); + Log.w(TAG, "Found " + total + " thumbnails to delete."); + } + + while (cursor != null && cursor.moveToNext()) { + File file = new File(CursorUtil.requireString(cursor, "thumbnail")); + + if (file.delete()) { + deleted++; + } else { + Log.w(TAG, "Failed to delete file! " + file.getAbsolutePath()); + } + } + } + + Log.w(TAG, "Deleted " + deleted + "/" + total + " thumbnail files."); + } + + if (oldVersion < STICKER_CONTENT_TYPE_CLEANUP) { + ContentValues values = new ContentValues(); + values.put("ct", "image/webp"); + + String query = "sticker_id NOT NULL AND (ct IS NULL OR ct = '')"; + + int rows = db.update("part", values, query, null); + Log.i(TAG, "Updated " + rows + " sticker attachment content types."); + } + + if (oldVersion < MENTION_CLEANUP) { + String selectMentionIdsNotInGroupsV2 = "select mention._id from mention left join thread on mention.thread_id = thread._id left join recipient on thread.recipient_ids = recipient._id where recipient.group_type != 3"; + db.delete("mention", "_id in (" + selectMentionIdsNotInGroupsV2 + ")", null); + db.delete("mention", "message_id NOT IN (SELECT _id FROM mms) OR thread_id NOT IN (SELECT _id from thread)", null); + + List idsToDelete = new LinkedList<>(); + try (Cursor cursor = db.rawQuery("select mention.*, mms.body from mention inner join mms on mention.message_id = mms._id", null)) { + while (cursor != null && cursor.moveToNext()) { + int rangeStart = CursorUtil.requireInt(cursor, "range_start"); + int rangeLength = CursorUtil.requireInt(cursor, "range_length"); + String body = CursorUtil.requireString(cursor, "body"); + + if (body == null || body.isEmpty() || rangeStart < 0 || rangeLength < 0 || (rangeStart + rangeLength) > body.length()) { + idsToDelete.add(CursorUtil.requireLong(cursor, "_id")); + } + } + } + + if (Util.hasItems(idsToDelete)) { + String ids = TextUtils.join(",", idsToDelete); + db.delete("mention", "_id in (" + ids + ")", null); + } + } + + if (oldVersion < MENTION_CLEANUP_V2) { + String selectMentionIdsWithMismatchingThreadIds = "select mention._id from mention left join mms on mention.message_id = mms._id where mention.thread_id != mms.thread_id"; + db.delete("mention", "_id in (" + selectMentionIdsWithMismatchingThreadIds + ")", null); + + List idsToDelete = new LinkedList<>(); + Set> mentionTuples = new HashSet<>(); + try (Cursor cursor = db.rawQuery("select mention.*, mms.body from mention inner join mms on mention.message_id = mms._id order by mention._id desc", null)) { + while (cursor != null && cursor.moveToNext()) { + long mentionId = CursorUtil.requireLong(cursor, "_id"); + long messageId = CursorUtil.requireLong(cursor, "message_id"); + int rangeStart = CursorUtil.requireInt(cursor, "range_start"); + int rangeLength = CursorUtil.requireInt(cursor, "range_length"); + String body = CursorUtil.requireString(cursor, "body"); + + if (body != null && rangeStart < body.length() && body.charAt(rangeStart) != '\uFFFC') { + idsToDelete.add(mentionId); + } else { + Triple tuple = new Triple<>(messageId, rangeStart, rangeLength); + if (mentionTuples.contains(tuple)) { + idsToDelete.add(mentionId); + } else { + mentionTuples.add(tuple); + } + } + } + + if (Util.hasItems(idsToDelete)) { + String ids = TextUtils.join(",", idsToDelete); + db.delete("mention", "_id in (" + ids + ")", null); + } + } + } + + if (oldVersion < REACTION_CLEANUP) { + ContentValues values = new ContentValues(); + values.putNull("reactions"); + db.update("sms", values, "remote_deleted = ?", new String[] { "1" }); + } + + if (oldVersion < CAPABILITIES_REFACTOR) { + db.execSQL("ALTER TABLE recipient ADD COLUMN capabilities INTEGER DEFAULT 0"); + + db.execSQL("UPDATE recipient SET capabilities = 1 WHERE gv2_capability = 1"); + db.execSQL("UPDATE recipient SET capabilities = 2 WHERE gv2_capability = -1"); + } + + if (oldVersion < GV1_MIGRATION) { + db.execSQL("ALTER TABLE groups ADD COLUMN expected_v2_id TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE groups ADD COLUMN former_v1_members TEXT DEFAULT NULL"); + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON groups (expected_v2_id)"); + + int count = 0; + try (Cursor cursor = db.rawQuery("SELECT * FROM groups WHERE group_id LIKE '__textsecure_group__!%' AND LENGTH(group_id) = 53", null)) { + while (cursor.moveToNext()) { + String gv1 = CursorUtil.requireString(cursor, "group_id"); + String gv2 = GroupId.parseOrThrow(gv1).requireV1().deriveV2MigrationGroupId().toString(); + + ContentValues values = new ContentValues(); + values.put("expected_v2_id", gv2); + count += db.update("groups", values, "group_id = ?", SqlUtil.buildArgs(gv1)); + } + } + + Log.i(TAG, "Updated " + count + " GV1 groups with expected GV2 IDs."); + } + + if (oldVersion < NOTIFIED_TIMESTAMP) { + db.execSQL("ALTER TABLE sms ADD COLUMN notified_timestamp INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN notified_timestamp INTEGER DEFAULT 0"); + } + + if (oldVersion < GV1_MIGRATION_LAST_SEEN) { + db.execSQL("ALTER TABLE recipient ADD COLUMN last_gv1_migrate_reminder INTEGER DEFAULT 0"); + } + + if (oldVersion < VIEWED_RECEIPTS) { + db.execSQL("ALTER TABLE mms ADD COLUMN viewed_receipt_count INTEGER DEFAULT 0"); + } + + if (oldVersion < CLEAN_UP_GV1_IDS) { + List deletableRecipients = new LinkedList<>(); + try (Cursor cursor = db.rawQuery("SELECT _id, group_id FROM recipient\n" + + "WHERE group_id NOT IN (SELECT group_id FROM groups)\n" + + "AND group_id LIKE '__textsecure_group__!%' AND length(group_id) <> 53\n" + + "AND (_id NOT IN (SELECT recipient_ids FROM thread) OR _id IN (SELECT recipient_ids FROM thread WHERE message_count = 0))", null)) + { + while (cursor.moveToNext()) { + String recipientId = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + String groupIdV1 = cursor.getString(cursor.getColumnIndexOrThrow("group_id")); + deletableRecipients.add(recipientId); + Log.d(TAG, String.format(Locale.US, "Found invalid GV1 on %s with no or empty thread %s length %d", recipientId, groupIdV1, groupIdV1.length())); + } + } + + for (String recipientId : deletableRecipients) { + db.delete("recipient", "_id = ?", new String[]{recipientId}); + Log.d(TAG, "Deleted recipient " + recipientId); + } + + List orphanedThreads = new LinkedList<>(); + try (Cursor cursor = db.rawQuery("SELECT _id FROM thread WHERE message_count = 0 AND recipient_ids NOT IN (SELECT _id FROM recipient)", null)) { + while (cursor.moveToNext()) { + orphanedThreads.add(cursor.getString(cursor.getColumnIndexOrThrow("_id"))); + } + } + + for (String orphanedThreadId : orphanedThreads) { + db.delete("thread", "_id = ?", new String[]{orphanedThreadId}); + Log.d(TAG, "Deleted orphaned thread " + orphanedThreadId); + } + + List remainingInvalidGV1Recipients = new LinkedList<>(); + try (Cursor cursor = db.rawQuery("SELECT _id, group_id FROM recipient\n" + + "WHERE group_id NOT IN (SELECT group_id FROM groups)\n" + + "AND group_id LIKE '__textsecure_group__!%' AND length(group_id) <> 53\n" + + "AND _id IN (SELECT recipient_ids FROM thread)", null)) + { + while (cursor.moveToNext()) { + String recipientId = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + String groupIdV1 = cursor.getString(cursor.getColumnIndexOrThrow("group_id")); + remainingInvalidGV1Recipients.add(recipientId); + Log.d(TAG, String.format(Locale.US, "Found invalid GV1 on %s with non-empty thread %s length %d", recipientId, groupIdV1, groupIdV1.length())); + } + } + + for (String recipientId : remainingInvalidGV1Recipients) { + String newId = "__textsecure_group__!" + Hex.toStringCondensed(Util.getSecretBytes(16)); + ContentValues values = new ContentValues(1); + values.put("group_id", newId); + + db.update("recipient", values, "_id = ?", new String[] { String.valueOf(recipientId) }); + Log.d(TAG, String.format("Replaced group id on recipient %s now %s", recipientId, newId)); + } + } + + if (oldVersion < GV1_MIGRATION_REFACTOR) { + ContentValues values = new ContentValues(1); + values.putNull("former_v1_members"); + + int count = db.update("groups", values, "former_v1_members NOT NULL", null); + + Log.i(TAG, "Cleared former_v1_members for " + count + " rows"); + } + + if (oldVersion < CLEAR_PROFILE_KEY_CREDENTIALS) { + ContentValues values = new ContentValues(1); + values.putNull("profile_key_credential"); + + int count = db.update("recipient", values, "profile_key_credential NOT NULL", null); + + Log.i(TAG, "Cleared profile key credentials for " + count + " rows"); + } + + if (oldVersion < LAST_RESET_SESSION_TIME) { + db.execSQL("ALTER TABLE recipient ADD COLUMN last_session_reset BLOB DEFAULT NULL"); + } + + if (oldVersion < WALLPAPER) { + db.execSQL("ALTER TABLE recipient ADD COLUMN wallpaper BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN wallpaper_file TEXT DEFAULT NULL"); + } + + if (oldVersion < ABOUT) { + db.execSQL("ALTER TABLE recipient ADD COLUMN about TEXT DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN about_emoji TEXT DEFAULT NULL"); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (oldVersion < MIGRATE_PREKEYS_VERSION) { + PreKeyMigrationHelper.cleanUpPreKeys(context); + } + + Log.i(TAG, "Upgrade complete. Took " + (System.currentTimeMillis() - startTime) + " ms."); + } + + public org.thoughtcrime.securesms.database.SQLiteDatabase getReadableDatabase() { + return new org.thoughtcrime.securesms.database.SQLiteDatabase(getReadableDatabase(databaseSecret.asString())); + } + + public org.thoughtcrime.securesms.database.SQLiteDatabase getWritableDatabase() { + return new org.thoughtcrime.securesms.database.SQLiteDatabase(getWritableDatabase(databaseSecret.asString())); + } + + @Override + public @NonNull SQLiteDatabase getSqlCipherDatabase() { + return getWritableDatabase().getSqlCipherDatabase(); + } + + public void markCurrent(SQLiteDatabase db) { + db.setVersion(DATABASE_VERSION); + } + + public static boolean databaseFileExists(@NonNull Context context) { + return context.getDatabasePath(DATABASE_NAME).exists(); + } + + public static File getDatabaseFile(@NonNull Context context) { + return context.getDatabasePath(DATABASE_NAME); + } + + private void executeStatements(SQLiteDatabase db, String[] statements) { + for (String statement : statements) + db.execSQL(statement); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SessionStoreMigrationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SessionStoreMigrationHelper.java new file mode 100644 index 00000000..6fcec92c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SessionStoreMigrationHelper.java @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.database.helpers; + + +import android.content.ContentValues; +import android.content.Context; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.Conversions; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.SessionDatabase; +import org.whispersystems.libsignal.state.SessionRecord; +import org.whispersystems.libsignal.state.SessionState; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +class SessionStoreMigrationHelper { + + private static final String TAG = SessionStoreMigrationHelper.class.getSimpleName(); + + private static final String SESSIONS_DIRECTORY_V2 = "sessions-v2"; + private static final Object FILE_LOCK = new Object(); + + private static final int SINGLE_STATE_VERSION = 1; + private static final int ARCHIVE_STATES_VERSION = 2; + private static final int PLAINTEXT_VERSION = 3; + private static final int CURRENT_VERSION = 3; + + static void migrateSessions(Context context, SQLiteDatabase database) { + File directory = new File(context.getFilesDir(), SESSIONS_DIRECTORY_V2); + + if (directory.exists()) { + File[] sessionFiles = directory.listFiles(); + + if (sessionFiles != null) { + for (File sessionFile : sessionFiles) { + try { + String[] parts = sessionFile.getName().split("[.]"); + String address = parts[0]; + + int deviceId; + + if (parts.length > 1) deviceId = Integer.parseInt(parts[1]); + else deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID; + + FileInputStream in = new FileInputStream(sessionFile); + int versionMarker = readInteger(in); + + if (versionMarker > CURRENT_VERSION) { + throw new AssertionError("Unknown version: " + versionMarker + ", " + sessionFile.getAbsolutePath()); + } + + byte[] serialized = readBlob(in); + in.close(); + + if (versionMarker < PLAINTEXT_VERSION) { + throw new AssertionError("Not plaintext: " + versionMarker + ", " + sessionFile.getAbsolutePath()); + } + + SessionRecord sessionRecord; + + if (versionMarker == SINGLE_STATE_VERSION) { + Log.i(TAG, "Migrating single state version: " + sessionFile.getAbsolutePath()); + SessionState sessionState = new SessionState(serialized); + + sessionRecord = new SessionRecord(sessionState); + } else if (versionMarker >= ARCHIVE_STATES_VERSION) { + Log.i(TAG, "Migrating session: " + sessionFile.getAbsolutePath()); + sessionRecord = new SessionRecord(serialized); + } else { + throw new AssertionError("Unknown version: " + versionMarker + ", " + sessionFile.getAbsolutePath()); + } + + + ContentValues contentValues = new ContentValues(); + contentValues.put(SessionDatabase.RECIPIENT_ID, address); + contentValues.put(SessionDatabase.DEVICE, deviceId); + contentValues.put(SessionDatabase.RECORD, sessionRecord.serialize()); + + database.insert(SessionDatabase.TABLE_NAME, null, contentValues); + } catch (NumberFormatException | IOException e) { + Log.w(TAG, e); + } + } + } + } + } + + private static byte[] readBlob(FileInputStream in) throws IOException { + int length = readInteger(in); + byte[] blobBytes = new byte[length]; + + in.read(blobBytes, 0, blobBytes.length); + return blobBytes; + } + + private static int readInteger(FileInputStream in) throws IOException { + byte[] integer = new byte[4]; + in.read(integer, 0, integer.length); + return Conversions.byteArrayToInt(integer); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java new file mode 100644 index 00000000..d588fa41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.database.identity; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public final class IdentityRecordList { + + private final List identityRecords; + private final boolean isVerified; + private final boolean isUnverified; + + public IdentityRecordList(@NonNull Collection records) { + identityRecords = new ArrayList<>(records); + isVerified = isVerified(identityRecords); + isUnverified = isUnverified(identityRecords); + } + + public List getIdentityRecords() { + return Collections.unmodifiableList(identityRecords); + } + + public boolean isVerified() { + return isVerified; + } + + public boolean isUnverified() { + return isUnverified; + } + + private static boolean isVerified(@NonNull Collection identityRecords) { + for (IdentityRecord identityRecord : identityRecords) { + if (identityRecord.getVerifiedStatus() != VerifiedStatus.VERIFIED) { + return false; + } + } + + return identityRecords.size() > 0; + } + + private static boolean isUnverified(@NonNull Collection identityRecords) { + for (IdentityRecord identityRecord : identityRecords) { + if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) { + return true; + } + } + + return false; + } + + public boolean isUnverified(boolean excludeFirstUse) { + for (IdentityRecord identityRecord : identityRecords) { + if (excludeFirstUse && identityRecord.isFirstUse()) { + continue; + } + + if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) { + return true; + } + } + + return false; + } + + public boolean isUntrusted(boolean excludeFirstUse) { + for (IdentityRecord identityRecord : identityRecords) { + if (excludeFirstUse && identityRecord.isFirstUse()) { + continue; + } + + if (isUntrusted(identityRecord)) { + return true; + } + } + + return false; + } + + public @NonNull List getUntrustedRecords() { + List results = new ArrayList<>(identityRecords.size()); + + for (IdentityRecord identityRecord : identityRecords) { + if (isUntrusted(identityRecord)) { + results.add(identityRecord); + } + } + + return results; + } + + public @NonNull List getUntrustedRecipients() { + List untrusted = new ArrayList<>(identityRecords.size()); + + for (IdentityRecord identityRecord : identityRecords) { + if (isUntrusted(identityRecord)) { + untrusted.add(Recipient.resolved(identityRecord.getRecipientId())); + } + } + + return untrusted; + } + + public @NonNull List getUnverifiedRecords() { + List results = new ArrayList<>(identityRecords.size()); + + for (IdentityRecord identityRecord : identityRecords) { + if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) { + results.add(identityRecord); + } + } + + return results; + } + + public @NonNull List getUnverifiedRecipients() { + List unverified = new ArrayList<>(identityRecords.size()); + + for (IdentityRecord identityRecord : identityRecords) { + if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) { + unverified.add(Recipient.resolved(identityRecord.getRecipientId())); + } + } + + return unverified; + } + + private static boolean isUntrusted(@NonNull IdentityRecord identityRecord) { + return !identityRecord.isApprovedNonBlocking() && + System.currentTimeMillis() - identityRecord.getTimestamp() < TimeUnit.SECONDS.toMillis(5); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/CountryListLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/CountryListLoader.java new file mode 100644 index 00000000..f55a7579 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/CountryListLoader.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; + +import androidx.loader.content.AsyncTaskLoader; + +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public final class CountryListLoader extends AsyncTaskLoader>> { + + public CountryListLoader(Context context) { + super(context); + } + + @Override + public ArrayList> loadInBackground() { + Set regions = PhoneNumberUtil.getInstance().getSupportedRegions(); + ArrayList> results = new ArrayList<>(regions.size()); + + for (String region : regions) { + Map data = new HashMap<>(2); + data.put("country_name", PhoneNumberFormatter.getRegionDisplayNameLegacy(region)); + data.put("country_code", "+" +PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)); + results.add(data); + } + + Collections.sort(results, new RegionComparator()); + + return results; + } + + private static class RegionComparator implements Comparator> { + + private final Collator collator; + + RegionComparator() { + collator = Collator.getInstance(); + collator.setStrength(Collator.PRIMARY); + } + + @Override + public int compare(Map lhs, Map rhs) { + String a = lhs.get("country_name"); + String b = rhs.get("country_name"); + return collator.compare(a, b); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java new file mode 100644 index 00000000..1c3b2830 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.devicelist.Device; +import org.thoughtcrime.securesms.util.AsyncLoader; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.util.ByteUtil; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import static org.thoughtcrime.securesms.devicelist.DeviceNameProtos.DeviceName; + +public class DeviceListLoader extends AsyncLoader> { + + private static final String TAG = DeviceListLoader.class.getSimpleName(); + + private final SignalServiceAccountManager accountManager; + + public DeviceListLoader(Context context, SignalServiceAccountManager accountManager) { + super(context); + this.accountManager = accountManager; + } + + @Override + public List loadInBackground() { + try { + List devices = Stream.of(accountManager.getDevices()) + .filter(d -> d.getId() != SignalServiceAddress.DEFAULT_DEVICE_ID) + .map(this::mapToDevice) + .toList(); + + Collections.sort(devices, new DeviceComparator()); + + return devices; + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private Device mapToDevice(@NonNull DeviceInfo deviceInfo) { + try { + if (TextUtils.isEmpty(deviceInfo.getName()) || deviceInfo.getName().length() < 4) { + throw new IOException("Invalid DeviceInfo name."); + } + + DeviceName deviceName = DeviceName.parseFrom(Base64.decode(deviceInfo.getName())); + + if (!deviceName.hasCiphertext() || !deviceName.hasEphemeralPublic() || !deviceName.hasSyntheticIv()) { + throw new IOException("Got a DeviceName that wasn't properly populated."); + } + + byte[] syntheticIv = deviceName.getSyntheticIv().toByteArray(); + byte[] cipherText = deviceName.getCiphertext().toByteArray(); + ECPrivateKey identityKey = IdentityKeyUtil.getIdentityKeyPair(getContext()).getPrivateKey(); + ECPublicKey ephemeralPublic = Curve.decodePoint(deviceName.getEphemeralPublic().toByteArray(), 0); + byte[] masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(masterSecret, "HmacSHA256")); + byte[] cipherKeyPart1 = mac.doFinal("cipher".getBytes()); + + mac.init(new SecretKeySpec(cipherKeyPart1, "HmacSHA256")); + byte[] cipherKey = mac.doFinal(syntheticIv); + + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(new byte[16])); + final byte[] plaintext = cipher.doFinal(cipherText); + + mac.init(new SecretKeySpec(masterSecret, "HmacSHA256")); + byte[] verificationPart1 = mac.doFinal("auth".getBytes()); + + mac.init(new SecretKeySpec(verificationPart1, "HmacSHA256")); + byte[] verificationPart2 = mac.doFinal(plaintext); + byte[] ourSyntheticIv = ByteUtil.trim(verificationPart2, 16); + + if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { + throw new GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv."); + } + + return new Device(deviceInfo.getId(), new String(plaintext), deviceInfo.getCreated(), deviceInfo.getLastSeen()); + + } catch (IOException e) { + Log.w(TAG, "Failed while reading the protobuf.", e); + } catch (GeneralSecurityException | InvalidKeyException e) { + Log.w(TAG, "Failed during decryption.", e); + } + + return new Device(deviceInfo.getId(), deviceInfo.getName(), deviceInfo.getCreated(), deviceInfo.getLastSeen()); + } + + private static class DeviceComparator implements Comparator { + + @Override + public int compare(Device lhs, Device rhs) { + if (lhs.getCreated() < rhs.getCreated()) return -1; + else if (lhs.getCreated() != rhs.getCreated()) return 1; + else return 0; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/GroupedThreadMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/GroupedThreadMediaLoader.java new file mode 100644 index 00000000..6800d8e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/GroupedThreadMediaLoader.java @@ -0,0 +1,313 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.util.SparseArray; + +import androidx.annotation.NonNull; +import androidx.loader.content.AsyncTaskLoader; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.util.CalendarDateOnly; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +public final class GroupedThreadMediaLoader extends AsyncTaskLoader { + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(GroupedThreadMediaLoader.class); + + private final ContentObserver observer; + private final MediaLoader.MediaType mediaType; + private final MediaDatabase.Sorting sorting; + private final long threadId; + + public GroupedThreadMediaLoader(@NonNull Context context, + long threadId, + @NonNull MediaLoader.MediaType mediaType, + @NonNull MediaDatabase.Sorting sorting) + { + super(context); + this.threadId = threadId; + this.mediaType = mediaType; + this.sorting = sorting; + this.observer = new ForceLoadContentObserver(); + + onContentChanged(); + } + + @Override + protected void onStartLoading() { + if (takeContentChanged()) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onAbandon() { + DatabaseFactory.getMediaDatabase(getContext()).unsubscribeToMediaChanges(observer); + } + + @Override + public GroupedThreadMedia loadInBackground() { + Context context = getContext(); + GroupingMethod groupingMethod = sorting.isRelatedToFileSize() + ? new RoughSizeGroupingMethod(context) + : new DateGroupingMethod(context, CalendarDateOnly.getInstance()); + + PopulatedGroupedThreadMedia mediaGrouping = new PopulatedGroupedThreadMedia(groupingMethod); + + DatabaseFactory.getMediaDatabase(context).subscribeToMediaChanges(observer); + try (Cursor cursor = ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting)) { + while (cursor != null && cursor.moveToNext()) { + mediaGrouping.add(MediaDatabase.MediaRecord.from(context, cursor)); + } + } + + if (sorting == MediaDatabase.Sorting.Oldest || sorting == MediaDatabase.Sorting.Largest) { + return new ReversedGroupedThreadMedia(mediaGrouping); + } else { + return mediaGrouping; + } + } + + public interface GroupingMethod { + + int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord); + + @NonNull String groupName(int groupNo); + } + + public static class DateGroupingMethod implements GroupingMethod { + + private final Context context; + private final long yesterdayStart; + private final long todayStart; + private final long thisWeekStart; + private final long thisMonthStart; + + private static final int TODAY = Integer.MIN_VALUE; + private static final int YESTERDAY = Integer.MIN_VALUE + 1; + private static final int THIS_WEEK = Integer.MIN_VALUE + 2; + private static final int THIS_MONTH = Integer.MIN_VALUE + 3; + + DateGroupingMethod(@NonNull Context context, @NonNull Calendar today) { + this.context = context; + todayStart = today.getTimeInMillis(); + yesterdayStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -1); + thisWeekStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -6); + thisMonthStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -30); + } + + private static long getTimeInMillis(@NonNull Calendar now, int field, int offset) { + Calendar copy = (Calendar) now.clone(); + copy.add(field, offset); + return copy.getTimeInMillis(); + } + + @Override + public int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord) { + long date = mediaRecord.getDate(); + + if (date > todayStart) return TODAY; + if (date > yesterdayStart) return YESTERDAY; + if (date > thisWeekStart) return THIS_WEEK; + if (date > thisMonthStart) return THIS_MONTH; + + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(date); + + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH); + + return -(year * 12 + month); + } + + @Override + public @NonNull String groupName(int groupNo) { + switch (groupNo) { + case TODAY: + return context.getString(R.string.BucketedThreadMedia_Today); + case YESTERDAY: + return context.getString(R.string.BucketedThreadMedia_Yesterday); + case THIS_WEEK: + return context.getString(R.string.BucketedThreadMedia_This_week); + case THIS_MONTH: + return context.getString(R.string.BucketedThreadMedia_This_month); + default: + int yearAndMonth = -groupNo; + int month = yearAndMonth % 12; + int year = yearAndMonth / 12; + + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month); + + return new SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(calendar.getTime()); + } + } + } + + public static class RoughSizeGroupingMethod implements GroupingMethod { + + private final String largeDescription; + private final String mediumDescription; + private final String smallDescription; + + private static final int MB = 1024 * 1024; + private static final int SMALL = 0; + private static final int MEDIUM = 1; + private static final int LARGE = 2; + + RoughSizeGroupingMethod(@NonNull Context context) { + smallDescription = context.getString(R.string.BucketedThreadMedia_Small); + mediumDescription = context.getString(R.string.BucketedThreadMedia_Medium); + largeDescription = context.getString(R.string.BucketedThreadMedia_Large); + } + + @Override + public int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord) { + long size = mediaRecord.getAttachment().getSize(); + + if (size < MB) return SMALL; + if (size < 20 * MB) return MEDIUM; + + return LARGE; + } + + @Override + public @NonNull String groupName(int groupNo) { + switch (groupNo) { + case SMALL : return smallDescription; + case MEDIUM: return mediumDescription; + case LARGE : return largeDescription; + default: throw new AssertionError(); + } + } + } + + public static abstract class GroupedThreadMedia { + + public abstract int getSectionCount(); + + public abstract int getSectionItemCount(int section); + + public abstract @NonNull MediaDatabase.MediaRecord get(int section, int item); + + public abstract @NonNull String getName(int section); + + } + + public static class EmptyGroupedThreadMedia extends GroupedThreadMedia { + + @Override + public int getSectionCount() { + return 0; + } + + @Override + public int getSectionItemCount(int section) { + return 0; + } + + @Override + public @NonNull MediaDatabase.MediaRecord get(int section, int item) { + throw new AssertionError(); + } + + @Override + public @NonNull String getName(int section) { + throw new AssertionError(); + } + } + + public static class ReversedGroupedThreadMedia extends GroupedThreadMedia { + + private final GroupedThreadMedia decorated; + + ReversedGroupedThreadMedia(@NonNull GroupedThreadMedia decorated) { + this.decorated = decorated; + } + + @Override + public int getSectionCount() { + return decorated.getSectionCount(); + } + + @Override + public int getSectionItemCount(int section) { + return decorated.getSectionItemCount(getReversedSection(section)); + } + + @Override + public @NonNull MediaDatabase.MediaRecord get(int section, int item) { + return decorated.get(getReversedSection(section), item); + } + + @Override + public @NonNull String getName(int section) { + return decorated.getName(getReversedSection(section)); + } + + private int getReversedSection(int section) { + return decorated.getSectionCount() - 1 - section; + } + } + + private static class PopulatedGroupedThreadMedia extends GroupedThreadMedia { + + @NonNull + private final GroupingMethod groupingMethod; + + private final SparseArray> records = new SparseArray<>(); + + private PopulatedGroupedThreadMedia(@NonNull GroupingMethod groupingMethod) { + this.groupingMethod = groupingMethod; + } + + private void add(@NonNull MediaDatabase.MediaRecord mediaRecord) { + int groupNo = groupingMethod.groupForRecord(mediaRecord); + + List mediaRecords = records.get(groupNo); + if (mediaRecords == null) { + mediaRecords = new LinkedList<>(); + records.put(groupNo, mediaRecords); + } + + mediaRecords.add(mediaRecord); + } + + @Override + public int getSectionCount() { + return records.size(); + } + + @Override + public int getSectionItemCount(int section) { + return records.get(records.keyAt(section)).size(); + } + + @Override + public @NonNull MediaDatabase.MediaRecord get(int section, int item) { + return records.get(records.keyAt(section)).get(item); + } + + @Override + public @NonNull String getName(int section) { + return groupingMethod.groupName(records.keyAt(section)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MediaLoader.java new file mode 100644 index 00000000..1ac9ded3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MediaLoader.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; + +import org.thoughtcrime.securesms.util.AbstractCursorLoader; + +public abstract class MediaLoader extends AbstractCursorLoader { + + MediaLoader(Context context) { + super(context); + } + + public enum MediaType { + GALLERY, + DOCUMENT, + AUDIO, + ALL + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MessageDetailsLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MessageDetailsLoader.java new file mode 100644 index 00000000..65d0b97b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/MessageDetailsLoader.java @@ -0,0 +1,47 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.database.Cursor; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.util.AbstractCursorLoader; + +public class MessageDetailsLoader extends AbstractCursorLoader { + private final String type; + private final long messageId; + + public MessageDetailsLoader(Context context, String type, long messageId) { + super(context); + this.type = type; + this.messageId = messageId; + } + + @Override + public Cursor getCursor() { + switch (type) { + case MmsSmsDatabase.SMS_TRANSPORT: + return DatabaseFactory.getSmsDatabase(context).getVerboseMessageCursor(messageId); + case MmsSmsDatabase.MMS_TRANSPORT: + return DatabaseFactory.getMmsDatabase(context).getVerboseMessageCursor(messageId); + default: + throw new AssertionError("no valid message type specified"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java new file mode 100644 index 00000000..8f7664b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Pair; + +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MediaDatabase.Sorting; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.AsyncLoader; + +public final class PagingMediaLoader extends AsyncLoader> { + + @SuppressWarnings("unused") + private static final String TAG = PagingMediaLoader.class.getSimpleName(); + + private final Uri uri; + private final boolean leftIsRecent; + private final Sorting sorting; + private final long threadId; + + public PagingMediaLoader(@NonNull Context context, long threadId, @NonNull Uri uri, boolean leftIsRecent, @NonNull Sorting sorting) { + super(context); + this.threadId = threadId; + this.uri = uri; + this.leftIsRecent = leftIsRecent; + this.sorting = sorting; + } + + @Override + public @Nullable Pair loadInBackground() { + Cursor cursor = DatabaseFactory.getMediaDatabase(getContext()).getGalleryMediaForThread(threadId, sorting, threadId == MediaDatabase.ALL_THREADS); + + while (cursor.moveToNext()) { + AttachmentId attachmentId = new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))); + Uri attachmentUri = PartAuthority.getAttachmentDataUri(attachmentId); + + if (attachmentUri.equals(uri)) { + return new Pair<>(cursor, leftIsRecent ? cursor.getPosition() : cursor.getCount() - 1 - cursor.getPosition()); + } + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java new file mode 100644 index 00000000..d8c4694d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.database.loaders; + + +import android.Manifest; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; + +import androidx.loader.content.CursorLoader; + +import org.thoughtcrime.securesms.permissions.Permissions; + +public class RecentPhotosLoader extends CursorLoader { + + public static Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + + private static final String[] PROJECTION = new String[] { + MediaStore.Images.ImageColumns._ID, + MediaStore.Images.ImageColumns.DATE_TAKEN, + MediaStore.Images.ImageColumns.DATE_MODIFIED, + MediaStore.Images.ImageColumns.ORIENTATION, + MediaStore.Images.ImageColumns.MIME_TYPE, + MediaStore.Images.ImageColumns.BUCKET_ID, + MediaStore.Images.ImageColumns.SIZE, + MediaStore.Images.ImageColumns.WIDTH, + MediaStore.Images.ImageColumns.HEIGHT + }; + + private static final String SELECTION = Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.IS_PENDING + " != 1" + : MediaStore.Images.Media.DATA + " IS NULL"; + + private final Context context; + + public RecentPhotosLoader(Context context) { + super(context); + this.context = context.getApplicationContext(); + } + + @Override + public Cursor loadInBackground() { + if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + PROJECTION, SELECTION, null, + MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); + } else { + return null; + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecipientMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecipientMediaLoader.java new file mode 100644 index 00000000..3e3e85c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecipientMediaLoader.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +/** + * It is more efficient to use the {@link ThreadMediaLoader} if you know the thread id already. + */ +public final class RecipientMediaLoader extends MediaLoader { + + @Nullable private final RecipientId recipientId; + @NonNull private final MediaType mediaType; + @NonNull private final MediaDatabase.Sorting sorting; + + public RecipientMediaLoader(@NonNull Context context, + @Nullable RecipientId recipientId, + @NonNull MediaType mediaType, + @NonNull MediaDatabase.Sorting sorting) + { + super(context); + this.recipientId = recipientId; + this.mediaType = mediaType; + this.sorting = sorting; + } + + @Override + public Cursor getCursor() { + if (recipientId == null || recipientId.isUnknown()) return null; + + long threadId = DatabaseFactory.getThreadDatabase(getContext()) + .getThreadIdFor(Recipient.resolved(recipientId)); + + return ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java new file mode 100644 index 00000000..3703896c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; + +public final class ThreadMediaLoader extends MediaLoader { + + private final long threadId; + @NonNull private final MediaType mediaType; + @NonNull private final MediaDatabase.Sorting sorting; + + public ThreadMediaLoader(@NonNull Context context, + long threadId, + @NonNull MediaType mediaType, + @NonNull MediaDatabase.Sorting sorting) + { + super(context); + this.threadId = threadId; + this.mediaType = mediaType; + this.sorting = sorting; + } + + @Override + public Cursor getCursor() { + return createThreadMediaCursor(context, threadId, mediaType, sorting); + } + + static Cursor createThreadMediaCursor(@NonNull Context context, + long threadId, + @NonNull MediaType mediaType, + @NonNull MediaDatabase.Sorting sorting) { + MediaDatabase mediaDatabase = DatabaseFactory.getMediaDatabase(context); + + switch (mediaType) { + case GALLERY : return mediaDatabase.getGalleryMediaForThread(threadId, sorting); + case DOCUMENT: return mediaDatabase.getDocumentMediaForThread(threadId, sorting); + case AUDIO : return mediaDatabase.getAudioMediaForThread(threadId, sorting); + case ALL : return mediaDatabase.getAllMediaForThread(threadId, sorting); + default : throw new AssertionError(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java new file mode 100644 index 00000000..d1e22a32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.text.SpannableString; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * The base class for all message record models. Encapsulates basic data + * shared between ThreadRecord and MessageRecord. + * + * @author Moxie Marlinspike + * + */ + +public abstract class DisplayRecord { + + protected final long type; + + private final Recipient recipient; + private final long dateSent; + private final long dateReceived; + private final long threadId; + private final String body; + private final int deliveryStatus; + private final int deliveryReceiptCount; + private final int readReceiptCount; + private final int viewReceiptCount; + + DisplayRecord(String body, Recipient recipient, long dateSent, + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, int readReceiptCount, int viewReceiptCount) + { + this.threadId = threadId; + this.recipient = recipient; + this.dateSent = dateSent; + this.dateReceived = dateReceived; + this.type = type; + this.body = body; + this.deliveryReceiptCount = deliveryReceiptCount; + this.readReceiptCount = readReceiptCount; + this.deliveryStatus = deliveryStatus; + this.viewReceiptCount = viewReceiptCount; + } + + public @NonNull String getBody() { + return body == null ? "" : body; + } + + public boolean isFailed() { + return + MmsSmsColumns.Types.isFailedMessageType(type) || + MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) || + deliveryStatus >= SmsDatabase.Status.STATUS_FAILED; + } + + public boolean isPending() { + return MmsSmsColumns.Types.isPendingMessageType(type) && + !MmsSmsColumns.Types.isIdentityVerified(type) && + !MmsSmsColumns.Types.isIdentityDefault(type); + } + + public boolean isSent() { + return MmsSmsColumns.Types.isSentType(type); + } + + public boolean isOutgoing() { + return MmsSmsColumns.Types.isOutgoingMessageType(type); + } + + public abstract SpannableString getDisplayBody(@NonNull Context context); + + public Recipient getRecipient() { + return recipient.live().get(); + } + + public long getDateSent() { + return dateSent; + } + + public long getDateReceived() { + return dateReceived; + } + + public long getThreadId() { + return threadId; + } + + public boolean isKeyExchange() { + return SmsDatabase.Types.isKeyExchangeType(type); + } + + public boolean isEndSession() { + return SmsDatabase.Types.isEndSessionType(type); + } + + public boolean isGroupUpdate() { + return SmsDatabase.Types.isGroupUpdate(type); + } + + public boolean isGroupV2() { + return SmsDatabase.Types.isGroupV2(type); + } + + public boolean isGroupQuit() { + return SmsDatabase.Types.isGroupQuit(type); + } + + public boolean isGroupAction() { + return isGroupUpdate() || isGroupQuit(); + } + + public boolean isExpirationTimerUpdate() { + return SmsDatabase.Types.isExpirationTimerUpdate(type); + } + + public boolean isCallLog() { + return SmsDatabase.Types.isCallLog(type); + } + + public boolean isJoined() { + return SmsDatabase.Types.isJoinedType(type); + } + + public boolean isIncomingAudioCall() { + return SmsDatabase.Types.isIncomingAudioCall(type); + } + + public boolean isIncomingVideoCall() { + return SmsDatabase.Types.isIncomingVideoCall(type); + } + + public boolean isOutgoingAudioCall() { + return SmsDatabase.Types.isOutgoingAudioCall(type); + } + + public boolean isOutgoingVideoCall() { + return SmsDatabase.Types.isOutgoingVideoCall(type); + } + + public final boolean isMissedAudioCall() { + return SmsDatabase.Types.isMissedAudioCall(type); + } + + public final boolean isMissedVideoCall() { + return SmsDatabase.Types.isMissedVideoCall(type); + } + + public final boolean isGroupCall() { + return SmsDatabase.Types.isGroupCall(type); + } + + public boolean isVerificationStatusChange() { + return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type); + } + + public boolean isProfileChange() { + return SmsDatabase.Types.isProfileChange(type); + } + + public int getDeliveryStatus() { + return deliveryStatus; + } + + public int getDeliveryReceiptCount() { + return deliveryReceiptCount; + } + + public int getReadReceiptCount() { + return readReceiptCount; + } + + /** + * For outgoing messages, this is incremented whenever a remote recipient has viewed our message + * and sends us a VIEWED receipt. For incoming messages, this is an indication of whether local + * user has viewed a piece of content. + * + * @return the number of times this has been viewed. + */ + public int getViewedReceiptCount() { + return viewReceiptCount; + } + + public boolean isDelivered() { + return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE && + deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; + } + + public boolean isRemoteRead() { + return readReceiptCount > 0; + } + + public boolean isPendingInsecureSmsFallback() { + return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java new file mode 100644 index 00000000..99206f9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.List; + +public final class GroupCallUpdateDetailsUtil { + + private static final String TAG = Log.tag(GroupCallUpdateDetailsUtil.class); + + private GroupCallUpdateDetailsUtil() { + } + + public static @NonNull GroupCallUpdateDetails parse(@Nullable String body) { + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetails.getDefaultInstance(); + + if (body == null) { + return groupCallUpdateDetails; + } + + try { + groupCallUpdateDetails = GroupCallUpdateDetails.parseFrom(Base64.decode(body)); + } catch (IOException e) { + Log.w(TAG, "Group call update details could not be read", e); + } + + return groupCallUpdateDetails; + } + + public static @NonNull String createUpdatedBody(@NonNull GroupCallUpdateDetails groupCallUpdateDetails, @NonNull List inCallUuids, boolean isCallFull) { + GroupCallUpdateDetails.Builder builder = groupCallUpdateDetails.toBuilder() + .setIsCallFull(isCallFull) + .clearInCallUuids(); + + if (Util.hasItems(inCallUuids)) { + builder.addAllInCallUuids(inCallUuids); + } + + return Base64.encodeBytes(builder.build().toByteArray()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java new file mode 100644 index 00000000..637229c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +/** + * Create a group call update message based on time and joined members. + */ +public class GroupCallUpdateMessageFactory implements UpdateDescription.StringFactory { + private final Context context; + private final List joinedMembers; + private final boolean withTime; + private final GroupCallUpdateDetails groupCallUpdateDetails; + private final UUID selfUuid; + + public GroupCallUpdateMessageFactory(@NonNull Context context, + @NonNull List joinedMembers, + boolean withTime, + @NonNull GroupCallUpdateDetails groupCallUpdateDetails) + { + this.context = context; + this.joinedMembers = new ArrayList<>(joinedMembers); + this.withTime = withTime; + this.groupCallUpdateDetails = groupCallUpdateDetails; + this.selfUuid = TextSecurePreferences.getLocalUuid(context); + + boolean removed = this.joinedMembers.remove(selfUuid); + if (removed) { + this.joinedMembers.add(selfUuid); + } + } + + @Override + public @NonNull String create() { + String time = DateUtils.getTimeString(context, Locale.getDefault(), groupCallUpdateDetails.getStartedCallTimestamp()); + + switch (joinedMembers.size()) { + case 0: + return withTime ? context.getString(R.string.MessageRecord_group_call_s, time) + : context.getString(R.string.MessageRecord_group_call); + case 1: + if (joinedMembers.get(0).toString().equals(groupCallUpdateDetails.getStartedCallUuid())) { + return withTime ? context.getString(R.string.MessageRecord_s_started_a_group_call_s, describe(joinedMembers.get(0)), time) + : context.getString(R.string.MessageRecord_s_started_a_group_call, describe(joinedMembers.get(0))); + } else if (Objects.equals(joinedMembers.get(0), selfUuid)) { + return withTime ? context.getString(R.string.MessageRecord_you_are_in_the_group_call_s1, time) + : context.getString(R.string.MessageRecord_you_are_in_the_group_call); + } else { + return withTime ? context.getString(R.string.MessageRecord_s_is_in_the_group_call_s, describe(joinedMembers.get(0)), time) + : context.getString(R.string.MessageRecord_s_is_in_the_group_call, describe(joinedMembers.get(0))); + } + case 2: + return withTime ? context.getString(R.string.MessageRecord_s_and_s_are_in_the_group_call_s1, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1)), + time) + : context.getString(R.string.MessageRecord_s_and_s_are_in_the_group_call, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1))); + default: + int others = joinedMembers.size() - 2; + return withTime ? context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_group_call_s, + others, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1)), + others, + time) + : context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_group_call, + others, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1)), + others); + } + } + + private @NonNull String describe(@NonNull UUID uuid) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + return context.getString(R.string.MessageRecord_unknown); + } + + Recipient recipient = Recipient.resolved(RecipientId.from(uuid, null)); + + if (recipient.isSelf()) { + return context.getString(R.string.MessageRecord_you); + } else { + return recipient.getShortDisplayName(context); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java new file mode 100644 index 00000000..79d43778 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -0,0 +1,738 @@ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.StringUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +final class GroupsV2UpdateMessageProducer { + + @NonNull private final Context context; + @NonNull private final DescribeMemberStrategy descriptionStrategy; + @NonNull private final UUID selfUuid; + @NonNull private final ByteString selfUuidBytes; + + /** + * @param descriptionStrategy Strategy for member description. + */ + GroupsV2UpdateMessageProducer(@NonNull Context context, + @NonNull DescribeMemberStrategy descriptionStrategy, + @NonNull UUID selfUuid) { + this.context = context; + this.descriptionStrategy = descriptionStrategy; + this.selfUuid = selfUuid; + this.selfUuidBytes = UuidUtil.toByteString(selfUuid); + } + + /** + * Describes a group that is new to you, use this when there is no available change record. + *

+ * Invitation and revision 0 groups are the most common use cases for this. + *

+ * When invited, it's possible there's no change available. + *

+ * When the revision of the group is 0, the change is very noisy and only the editor is useful. + */ + UpdateDescription describeNewGroup(@NonNull DecryptedGroup group, @NonNull DecryptedGroupChange decryptedGroupChange) { + Optional selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid); + if (selfPending.isPresent()) { + return updateDescription(selfPending.get().getAddedByUuid(), inviteBy -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, inviteBy), R.drawable.ic_update_group_add_16); + } + + ByteString foundingMemberUuid = decryptedGroupChange.getEditor(); + if (!foundingMemberUuid.isEmpty()) { + if (selfUuidBytes.equals(foundingMemberUuid)) { + return updateDescription(context.getString(R.string.MessageRecord_you_created_the_group), R.drawable.ic_update_group_16); + } else { + return updateDescription(foundingMemberUuid, creator -> context.getString(R.string.MessageRecord_s_added_you, creator), R.drawable.ic_update_group_add_16); + } + } + + if (DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid).isPresent()) { + return updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group), R.drawable.ic_update_group_add_16); + } else { + return updateDescription(context.getString(R.string.MessageRecord_group_updated), R.drawable.ic_update_group_16); + } + } + + List describeChanges(@Nullable DecryptedGroup previousGroupState, @NonNull DecryptedGroupChange change) { + if (DecryptedGroup.getDefaultInstance().equals(previousGroupState)) { + previousGroupState = null; + } + + List updates = new LinkedList<>(); + + if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) { + describeUnknownEditorMemberAdditions(change, updates); + + describeUnknownEditorModifyMemberRoles(change, updates); + describeUnknownEditorInvitations(change, updates); + describeUnknownEditorRevokedInvitations(change, updates); + describeUnknownEditorPromotePending(change, updates); + describeUnknownEditorNewTitle(change, updates); + describeUnknownEditorNewAvatar(change, updates); + describeUnknownEditorNewTimer(change, updates); + describeUnknownEditorNewAttributeAccess(change, updates); + describeUnknownEditorNewMembershipAccess(change, updates); + describeUnknownEditorNewGroupInviteLinkAccess(previousGroupState, change, updates); + describeRequestingMembers(change, updates); + describeUnknownEditorRequestingMembersApprovals(change, updates); + describeUnknownEditorRequestingMembersDeletes(change, updates); + + describeUnknownEditorMemberRemovals(change, updates); + + if (updates.isEmpty()) { + describeUnknownEditorUnknownChange(updates); + } + + } else { + describeMemberAdditions(change, updates); + + describeModifyMemberRoles(change, updates); + describeInvitations(change, updates); + describeRevokedInvitations(change, updates); + describePromotePending(change, updates); + describeNewTitle(change, updates); + describeNewAvatar(change, updates); + describeNewTimer(change, updates); + describeNewAttributeAccess(change, updates); + describeNewMembershipAccess(change, updates); + describeNewGroupInviteLinkAccess(previousGroupState, change, updates); + describeRequestingMembers(change, updates); + describeRequestingMembersApprovals(change, updates); + describeRequestingMembersDeletes(change, updates); + + describeMemberRemovals(change, updates); + + if (updates.isEmpty()) { + describeUnknownChange(change, updates); + } + } + + return updates; + } + + /** + * Handles case of future protocol versions where we don't know what has changed. + */ + private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_updated_group), R.drawable.ic_update_group_16)); + } else { + updates.add(updateDescription(change.getEditor(), (editor) -> context.getString(R.string.MessageRecord_s_updated_group, editor), R.drawable.ic_update_group_16)); + } + } + + private void describeUnknownEditorUnknownChange(@NonNull List updates) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_updated), R.drawable.ic_update_group_16)); + } + + private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + for (DecryptedMember member : change.getNewMembersList()) { + boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes); + + if (editorIsYou) { + if (newMemberIsYou) { + updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group_via_the_group_link), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(member.getUuid(), added -> context.getString(R.string.MessageRecord_you_added_s, added), R.drawable.ic_update_group_add_16)); + } + } else { + if (newMemberIsYou) { + updates.add(0, updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor), R.drawable.ic_update_group_add_16)); + } else { + if (member.getUuid().equals(change.getEditor())) { + updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group_via_the_group_link, newMember), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(change.getEditor(), member.getUuid(), (editor, newMember) -> context.getString(R.string.MessageRecord_s_added_s, editor, newMember), R.drawable.ic_update_group_add_16)); + } + } + } + } + } + + private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedMember member : change.getNewMembersList()) { + boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes); + + if (newMemberIsYou) { + updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group), R.drawable.ic_update_group_add_16)); + } else { + updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember), R.drawable.ic_update_group_add_16)); + } + } + } + + private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + for (ByteString member : change.getDeleteMembersList()) { + boolean removedMemberIsYou = member.equals(selfUuidBytes); + + if (editorIsYou) { + if (removedMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_left_the_group), R.drawable.ic_update_group_leave_16)); + } else { + updates.add(updateDescription(member, removedMember -> context.getString(R.string.MessageRecord_you_removed_s, removedMember), R.drawable.ic_update_group_remove_16)); + } + } else { + if (removedMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_removed_you_from_the_group, editor), R.drawable.ic_update_group_remove_16)); + } else { + if (member.equals(change.getEditor())) { + updates.add(updateDescription(member, leavingMember -> context.getString(R.string.MessageRecord_s_left_the_group, leavingMember), R.drawable.ic_update_group_leave_16)); + } else { + updates.add(updateDescription(change.getEditor(), member, (editor, removedMember) -> context.getString(R.string.MessageRecord_s_removed_s, editor, removedMember), R.drawable.ic_update_group_remove_16)); + } + } + } + } + } + + private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (ByteString member : change.getDeleteMembersList()) { + boolean removedMemberIsYou = member.equals(selfUuidBytes); + + if (removedMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_in_the_group), R.drawable.ic_update_group_leave_16)); + } else { + updates.add(updateDescription(member, oldMember -> context.getString(R.string.MessageRecord_s_is_no_longer_in_the_group, oldMember), R.drawable.ic_update_group_leave_16)); + } + } + } + + private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { + boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); + if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { + if (editorIsYou) { + updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_you_made_s_an_admin, newAdmin), R.drawable.ic_update_group_role_16)); + } else { + if (changedMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_made_you_an_admin, editor), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, newAdmin) -> context.getString(R.string.MessageRecord_s_made_s_an_admin, editor, newAdmin), R.drawable.ic_update_group_role_16)); + + } + } + } else { + if (editorIsYou) { + updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, oldAdmin), R.drawable.ic_update_group_role_16)); + } else { + if (changedMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, editor), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, oldAdmin) -> context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, editor, oldAdmin), R.drawable.ic_update_group_role_16)); + } + } + } + } + } + + private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) { + boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes); + + if (roleChange.getRole() == Member.Role.ADMINISTRATOR) { + if (changedMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_now_an_admin), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_s_is_now_an_admin, newAdmin), R.drawable.ic_update_group_role_16)); + } + } else { + if (changedMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_an_admin), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_s_is_no_longer_an_admin, oldAdmin), R.drawable.ic_update_group_role_16)); + } + } + } + } + + private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + int notYouInviteCount = 0; + + for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { + boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); + + if (newMemberIsYou) { + updates.add(0, updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, editor), R.drawable.ic_update_group_add_16)); + } else { + if (editorIsYou) { + updates.add(updateDescription(invitee.getUuid(), newInvitee -> context.getString(R.string.MessageRecord_you_invited_s_to_the_group, newInvitee), R.drawable.ic_update_group_add_16)); + } else { + notYouInviteCount++; + } + } + } + + if (notYouInviteCount > 0) { + final int notYouInviteCountFinalCopy = notYouInviteCount; + updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCountFinalCopy, editor, notYouInviteCountFinalCopy), R.drawable.ic_update_group_add_16)); + } + } + + private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + int notYouInviteCount = 0; + + for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) { + boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes); + + if (newMemberIsYou) { + UUID uuid = UuidUtil.fromByteStringOrUnknown(invitee.getAddedByUuid()); + + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_were_invited_to_the_group), R.drawable.ic_update_group_add_16)); + } else { + updates.add(0, updateDescription(invitee.getAddedByUuid(), editor -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, editor), R.drawable.ic_update_group_add_16)); + } + } else { + notYouInviteCount++; + } + } + + if (notYouInviteCount > 0) { + updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_people_were_invited_to_the_group, notYouInviteCount, notYouInviteCount), R.drawable.ic_update_group_add_16)); + } + } + + private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + int notDeclineCount = 0; + + for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) { + boolean decline = invitee.getUuid().equals(change.getEditor()); + if (decline) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_declined_the_invitation_to_the_group), R.drawable.ic_update_group_decline_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group), R.drawable.ic_update_group_decline_16)); + } + } else if (invitee.getUuid().equals(selfUuidBytes)) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_revoked_your_invitation_to_the_group, editor), R.drawable.ic_update_group_decline_16)); + } else { + notDeclineCount++; + } + } + + if (notDeclineCount > 0) { + if (editorIsYou) { + updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_you_revoked_invites, notDeclineCount, notDeclineCount), R.drawable.ic_update_group_decline_16)); + } else { + final int notDeclineCountFinalCopy = notDeclineCount; + updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_revoked_invites, notDeclineCountFinalCopy, editor, notDeclineCountFinalCopy), R.drawable.ic_update_group_decline_16)); + } + } + } + + private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List updates) { + int notDeclineCount = 0; + + for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) { + boolean inviteeWasYou = invitee.getUuid().equals(selfUuidBytes); + + if (inviteeWasYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_an_admin_revoked_your_invitation_to_the_group), R.drawable.ic_update_group_decline_16)); + } else { + notDeclineCount++; + } + } + + if (notDeclineCount > 0) { + updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_invitations_were_revoked, notDeclineCount, notDeclineCount), R.drawable.ic_update_group_decline_16)); + } + } + + private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + for (DecryptedMember newMember : change.getPromotePendingMembersList()) { + ByteString uuid = newMember.getUuid(); + boolean newMemberIsYou = uuid.equals(selfUuidBytes); + + if (editorIsYou) { + if (newMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_accepted_invite), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(uuid, newPromotedMember -> context.getString(R.string.MessageRecord_you_added_invited_member_s, newPromotedMember), R.drawable.ic_update_group_add_16)); + } + } else { + if (newMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor), R.drawable.ic_update_group_add_16)); + } else { + if (uuid.equals(change.getEditor())) { + updates.add(updateDescription(uuid, newAcceptedMember -> context.getString(R.string.MessageRecord_s_accepted_invite, newAcceptedMember), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(change.getEditor(), uuid, (editor, newAcceptedMember) -> context.getString(R.string.MessageRecord_s_added_invited_member_s, editor, newAcceptedMember), R.drawable.ic_update_group_add_16)); + } + } + } + } + } + + private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedMember newMember : change.getPromotePendingMembersList()) { + ByteString uuid = newMember.getUuid(); + boolean newMemberIsYou = uuid.equals(selfUuidBytes); + + if (newMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group), R.drawable.ic_update_group_add_16)); + } else { + updates.add(updateDescription(uuid, newMemberName -> context.getString(R.string.MessageRecord_s_joined_the_group, newMemberName), R.drawable.ic_update_group_add_16)); + } + } + } + + private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.hasNewTitle()) { + String newTitle = StringUtil.isolateBidi(change.getNewTitle().getValue()); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle), R.drawable.ic_update_group_name_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, editor, newTitle), R.drawable.ic_update_group_name_16)); + } + } + } + + private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.hasNewTitle()) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(change.getNewTitle().getValue())), R.drawable.ic_update_group_name_16)); + } + } + + private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.hasNewAvatar()) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_avatar), R.drawable.ic_update_group_avatar_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_avatar, editor), R.drawable.ic_update_group_avatar_16)); + } + } + } + + private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.hasNewAvatar()) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_group_avatar_has_been_changed), R.drawable.ic_update_group_avatar_16)); + } + } + + private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.hasNewTimer()) { + String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration()); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, editor, time), R.drawable.ic_update_timer_16)); + } + } + } + + private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.hasNewTimer()) { + String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration()); + updates.add(updateDescription(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time), R.drawable.ic_update_timer_16)); + } + } + + private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { + String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess()); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_info_to_s, accessLevel), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, editor, accessLevel), R.drawable.ic_update_group_role_16)); + } + } + } + + private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { + String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess()); + updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_info_has_been_changed_to_s, accessLevel), R.drawable.ic_update_group_role_16)); + } + } + + private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { + String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess()); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_membership_to_s, accessLevel), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, editor, accessLevel), R.drawable.ic_update_group_role_16)); + } + } + } + + private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { + String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess()); + updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_membership_has_been_changed_to_s, accessLevel), R.drawable.ic_update_group_role_16)); + } + } + + private void describeNewGroupInviteLinkAccess(@Nullable DecryptedGroup previousGroupState, + @NonNull DecryptedGroupChange change, + @NonNull List updates) + { + AccessControl.AccessRequired previousAccessControl = null; + + if (previousGroupState != null) { + previousAccessControl = previousGroupState.getAccessControl().getAddFromInviteLink(); + } + + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + boolean groupLinkEnabled = false; + + switch (change.getNewInviteLinkAccess()) { + case ANY: + groupLinkEnabled = true; + if (editorIsYou) { + if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_off_admin_approval_for_the_group_link), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_off), R.drawable.ic_update_group_role_16)); + } + } else { + if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_off_admin_approval_for_the_group_link, editor), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_off, editor), R.drawable.ic_update_group_role_16)); + } + } + break; + case ADMINISTRATOR: + groupLinkEnabled = true; + if (editorIsYou) { + if (previousAccessControl == AccessControl.AccessRequired.ANY) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_admin_approval_for_the_group_link), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_on), R.drawable.ic_update_group_role_16)); + } + } else { + if (previousAccessControl == AccessControl.AccessRequired.ANY) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_admin_approval_for_the_group_link, editor), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_on, editor), R.drawable.ic_update_group_role_16)); + } + } + break; + case UNSATISFIABLE: + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_off_the_group_link), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_off_the_group_link, editor), R.drawable.ic_update_group_role_16)); + } + break; + } + + if (!groupLinkEnabled && change.getNewInviteLinkPassword().size() > 0) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_reset_the_group_link), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_reset_the_group_link, editor), R.drawable.ic_update_group_role_16)); + } + } + } + + private void describeUnknownEditorNewGroupInviteLinkAccess(@Nullable DecryptedGroup previousGroupState, + @NonNull DecryptedGroupChange change, + @NonNull List updates) + { + AccessControl.AccessRequired previousAccessControl = null; + + if (previousGroupState != null) { + previousAccessControl = previousGroupState.getAccessControl().getAddFromInviteLink(); + } + + switch (change.getNewInviteLinkAccess()) { + case ANY: + if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_admin_approval_for_the_group_link_has_been_turned_off), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_off), R.drawable.ic_update_group_role_16)); + } + break; + case ADMINISTRATOR: + if (previousAccessControl == AccessControl.AccessRequired.ANY) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_admin_approval_for_the_group_link_has_been_turned_on), R.drawable.ic_update_group_role_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_on), R.drawable.ic_update_group_role_16)); + } + break; + case UNSATISFIABLE: + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_off), R.drawable.ic_update_group_role_16)); + break; + } + + if (change.getNewInviteLinkPassword().size() > 0) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_reset), R.drawable.ic_update_group_role_16)); + } + } + + private void describeRequestingMembers(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedRequestingMember member : change.getNewRequestingMembersList()) { + boolean requestingMemberIsYou = member.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_sent_a_request_to_join_the_group), R.drawable.ic_update_group_16)); + } else { + updates.add(updateDescription(member.getUuid(), requesting -> context.getString(R.string.MessageRecord_s_requested_to_join_via_the_group_link, requesting), R.drawable.ic_update_group_16)); + } + } + } + + private void describeRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor), R.drawable.ic_update_group_accept_16)); + } else { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (editorIsYou) { + updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_you_approved_a_request_to_join_the_group_from_s, requesting), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting), R.drawable.ic_update_group_accept_16)); + } + } + } + } + + private void describeUnknownEditorRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_approved), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_a_request_to_join_the_group_from_s_has_been_approved, requesting), R.drawable.ic_update_group_accept_16)); + } + } + } + + private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + for (ByteString requestingMember : change.getDeleteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes); + + if (requestingMemberIsYou) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_canceled_your_request_to_join_the_group), R.drawable.ic_update_group_decline_16)); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin), R.drawable.ic_update_group_decline_16)); + } + } else { + boolean editorIsCanceledMember = change.getEditor().equals(requestingMember); + + if (editorIsCanceledMember) { + updates.add(updateDescription(requestingMember, editorRequesting -> context.getString(R.string.MessageRecord_s_canceled_their_request_to_join_the_group, editorRequesting), R.drawable.ic_update_group_decline_16)); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting), R.drawable.ic_update_group_decline_16)); + } + } + } + } + + private void describeUnknownEditorRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (ByteString requestingMember : change.getDeleteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin), R.drawable.ic_update_group_decline_16)); + } else { + updates.add(updateDescription(requestingMember, requesting -> context.getString(R.string.MessageRecord_a_request_to_join_the_group_from_s_has_been_denied, requesting), R.drawable.ic_update_group_decline_16)); + } + } + } + + interface DescribeMemberStrategy { + + /** + * Map a UUID to a string that describes the group member. + */ + @NonNull + @WorkerThread + String describe(@NonNull UUID uuid); + } + + private interface StringFactory1Arg { + String create(String arg1); + } + + private interface StringFactory2Args { + String create(String arg1, String arg2); + } + + private static UpdateDescription updateDescription(@NonNull String string, + @DrawableRes int iconResource) + { + return UpdateDescription.staticDescription(string, iconResource); + } + + private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, + @NonNull StringFactory1Arg stringFactory, + @DrawableRes int iconResource) + { + UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes); + + return UpdateDescription.mentioning(Collections.singletonList(uuid1), () -> stringFactory.create(descriptionStrategy.describe(uuid1)), iconResource); + } + + private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, + @NonNull ByteString uuid2Bytes, + @NonNull StringFactory2Args stringFactory, + @DrawableRes int iconResource) + { + UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes); + UUID uuid2 = UuidUtil.fromByteStringOrUnknown(uuid2Bytes); + + return UpdateDescription.mentioning(Arrays.asList(uuid1, uuid2), () -> stringFactory.create(descriptionStrategy.describe(uuid1), descriptionStrategy.describe(uuid2)), iconResource); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/IncomingSticker.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/IncomingSticker.java new file mode 100644 index 00000000..1f69a78d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/IncomingSticker.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class IncomingSticker { + + private final String packKey; + private final String packId; + private final String packTitle; + private final String packAuthor; + private final int stickerId; + private final String emoji; + private final String contentType; + private final boolean isCover; + private final boolean isInstalled; + + public IncomingSticker(@NonNull String packId, + @NonNull String packKey, + @NonNull String packTitle, + @NonNull String packAuthor, + int stickerId, + @NonNull String emoji, + @Nullable String contentType, + boolean isCover, + boolean isInstalled) + { + this.packId = packId; + this.packKey = packKey; + this.packTitle = packTitle; + this.packAuthor = packAuthor; + this.stickerId = stickerId; + this.emoji = emoji; + this.contentType = contentType; + this.isCover = isCover; + this.isInstalled = isInstalled; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackTitle() { + return packTitle; + } + + public @NonNull String getPackAuthor() { + return packAuthor; + } + + public int getStickerId() { + return stickerId; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public @Nullable String getContentType() { + return contentType; + } + + public boolean isCover() { + return isCover; + } + + public boolean isInstalled() { + return isInstalled; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java new file mode 100644 index 00000000..f7c8cbe2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/LiveUpdateMessage.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; + +import androidx.annotation.AnyThread; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Function; + +import java.util.List; + +public final class LiveUpdateMessage { + + /** + * Creates a live data that observes the recipients mentioned in the {@link UpdateDescription} and + * recreates the string asynchronously when they change. + */ + @AnyThread + public static LiveData fromMessageDescription(@NonNull Context context, @NonNull UpdateDescription updateDescription, @ColorInt int defaultTint) { + if (updateDescription.isStringStatic()) { + return LiveDataUtil.just(toSpannable(context, updateDescription, updateDescription.getStaticString(), defaultTint)); + } + + List> allMentionedRecipients = Stream.of(updateDescription.getMentioned()) + .map(uuid -> Recipient.resolved(RecipientId.from(uuid, null)).live().getLiveData()) + .toList(); + + LiveData mentionedRecipientChangeStream = allMentionedRecipients.isEmpty() ? LiveDataUtil.just(new Object()) + : LiveDataUtil.merge(allMentionedRecipients); + + return LiveDataUtil.mapAsync(mentionedRecipientChangeStream, event -> toSpannable(context, updateDescription, updateDescription.getString(), defaultTint)); + } + + /** + * Observes a single recipient and recreates the string asynchronously when they change. + */ + public static LiveData recipientToStringAsync(@NonNull RecipientId recipientId, + @NonNull Function createStringInBackground) + { + return LiveDataUtil.mapAsync(Recipient.live(recipientId).getLiveDataResolved(), createStringInBackground); + } + + private static @NonNull SpannableString toSpannable(@NonNull Context context, @NonNull UpdateDescription updateDescription, @NonNull String string, @ColorInt int defaultTint) { + boolean isDarkTheme = ThemeUtil.isDarkTheme(context); + int drawableResource = updateDescription.getIconResource(); + int tint = isDarkTheme ? updateDescription.getDarkTint() : updateDescription.getLightTint(); + + if (tint == 0) { + tint = defaultTint; + } + + if (drawableResource == 0) { + return new SpannableString(string); + } else { + Drawable drawable = ContextUtil.requireDrawable(context, drawableResource); + drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + drawable.setColorFilter(tint, PorterDuff.Mode.SRC_ATOP); + + Spannable stringWithImage = new SpannableStringBuilder().append(SpanUtil.buildImageSpan(drawable)).append(" ").append(string); + + return new SpannableString(SpanUtil.color(tint, stringWithImage)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java new file mode 100644 index 00000000..ce3a50e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -0,0 +1,117 @@ +/** + * Copyright (C) 2012 Moxie Marlinspike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.text.SpannableString; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +/** + * Represents the message record model for MMS messages that contain + * media (ie: they've been downloaded). + * + * @author Moxie Marlinspike + * + */ + +public class MediaMmsMessageRecord extends MmsMessageRecord { + private final static String TAG = MediaMmsMessageRecord.class.getSimpleName(); + + private final int partCount; + private final boolean mentionsSelf; + + public MediaMmsMessageRecord(long id, + Recipient conversationRecipient, + Recipient individualRecipient, + int recipientDeviceId, + long dateSent, + long dateReceived, + long dateServer, + int deliveryReceiptCount, + long threadId, + String body, + @NonNull SlideDeck slideDeck, + int partCount, + long mailbox, + List mismatches, + List failures, + int subscriptionId, + long expiresIn, + long expireStarted, + boolean viewOnce, + int readReceiptCount, + @Nullable Quote quote, + @NonNull List contacts, + @NonNull List linkPreviews, + boolean unidentified, + @NonNull List reactions, + boolean remoteDelete, + boolean mentionsSelf, + long notifiedTimestamp, + int viewedReceiptCount) + { + super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, + dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, + subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck, + readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount); + this.partCount = partCount; + this.mentionsSelf = mentionsSelf; + } + + public int getPartCount() { + return partCount; + } + + @Override + public boolean hasSelfMention() { + return mentionsSelf; + } + + @Override + public boolean isMmsNotification() { + return false; + } + + @Override + public SpannableString getDisplayBody(@NonNull Context context) { + if (MmsDatabase.Types.isFailedDecryptType(type)) { + return emphasisAdded(context.getString(R.string.MmsMessageRecord_bad_encrypted_mms_message)); + } else if (MmsDatabase.Types.isDuplicateMessageType(type)) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); + } else if (MmsDatabase.Types.isNoRemoteSessionType(type)) { + return emphasisAdded(context.getString(R.string.MmsMessageRecord_mms_message_encrypted_for_non_existing_session)); + } else if (isLegacyMessage()) { + return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); + } + + return super.getDisplayBody(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java new file mode 100644 index 00000000..4ba070cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.megaphone.Megaphones; + +public class MegaphoneRecord { + + private final Megaphones.Event event; + private final int seenCount; + private final long lastSeen; + private final long firstVisible; + private final boolean finished; + + public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, long firstVisible, boolean finished) { + this.event = event; + this.seenCount = seenCount; + this.lastSeen = lastSeen; + this.firstVisible = firstVisible; + this.finished = finished; + } + + public @NonNull Megaphones.Event getEvent() { + return event; + } + + public int getSeenCount() { + return seenCount; + } + + public long getLastSeen() { + return lastSeen; + } + + public long getFirstVisible() { + return firstVisible; + } + + public boolean isFinished() { + return finished; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java new file mode 100644 index 00000000..cfe5c453 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.database.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public class Mention implements Comparable, Parcelable { + private final RecipientId recipientId; + private final int start; + private final int length; + + public Mention(@NonNull RecipientId recipientId, int start, int length) { + this.recipientId = recipientId; + this.start = start; + this.length = length; + } + + protected Mention(Parcel in) { + recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + start = in.readInt(); + length = in.readInt(); + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public int getStart() { + return start; + } + + public int getLength() { + return length; + } + + @Override + public int compareTo(Mention other) { + return Integer.compare(start, other.start); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, start, length); + } + + @Override + public boolean equals(@Nullable Object object) { + if (this == object) { + return true; + } + + if (object == null || getClass() != object.getClass()) { + return false; + } + + Mention that = (Mention) object; + return recipientId.equals(that.recipientId) && start == that.start && length == that.length; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(recipientId, flags); + dest.writeInt(start); + dest.writeInt(length); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Mention createFromParcel(Parcel in) { + return new Mention(in); + } + + @Override + public Mention[] newArray(int size) { + return new Mention[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java new file mode 100644 index 00000000..6b297126 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -0,0 +1,565 @@ +/* + * Copyright (C) 2012 Moxie Marlinpsike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Function; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * The base class for message record models that are displayed in + * conversations, as opposed to models that are displayed in a thread list. + * Encapsulates the shared data between both SMS and MMS messages. + * + * @author Moxie Marlinspike + * + */ +public abstract class MessageRecord extends DisplayRecord { + + private static final String TAG = Log.tag(MessageRecord.class); + + private final Recipient individualRecipient; + private final int recipientDeviceId; + private final long id; + private final List mismatches; + private final List networkFailures; + private final int subscriptionId; + private final long expiresIn; + private final long expireStarted; + private final boolean unidentified; + private final List reactions; + private final long serverTimestamp; + private final boolean remoteDelete; + private final long notifiedTimestamp; + + MessageRecord(long id, String body, Recipient conversationRecipient, + Recipient individualRecipient, int recipientDeviceId, + long dateSent, long dateReceived, long dateServer, long threadId, + int deliveryStatus, int deliveryReceiptCount, long type, + List mismatches, + List networkFailures, + int subscriptionId, long expiresIn, long expireStarted, + int readReceiptCount, boolean unidentified, + @NonNull List reactions, boolean remoteDelete, long notifiedTimestamp, + int viewedReceiptCount) + { + super(body, conversationRecipient, dateSent, dateReceived, + threadId, deliveryStatus, deliveryReceiptCount, type, + readReceiptCount, viewedReceiptCount); + this.id = id; + this.individualRecipient = individualRecipient; + this.recipientDeviceId = recipientDeviceId; + this.mismatches = mismatches; + this.networkFailures = networkFailures; + this.subscriptionId = subscriptionId; + this.expiresIn = expiresIn; + this.expireStarted = expireStarted; + this.unidentified = unidentified; + this.reactions = reactions; + this.serverTimestamp = dateServer; + this.remoteDelete = remoteDelete; + this.notifiedTimestamp = notifiedTimestamp; + } + + public abstract boolean isMms(); + public abstract boolean isMmsNotification(); + + public boolean isSecure() { + return MmsSmsColumns.Types.isSecureType(type); + } + + public boolean isLegacyMessage() { + return MmsSmsColumns.Types.isLegacyType(type); + } + + @Override + public SpannableString getDisplayBody(@NonNull Context context) { + UpdateDescription updateDisplayBody = getUpdateDisplayBody(context); + + if (updateDisplayBody != null) { + return new SpannableString(updateDisplayBody.getString()); + } + + return new SpannableString(getBody()); + } + + public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context) { + if (isGroupUpdate() && isGroupV2()) { + return getGv2ChangeDescription(context, getBody()); + } else if (isGroupUpdate() && isOutgoing()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_you_updated_group), R.drawable.ic_update_group_16); + } else if (isGroupUpdate()) { + return fromRecipient(getIndividualRecipient(), r -> GroupUtil.getNonV2GroupDescription(context, getBody()).toString(r), R.drawable.ic_update_group_16); + } else if (isGroupQuit() && isOutgoing()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_left_group), R.drawable.ic_update_group_leave_16); + } else if (isGroupQuit()) { + return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.ConversationItem_group_action_left, r.getDisplayName(context)), R.drawable.ic_update_group_leave_16); + } else if (isIncomingAudioCall()) { + return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_called_you_date, r.getDisplayName(context), getCallDateString(context)), R.drawable.ic_update_audio_call_incoming_16); + } else if (isIncomingVideoCall()) { + return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_called_you_date, r.getDisplayName(context), getCallDateString(context)), R.drawable.ic_update_video_call_incoming_16); + } else if (isOutgoingAudioCall()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_you_called_date, getCallDateString(context)), R.drawable.ic_update_audio_call_outgoing_16); + } else if (isOutgoingVideoCall()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_you_called_date, getCallDateString(context)), R.drawable.ic_update_video_call_outgoing_16); + } else if (isMissedAudioCall()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_audio_call_date, getCallDateString(context)), R.drawable.ic_update_audio_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); + } else if (isMissedVideoCall()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_video_call_date, getCallDateString(context)), R.drawable.ic_update_video_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); + } else if (isGroupCall()) { + return getGroupCallUpdateDescription(context, getBody(), true); + } else if (isJoined()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)), R.drawable.ic_update_group_add_16); + } else if (isExpirationTimerUpdate()) { + int seconds = (int)(getExpiresIn() / 1000); + if (seconds <= 0) { + return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages), R.drawable.ic_update_timer_disabled_16) + : fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, r.getDisplayName(context)), R.drawable.ic_update_timer_disabled_16); + } + String time = ExpirationUtil.getExpirationDisplayValue(context, seconds); + return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16) + : fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, r.getDisplayName(context), time), R.drawable.ic_update_timer_16); + } else if (isIdentityUpdate()) { + return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context)), R.drawable.ic_update_safety_number_16); + } else if (isIdentityVerified()) { + if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, r.getDisplayName(context)), R.drawable.ic_update_verified_16); + else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, r.getDisplayName(context)), R.drawable.ic_update_verified_16); + } else if (isIdentityDefault()) { + if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, r.getDisplayName(context)), R.drawable.ic_update_info_16); + else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, r.getDisplayName(context)), R.drawable.ic_update_info_16); + } else if (isProfileChange()) { + return staticUpdateDescription(getProfileChangeDescription(context), R.drawable.ic_update_profile_16); + } else if (isEndSession()) { + if (isOutgoing()) return staticUpdateDescription(context.getString(R.string.SmsMessageRecord_secure_session_reset), R.drawable.ic_update_info_16); + else return fromRecipient(getIndividualRecipient(), r-> context.getString(R.string.SmsMessageRecord_secure_session_reset_s, r.getDisplayName(context)), R.drawable.ic_update_info_16); + } else if (isGroupV1MigrationEvent()) { + return getGroupMigrationEventDescription(context); + } else if (isFailedDecryptionType()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_chat_session_refreshed), R.drawable.ic_refresh_16); + } + + return null; + } + + public boolean isSelfCreatedGroup() { + DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context(); + + if (decryptedGroupV2Context == null) { + return false; + } + DecryptedGroupChange change = decryptedGroupV2Context.getChange(); + + return selfCreatedGroup(change); + } + + private @Nullable DecryptedGroupV2Context getDecryptedGroupV2Context() { + if (!isGroupUpdate() || !isGroupV2()) { + return null; + } + + DecryptedGroupV2Context decryptedGroupV2Context; + try { + byte[] decoded = Base64.decode(getBody()); + decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded); + + } catch (IOException e) { + Log.w(TAG, "GV2 Message update detail could not be read", e); + decryptedGroupV2Context = null; + } + return decryptedGroupV2Context; + } + + private static boolean selfCreatedGroup(@NonNull DecryptedGroupChange change) { + return change.getRevision() == 0 && + change.getEditor().equals(UuidUtil.toByteString(Recipient.self().requireUuid())); + } + + public static @NonNull UpdateDescription getGv2ChangeDescription(@NonNull Context context, @NonNull String body) { + try { + ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context); + byte[] decoded = Base64.decode(body); + DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded); + GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get()); + + if (decryptedGroupV2Context.hasChange() && (decryptedGroupV2Context.getGroupState().getRevision() != 0 || decryptedGroupV2Context.hasPreviousGroupState())) { + return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getPreviousGroupState(), decryptedGroupV2Context.getChange())); + } else if (selfCreatedGroup(decryptedGroupV2Context.getChange())) { + return UpdateDescription.concatWithNewLines(Arrays.asList(updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange()), + staticUpdateDescription(context.getString(R.string.MessageRecord_invite_friends_to_this_group), 0))); + } else { + return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange()); + } + } catch (IOException e) { + Log.w(TAG, "GV2 Message update detail could not be read", e); + return staticUpdateDescription(context.getString(R.string.MessageRecord_group_updated), R.drawable.ic_update_group_16); + } + } + + public @Nullable InviteAddState getGv2AddInviteState() { + DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context(); + + if (decryptedGroupV2Context == null) { + return null; + } + + DecryptedGroup groupState = decryptedGroupV2Context.getGroupState(); + boolean invited = DecryptedGroupUtil.findPendingByUuid(groupState.getPendingMembersList(), Recipient.self().requireUuid()).isPresent(); + + if (decryptedGroupV2Context.hasChange()) { + UUID changeEditor = UuidUtil.fromByteStringOrNull(decryptedGroupV2Context.getChange().getEditor()); + + if (changeEditor != null) { + return new InviteAddState(invited, changeEditor); + } + } + + Log.w(TAG, "GV2 Message editor could not be determined"); + return null; + } + + private @NonNull String getCallDateString(@NonNull Context context) { + return DateUtils.getExtendedRelativeTimeSpanString(context, Locale.getDefault(), getDateSent()); + } + + private static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, + @NonNull Function stringGenerator, + @DrawableRes int iconResource) + { + return UpdateDescription.mentioning(Collections.singletonList(recipient.getUuid().or(UuidUtil.UNKNOWN_UUID)), + () -> stringGenerator.apply(recipient.resolve()), + iconResource); + } + + private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, + @DrawableRes int iconResource) + { + return UpdateDescription.staticDescription(string, iconResource); + } + + private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, + @DrawableRes int iconResource, + @ColorInt int lightTint, + @ColorInt int darkTint) + { + return UpdateDescription.staticDescription(string, iconResource, lightTint, darkTint); + } + + private @NonNull String getProfileChangeDescription(@NonNull Context context) { + try { + byte[] decoded = Base64.decode(getBody()); + ProfileChangeDetails profileChangeDetails = ProfileChangeDetails.parseFrom(decoded); + + if (profileChangeDetails.hasProfileNameChange()) { + String displayName = getIndividualRecipient().getDisplayName(context); + String newName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getNew()).toString()); + String previousName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getPrevious()).toString()); + + if (getIndividualRecipient().isSystemContact()) { + return context.getString(R.string.MessageRecord_changed_their_profile_name_from_to, displayName, previousName, newName); + } else { + return context.getString(R.string.MessageRecord_changed_their_profile_name_to, previousName, newName); + } + } + } catch (IOException e) { + Log.w(TAG, "Profile name change details could not be read", e); + } + + return context.getString(R.string.MessageRecord_changed_their_profile, getIndividualRecipient().getDisplayName(context)); + } + + private UpdateDescription getGroupMigrationEventDescription(@NonNull Context context) { + if (Util.isEmpty(getBody())) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_this_group_was_updated_to_a_new_group), R.drawable.ic_update_group_role_16); + } else { + GroupMigrationMembershipChange change = getGroupV1MigrationMembershipChanges(); + List updates = new ArrayList<>(2); + + if (change.getPending().size() == 1 && change.getPending().get(0).equals(Recipient.self().getId())) { + updates.add(staticUpdateDescription(context.getString(R.string.MessageRecord_you_couldnt_be_added_to_the_new_group_and_have_been_invited_to_join), R.drawable.ic_update_group_add_16)); + } else if (change.getPending().size() > 0) { + int count = change.getPending().size(); + updates.add(staticUpdateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_members_couldnt_be_added_to_the_new_group_and_have_been_invited, count, count), R.drawable.ic_update_group_add_16)); + } + + if (change.getDropped().size() > 0) { + int count = change.getDropped().size(); + updates.add(staticUpdateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_members_couldnt_be_added_to_the_new_group_and_have_been_removed, count, count), R.drawable.ic_update_group_remove_16)); + } + + return UpdateDescription.concatWithNewLines(updates); + } + } + + public static @NonNull UpdateDescription getGroupCallUpdateDescription(@NonNull Context context, @NonNull String body, boolean withTime) { + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(body); + + List joinedMembers = Stream.of(groupCallUpdateDetails.getInCallUuidsList()) + .map(UuidUtil::parseOrNull) + .withoutNulls() + .toList(); + + UpdateDescription.StringFactory stringFactory = new GroupCallUpdateMessageFactory(context, joinedMembers, withTime, groupCallUpdateDetails); + + return UpdateDescription.mentioning(joinedMembers, stringFactory, R.drawable.ic_video_16); + } + + /** + * Describes a UUID by it's corresponding recipient's {@link Recipient#getDisplayName(Context)}. + */ + private static class ShortStringDescriptionStrategy implements GroupsV2UpdateMessageProducer.DescribeMemberStrategy { + + private final Context context; + + ShortStringDescriptionStrategy(@NonNull Context context) { + this.context = context; + } + + @Override + public @NonNull String describe(@NonNull UUID uuid) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + return context.getString(R.string.MessageRecord_unknown); + } + return Recipient.resolved(RecipientId.from(uuid, null)).getDisplayName(context); + } + } + + public long getId() { + return id; + } + + public boolean isPush() { + return SmsDatabase.Types.isPushType(type) && !SmsDatabase.Types.isForcedSms(type); + } + + public long getTimestamp() { + if ((isPush() || isCallLog()) && getDateSent() < getDateReceived()) { + return getDateSent(); + } + return getDateReceived(); + } + + public long getServerTimestamp() { + return serverTimestamp; + } + + public boolean isForcedSms() { + return SmsDatabase.Types.isForcedSms(type); + } + + public boolean isIdentityVerified() { + return SmsDatabase.Types.isIdentityVerified(type); + } + + public boolean isIdentityDefault() { + return SmsDatabase.Types.isIdentityDefault(type); + } + + public boolean isIdentityMismatchFailure() { + return mismatches != null && !mismatches.isEmpty(); + } + + public boolean isBundleKeyExchange() { + return SmsDatabase.Types.isBundleKeyExchange(type); + } + + public boolean isContentBundleKeyExchange() { + return SmsDatabase.Types.isContentBundleKeyExchange(type); + } + + public boolean isIdentityUpdate() { + return SmsDatabase.Types.isIdentityUpdate(type); + } + + public boolean isCorruptedKeyExchange() { + return SmsDatabase.Types.isCorruptedKeyExchange(type); + } + + public boolean isInvalidVersionKeyExchange() { + return SmsDatabase.Types.isInvalidVersionKeyExchange(type); + } + + public boolean isGroupV1MigrationEvent() { + return SmsDatabase.Types.isGroupV1MigrationEvent(type); + } + + public @NonNull GroupMigrationMembershipChange getGroupV1MigrationMembershipChanges() { + if (isGroupV1MigrationEvent()) { + return GroupMigrationMembershipChange.deserialize(getBody()); + } else { + return GroupMigrationMembershipChange.empty(); + } + } + + public boolean isUpdate() { + return isGroupAction() || isJoined() || isExpirationTimerUpdate() || isCallLog() || + isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() || + isProfileChange() || isGroupV1MigrationEvent() || isFailedDecryptionType(); + } + + public boolean isMediaPending() { + return false; + } + + public Recipient getIndividualRecipient() { + return individualRecipient.live().get(); + } + + public int getRecipientDeviceId() { + return recipientDeviceId; + } + + public long getType() { + return type; + } + + public List getIdentityKeyMismatches() { + return mismatches; + } + + public List getNetworkFailures() { + return networkFailures; + } + + public boolean hasNetworkFailures() { + return networkFailures != null && !networkFailures.isEmpty(); + } + + public boolean hasFailedWithNetworkFailures() { + return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure()); + } + + public boolean isFailedDecryptionType() { + return MmsSmsColumns.Types.isFailedDecryptType(type); + } + + protected static SpannableString emphasisAdded(String sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spannable; + } + + public boolean equals(Object other) { + return other != null && + other instanceof MessageRecord && + ((MessageRecord) other).getId() == getId() && + ((MessageRecord) other).isMms() == isMms(); + } + + public int hashCode() { + return (int)getId(); + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public long getExpiresIn() { + return expiresIn; + } + + public long getExpireStarted() { + return expireStarted; + } + + public boolean isUnidentified() { + return unidentified; + } + + public boolean isViewOnce() { + return false; + } + + public boolean isRemoteDelete() { + return remoteDelete; + } + + public @NonNull List getReactions() { + return reactions; + } + + public boolean hasSelfMention() { + return false; + } + + public long getNotifiedTimestamp() { + return notifiedTimestamp; + } + + public static final class InviteAddState { + + private final boolean invited; + private final UUID addedOrInvitedBy; + + public InviteAddState(boolean invited, @NonNull UUID addedOrInvitedBy) { + this.invited = invited; + this.addedOrInvitedBy = addedOrInvitedBy; + } + + public @NonNull UUID getAddedOrInvitedBy() { + return addedOrInvitedBy; + } + + public boolean isInvited() { + return invited; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java new file mode 100644 index 00000000..996b1f79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.database.model; + + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.LinkedList; +import java.util.List; + +public abstract class MmsMessageRecord extends MessageRecord { + + private final @NonNull SlideDeck slideDeck; + private final @Nullable Quote quote; + private final @NonNull List contacts = new LinkedList<>(); + private final @NonNull List linkPreviews = new LinkedList<>(); + + private final boolean viewOnce; + + MmsMessageRecord(long id, String body, Recipient conversationRecipient, + Recipient individualRecipient, int recipientDeviceId, long dateSent, + long dateReceived, long dateServer, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, List mismatches, + List networkFailures, int subscriptionId, long expiresIn, + long expireStarted, boolean viewOnce, + @NonNull SlideDeck slideDeck, int readReceiptCount, + @Nullable Quote quote, @NonNull List contacts, + @NonNull List linkPreviews, boolean unidentified, + @NonNull List reactions, boolean remoteDelete, long notifiedTimestamp, + int viewedReceiptCount) + { + super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount); + + this.slideDeck = slideDeck; + this.quote = quote; + this.viewOnce = viewOnce; + + this.contacts.addAll(contacts); + this.linkPreviews.addAll(linkPreviews); + } + + @Override + public boolean isMms() { + return true; + } + + @NonNull + public SlideDeck getSlideDeck() { + return slideDeck; + } + + @Override + public boolean isMediaPending() { + for (Slide slide : getSlideDeck().getSlides()) { + if (slide.isInProgress() || slide.isPendingDownload()) { + return true; + } + } + + return false; + } + + @Override + public boolean isViewOnce() { + return viewOnce; + } + + public boolean containsMediaSlide() { + return slideDeck.containsMediaSlide(); + } + + public @Nullable Quote getQuote() { + return quote; + } + + public @NonNull List getSharedContacts() { + return contacts; + } + + public @NonNull List getLinkPreviews() { + return linkPreviews; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java new file mode 100644 index 00000000..c0b25047 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.text.SpannableString; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.LinkedList; + +/** + * Represents the message record model for MMS messages that are + * notifications (ie: they're pointers to undownloaded media). + * + * @author Moxie Marlinspike + * + */ + +public class NotificationMmsMessageRecord extends MmsMessageRecord { + + private final byte[] contentLocation; + private final long messageSize; + private final long expiry; + private final int status; + private final byte[] transactionId; + + public NotificationMmsMessageRecord(long id, Recipient conversationRecipient, + Recipient individualRecipient, int recipientDeviceId, + long dateSent, long dateReceived, int deliveryReceiptCount, + long threadId, byte[] contentLocation, long messageSize, + long expiry, int status, byte[] transactionId, long mailbox, + int subscriptionId, SlideDeck slideDeck, int readReceiptCount, + int viewedReceiptCount) + { + super(id, "", conversationRecipient, individualRecipient, recipientDeviceId, + dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, + new LinkedList<>(), new LinkedList<>(), subscriptionId, + 0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false, + Collections.emptyList(), false, 0, viewedReceiptCount); + + this.contentLocation = contentLocation; + this.messageSize = messageSize; + this.expiry = expiry; + this.status = status; + this.transactionId = transactionId; + } + + public byte[] getTransactionId() { + return transactionId; + } + + public int getStatus() { + return this.status; + } + + public byte[] getContentLocation() { + return contentLocation; + } + + public long getMessageSize() { + return (messageSize + 1023) / 1024; + } + + public long getExpiration() { + return expiry * 1000; + } + + @Override + public boolean isOutgoing() { + return false; + } + + @Override + public boolean isSecure() { + return false; + } + + @Override + public boolean isPending() { + return false; + } + + @Override + public boolean isMmsNotification() { + return true; + } + + @Override + public boolean isMediaPending() { + return true; + } + + @Override + public SpannableString getDisplayBody(@NonNull Context context) { + if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED) { + return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_multimedia_message)); + } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) { + return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_downloading_mms_message)); + } else { + return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_error_downloading_mms_message)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java new file mode 100644 index 00000000..d0eb1775 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.database.model; + +import android.text.SpannableString; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.List; + +public class Quote { + + private final long id; + private final RecipientId author; + private final CharSequence text; + private final boolean missing; + private final SlideDeck attachment; + private final List mentions; + + public Quote(long id, + @NonNull RecipientId author, + @Nullable CharSequence text, + boolean missing, + @NonNull SlideDeck attachment, + @NonNull List mentions) + { + this.id = id; + this.author = author; + this.missing = missing; + this.attachment = attachment; + this.mentions = mentions; + + SpannableString spannable = new SpannableString(text); + MentionAnnotation.setMentionAnnotations(spannable, mentions); + + this.text = spannable; + } + + public long getId() { + return id; + } + + public @NonNull RecipientId getAuthor() { + return author; + } + + public @Nullable CharSequence getDisplayText() { + return text; + } + + public boolean isOriginalMissing() { + return missing; + } + + public @NonNull SlideDeck getAttachment() { + return attachment; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.java new file mode 100644 index 00000000..f333362a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public class ReactionRecord { + private final String emoji; + private final RecipientId author; + private final long dateSent; + private final long dateReceived; + + public ReactionRecord(@NonNull String emoji, + @NonNull RecipientId author, + long dateSent, + long dateReceived) + { + this.emoji = emoji; + this.author = author; + this.dateSent = dateSent; + this.dateReceived = dateReceived; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public @NonNull RecipientId getAuthor() { + return author; + } + + public long getDateSent() { + return dateSent; + } + + public long getDateReceived() { + return dateReceived; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReactionRecord that = (ReactionRecord) o; + return dateSent == that.dateSent && + dateReceived == that.dateReceived && + Objects.equals(emoji, that.emoji) && + Objects.equals(author, that.author); + } + + @Override + public int hashCode() { + return Objects.hash(emoji, author, dateSent, dateReceived); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java new file mode 100644 index 00000000..be02a80b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; +import android.text.SpannableString; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.LinkedList; +import java.util.List; + +/** + * The message record model which represents standard SMS messages. + * + * @author Moxie Marlinspike + * + */ + +public class SmsMessageRecord extends MessageRecord { + + public SmsMessageRecord(long id, + String body, Recipient recipient, + Recipient individualRecipient, + int recipientDeviceId, + long dateSent, long dateReceived, long dateServer, + int deliveryReceiptCount, + long type, long threadId, + int status, List mismatches, + int subscriptionId, long expiresIn, long expireStarted, + int readReceiptCount, boolean unidentified, + @NonNull List reactions, boolean remoteDelete, + long notifiedTimestamp) + { + super(id, body, recipient, individualRecipient, recipientDeviceId, + dateSent, dateReceived, dateServer, threadId, status, deliveryReceiptCount, type, + mismatches, new LinkedList<>(), subscriptionId, + expiresIn, expireStarted, readReceiptCount, unidentified, reactions, remoteDelete, notifiedTimestamp, 0); + } + + public long getType() { + return type; + } + + @Override + public SpannableString getDisplayBody(@NonNull Context context) { + if (SmsDatabase.Types.isFailedDecryptType(type)) { + return emphasisAdded(context.getString(R.string.MessageRecord_chat_session_refreshed)); + } else if (isCorruptedKeyExchange()) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_corrupted_key_exchange_message)); + } else if (isInvalidVersionKeyExchange()) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_key_exchange_message_for_invalid_protocol_version)); + } else if (MmsSmsColumns.Types.isLegacyType(type)) { + return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); + } else if (isBundleKeyExchange()) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_message_with_new_safety_number_tap_to_process)); + } else if (isKeyExchange() && isOutgoing()) { + return new SpannableString(""); + } else if (isKeyExchange() && !isOutgoing()) { + return emphasisAdded(context.getString(R.string.ConversationItem_received_key_exchange_message_tap_to_process)); + } else if (SmsDatabase.Types.isDuplicateMessageType(type)) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); + } else if (SmsDatabase.Types.isNoRemoteSessionType(type)) { + return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session)); + } else if (isEndSession() && isOutgoing()) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset)); + } else if (isEndSession()) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset_s, getIndividualRecipient().getDisplayName(context))); + } else if (SmsDatabase.Types.isUnsupportedMessageType(type)) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_this_message_could_not_be_processed_because_it_was_sent_from_a_newer_version)); + } else if (SmsDatabase.Types.isInvalidMessageType(type)) { + return emphasisAdded(context.getString(R.string.SmsMessageRecord_error_handling_incoming_message)); + } else { + return super.getDisplayBody(context); + } + } + + @Override + public boolean isMms() { + return false; + } + + @Override + public boolean isMmsNotification() { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StatusUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/StatusUtil.java new file mode 100644 index 00000000..f3e0a517 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StatusUtil.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.database.model; + +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; + +final class StatusUtil { + private StatusUtil() {} + + static boolean isDelivered(long deliveryStatus, int deliveryReceiptCount) { + return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE && + deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; + } + + static boolean isPending(long type) { + return MmsSmsColumns.Types.isPendingMessageType(type) && + !MmsSmsColumns.Types.isIdentityVerified(type) && + !MmsSmsColumns.Types.isIdentityDefault(type); + } + + static boolean isFailed(long type, long deliveryStatus) { + return MmsSmsColumns.Types.isFailedMessageType(type) || + MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) || + deliveryStatus >= SmsDatabase.Status.STATUS_FAILED; + } + + static boolean isVerificationStatusChange(long type) { + return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Sticker.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Sticker.java new file mode 100644 index 00000000..9ee97c13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Sticker.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; + +public class Sticker { + + private final String packId; + private final String packKey; + private final int stickerId; + private final Attachment attachment; + + public Sticker(@NonNull String packId, + @NonNull String packKey, + int stickerId, + @NonNull Attachment attachment) + { + this.packId = packId; + this.packKey = packKey; + this.stickerId = stickerId; + this.attachment = attachment; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public int getStickerId() { + return stickerId; + } + + public @NonNull Attachment getAttachment() { + return attachment; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.java new file mode 100644 index 00000000..ee42ef58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerPackRecord.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.database.model; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Objects; + +/** + * Represents a record for a sticker pack in the {@link org.thoughtcrime.securesms.database.StickerDatabase}. + */ +public final class StickerPackRecord { + + private final String packId; + private final String packKey; + private final Optional title; + private final Optional author; + private final StickerRecord cover; + private final boolean installed; + + public StickerPackRecord(@NonNull String packId, + @NonNull String packKey, + @NonNull String title, + @NonNull String author, + @NonNull StickerRecord cover, + boolean installed) + { + this.packId = packId; + this.packKey = packKey; + this.title = TextUtils.isEmpty(title) ? Optional.absent() : Optional.of(title); + this.author = TextUtils.isEmpty(author) ? Optional.absent() : Optional.of(author); + this.cover = cover; + this.installed = installed; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public @NonNull Optional getTitle() { + return title; + } + + public @NonNull Optional getAuthor() { + return author; + } + + public @NonNull StickerRecord getCover() { + return cover; + } + + public boolean isInstalled() { + return installed; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StickerPackRecord record = (StickerPackRecord) o; + return installed == record.installed && + packId.equals(record.packId) && + packKey.equals(record.packKey) && + title.equals(record.title) && + author.equals(record.author) && + cover.equals(record.cover); + } + + @Override + public int hashCode() { + return Objects.hash(packId, packKey, title, author, cover, installed); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerRecord.java new file mode 100644 index 00000000..8f6773c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StickerRecord.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.database.model; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Objects; + +/** + * Represents a record for a sticker pack in the {@link org.thoughtcrime.securesms.database.StickerDatabase}. + */ +public final class StickerRecord { + + private final long rowId; + private final String packId; + private final String packKey; + private final int stickerId; + private final String emoji; + private final String contentType; + private final long size; + private final boolean isCover; + + public StickerRecord(long rowId, + @NonNull String packId, + @NonNull String packKey, + int stickerId, + @NonNull String emoji, + @Nullable String contentType, + long size, + boolean isCover) + { + this.rowId = rowId; + this.packId = packId; + this.packKey = packKey; + this.stickerId = stickerId; + this.emoji = emoji; + this.contentType = contentType; + this.size = size; + this.isCover = isCover; + } + + public long getRowId() { + return rowId; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public int getStickerId() { + return stickerId; + } + + public @NonNull Uri getUri() { + return PartAuthority.getStickerUri(rowId); + } + + public @NonNull String getEmoji() { + return emoji; + } + + public @NonNull String getContentType() { + return Util.isEmpty(contentType) ? MediaUtil.IMAGE_WEBP : contentType; + } + + public long getSize() { + return size; + } + + public boolean isCover() { + return isCover; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StickerRecord that = (StickerRecord) o; + return rowId == that.rowId && + stickerId == that.stickerId && + size == that.size && + isCover == that.isCover && + packId.equals(that.packId) && + packKey.equals(that.packKey) && + emoji.equals(that.emoji) && + Objects.equals(contentType, that.contentType); + } + + @Override + public int hashCode() { + return Objects.hash(rowId, packId, packKey, stickerId, emoji, contentType, size, isCover); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java new file mode 100644 index 00000000..619ea6c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -0,0 +1,410 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * Copyright (C) 2013-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase.Extra; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.Objects; + +/** + * Represents an entry in the {@link org.thoughtcrime.securesms.database.ThreadDatabase}. + */ +public final class ThreadRecord { + + private final long threadId; + private final String body; + private final Recipient recipient; + private final Recipient sender; + private final long type; + private final long date; + private final long deliveryStatus; + private final int deliveryReceiptCount; + private final int readReceiptCount; + private final Uri snippetUri; + private final String contentType; + private final Extra extra; + private final long count; + private final int unreadCount; + private final boolean forcedUnread; + private final int distributionType; + private final boolean archived; + private final long expiresIn; + private final long lastSeen; + private final boolean isPinned; + + private ThreadRecord(@NonNull Builder builder) { + this.threadId = builder.threadId; + this.body = builder.body; + this.recipient = builder.recipient; + this.sender = builder.sender; + this.date = builder.date; + this.type = builder.type; + this.deliveryStatus = builder.deliveryStatus; + this.deliveryReceiptCount = builder.deliveryReceiptCount; + this.readReceiptCount = builder.readReceiptCount; + this.snippetUri = builder.snippetUri; + this.contentType = builder.contentType; + this.extra = builder.extra; + this.count = builder.count; + this.unreadCount = builder.unreadCount; + this.forcedUnread = builder.forcedUnread; + this.distributionType = builder.distributionType; + this.archived = builder.archived; + this.expiresIn = builder.expiresIn; + this.lastSeen = builder.lastSeen; + this.isPinned = builder.isPinned; + } + + public long getThreadId() { + return threadId; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @Nullable Uri getSnippetUri() { + return snippetUri; + } + + public @NonNull String getBody() { + return body; + } + + public @Nullable Extra getExtra() { + return extra; + } + + public @Nullable String getContentType() { + return contentType; + } + + public long getCount() { + return count; + } + + public int getUnreadCount() { + return unreadCount; + } + + public boolean isForcedUnread() { + return forcedUnread; + } + + public boolean isRead() { + return unreadCount == 0 && !forcedUnread; + } + + public long getDate() { + return date; + } + + public boolean isArchived() { + return archived; + } + + public long getType() { + return type; + } + + public int getDistributionType() { + return distributionType; + } + + public long getExpiresIn() { + return expiresIn; + } + + public long getLastSeen() { + return lastSeen; + } + + public boolean isOutgoing() { + return MmsSmsColumns.Types.isOutgoingMessageType(type); + } + + public boolean isOutgoingAudioCall() { + return SmsDatabase.Types.isOutgoingAudioCall(type); + } + + public boolean isOutgoingVideoCall() { + return SmsDatabase.Types.isOutgoingVideoCall(type); + } + + public boolean isVerificationStatusChange() { + return StatusUtil.isVerificationStatusChange(type); + } + + public boolean isPending() { + return StatusUtil.isPending(type); + } + + public boolean isFailed() { + return StatusUtil.isFailed(type, deliveryStatus); + } + + public boolean isRemoteRead() { + return readReceiptCount > 0; + } + + public boolean isPendingInsecureSmsFallback() { + return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type); + } + + public boolean isDelivered() { + return StatusUtil.isDelivered(deliveryStatus, deliveryReceiptCount); + } + + public @Nullable RecipientId getGroupAddedBy() { + if (extra != null && extra.getGroupAddedBy() != null) return RecipientId.from(extra.getGroupAddedBy()); + else return null; + } + + public @NonNull RecipientId getIndividualRecipientId() { + if (extra != null && extra.getIndividualRecipientId() != null) { + return RecipientId.from(extra.getIndividualRecipientId()); + } else { + if (getRecipient().isGroup()) { + return RecipientId.UNKNOWN; + } else { + return getRecipient().getId(); + } + } + } + + public @NonNull RecipientId getGroupMessageSender() { + RecipientId threadRecipientId = getRecipient().getId(); + RecipientId individualRecipientId = getIndividualRecipientId(); + + if (threadRecipientId.equals(individualRecipientId)) { + return Recipient.self().getId(); + } else { + return individualRecipientId; + } + } + + public boolean isGv2Invite() { + return extra != null && extra.isGv2Invite(); + } + + public boolean isMessageRequestAccepted() { + if (extra != null) return extra.isMessageRequestAccepted(); + else return true; + } + + public boolean isPinned() { + return isPinned; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ThreadRecord that = (ThreadRecord) o; + return threadId == that.threadId && + type == that.type && + date == that.date && + deliveryStatus == that.deliveryStatus && + deliveryReceiptCount == that.deliveryReceiptCount && + readReceiptCount == that.readReceiptCount && + count == that.count && + unreadCount == that.unreadCount && + forcedUnread == that.forcedUnread && + distributionType == that.distributionType && + archived == that.archived && + expiresIn == that.expiresIn && + lastSeen == that.lastSeen && + isPinned == that.isPinned && + body.equals(that.body) && + recipient.equals(that.recipient) && + Objects.equals(snippetUri, that.snippetUri) && + Objects.equals(contentType, that.contentType) && + Objects.equals(extra, that.extra); + } + + @Override + public int hashCode() { + return Objects.hash(threadId, + body, + recipient, + type, + date, + deliveryStatus, + deliveryReceiptCount, + readReceiptCount, + snippetUri, + contentType, + extra, + count, + unreadCount, + forcedUnread, + distributionType, + archived, + expiresIn, + lastSeen, + isPinned); + } + + public static class Builder { + private long threadId; + private String body; + private Recipient recipient = Recipient.UNKNOWN; + private Recipient sender = Recipient.UNKNOWN; + private long type; + private long date; + private long deliveryStatus; + private int deliveryReceiptCount; + private int readReceiptCount; + private Uri snippetUri; + private String contentType; + private Extra extra; + private long count; + private int unreadCount; + private boolean forcedUnread; + private int distributionType; + private boolean archived; + private long expiresIn; + private long lastSeen; + private boolean isPinned; + + public Builder(long threadId) { + this.threadId = threadId; + } + + public Builder setBody(@NonNull String body) { + this.body = body; + return this; + } + + public Builder setRecipient(@NonNull Recipient recipient) { + this.recipient = recipient; + return this; + } + + public Builder setSender(@NonNull Recipient sender) { + this.sender = sender; + return this; + } + + public Builder setType(long type) { + this.type = type; + return this; + } + + public Builder setThreadId(long threadId) { + this.threadId = threadId; + return this; + } + + public Builder setDate(long date) { + this.date = date; + return this; + } + + public Builder setDeliveryStatus(long deliveryStatus) { + this.deliveryStatus = deliveryStatus; + return this; + } + + public Builder setDeliveryReceiptCount(int deliveryReceiptCount) { + this.deliveryReceiptCount = deliveryReceiptCount; + return this; + } + + public Builder setReadReceiptCount(int readReceiptCount) { + this.readReceiptCount = readReceiptCount; + return this; + } + + public Builder setSnippetUri(@Nullable Uri snippetUri) { + this.snippetUri = snippetUri; + return this; + } + + public Builder setContentType(@Nullable String contentType) { + this.contentType = contentType; + return this; + } + + public Builder setExtra(@Nullable Extra extra) { + this.extra = extra; + return this; + } + + public Builder setCount(long count) { + this.count = count; + return this; + } + + public Builder setUnreadCount(int unreadCount) { + this.unreadCount = unreadCount; + return this; + } + + public Builder setForcedUnread(boolean forcedUnread) { + this.forcedUnread = forcedUnread; + return this; + } + + public Builder setDistributionType(int distributionType) { + this.distributionType = distributionType; + return this; + } + + public Builder setArchived(boolean archived) { + this.archived = archived; + return this; + } + + public Builder setExpiresIn(long expiresIn) { + this.expiresIn = expiresIn; + return this; + } + + public Builder setLastSeen(long lastSeen) { + this.lastSeen = lastSeen; + return this; + } + + public Builder setPinned(boolean isPinned) { + this.isPinned = isPinned; + return this; + } + + public ThreadRecord build() { + if (distributionType == ThreadDatabase.DistributionTypes.CONVERSATION) { + Preconditions.checkArgument(threadId > 0); + Preconditions.checkArgument(date > 0); + Preconditions.checkNotNull(body); + Preconditions.checkNotNull(recipient); + } + return new ThreadRecord(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java new file mode 100644 index 00000000..eabd82fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/UpdateDescription.java @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.AnyThread; +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Contains a list of people mentioned in an update message and a function to create the update message. + */ +public final class UpdateDescription { + + public interface StringFactory { + @WorkerThread + String create(); + } + + private final Collection mentioned; + private final StringFactory stringFactory; + private final String staticString; + private final int lightIconResource; + private final int lightTint; + private final int darkTint; + + private UpdateDescription(@NonNull Collection mentioned, + @Nullable StringFactory stringFactory, + @Nullable String staticString, + @DrawableRes int iconResource, + @ColorInt int lightTint, + @ColorInt int darkTint) + { + if (staticString == null && stringFactory == null) { + throw new AssertionError(); + } + this.mentioned = mentioned; + this.stringFactory = stringFactory; + this.staticString = staticString; + this.lightIconResource = iconResource; + this.lightTint = lightTint; + this.darkTint = darkTint; + } + + /** + * Create an update description which has a string value created by a supplied factory method that + * will be run on a background thread. + * + * @param mentioned UUIDs of recipients that are mentioned in the string. + * @param stringFactory The background method for generating the string. + */ + public static UpdateDescription mentioning(@NonNull Collection mentioned, + @NonNull StringFactory stringFactory, + @DrawableRes int iconResource) + { + return new UpdateDescription(UuidUtil.filterKnown(mentioned), + stringFactory, + null, + iconResource, + 0, + 0); + } + + /** + * Create an update description that's string value is fixed. + */ + public static UpdateDescription staticDescription(@NonNull String staticString, + @DrawableRes int iconResource) + { + return new UpdateDescription(Collections.emptyList(), null, staticString, iconResource, 0, 0); + } + + /** + * Create an update description that's string value is fixed with a specific tint color. + */ + public static UpdateDescription staticDescription(@NonNull String staticString, + @DrawableRes int iconResource, + @ColorInt int lightTint, + @ColorInt int darkTint) + { + return new UpdateDescription(Collections.emptyList(), null, staticString, iconResource, lightTint, darkTint); + } + + public boolean isStringStatic() { + return staticString != null; + } + + @AnyThread + public @NonNull String getStaticString() { + if (staticString == null) { + throw new UnsupportedOperationException(); + } + + return staticString; + } + + @WorkerThread + public @NonNull String getString() { + if (staticString != null) { + return staticString; + } + + Util.assertNotMainThread(); + + //noinspection ConstantConditions + return stringFactory.create(); + } + + @AnyThread + public Collection getMentioned() { + return mentioned; + } + + public @DrawableRes int getIconResource() { + return lightIconResource; + } + + public @ColorInt int getLightTint() { + return lightTint; + } + + public @ColorInt int getDarkTint() { + return darkTint; + } + + public static UpdateDescription concatWithNewLines(@NonNull List updateDescriptions) { + if (updateDescriptions.size() == 0) { + throw new AssertionError(); + } + + if (updateDescriptions.size() == 1) { + return updateDescriptions.get(0); + } + + if (allAreStatic(updateDescriptions)) { + return UpdateDescription.staticDescription(concatStaticLines(updateDescriptions), + updateDescriptions.get(0).getIconResource() + ); + } + + Set allMentioned = new HashSet<>(); + + for (UpdateDescription updateDescription : updateDescriptions) { + allMentioned.addAll(updateDescription.getMentioned()); + } + + return UpdateDescription.mentioning(allMentioned, + () -> concatLines(updateDescriptions), + updateDescriptions.get(0).getIconResource()); + } + + private static boolean allAreStatic(@NonNull Collection updateDescriptions) { + for (UpdateDescription description : updateDescriptions) { + if (!description.isStringStatic()) { + return false; + } + } + + return true; + } + + @WorkerThread + private static String concatLines(@NonNull List updateDescriptions) { + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < updateDescriptions.size(); i++) { + if (i > 0) result.append('\n'); + result.append(updateDescriptions.get(i).getString()); + } + + return result.toString(); + } + + @AnyThread + private static String concatStaticLines(@NonNull List updateDescriptions) { + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < updateDescriptions.size(); i++) { + if (i > 0) result.append('\n'); + result.append(updateDescriptions.get(i).getStaticString()); + } + + return result.toString(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/deeplinks/DeepLinkEntryActivity.java b/app/src/main/java/org/thoughtcrime/securesms/deeplinks/DeepLinkEntryActivity.java new file mode 100644 index 00000000..7a17c29f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/deeplinks/DeepLinkEntryActivity.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.deeplinks; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; + +public class DeepLinkEntryActivity extends PassphraseRequiredActivity { + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + Intent intent = MainActivity.clearTop(this); + Uri data = getIntent().getData(); + intent.setData(data); + startActivity(intent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java b/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java new file mode 100644 index 00000000..bb83907b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.delete; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +final class Country { + private final String displayName; + private final int code; + private final String normalized; + private final String region; + + Country(@NonNull String displayName, int code, @NonNull String region) { + this.displayName = displayName; + this.code = code; + this.normalized = displayName.toLowerCase(); + this.region = region; + } + + int getCode() { + return code; + } + + @NonNull String getDisplayName() { + return displayName; + } + + public String getNormalizedDisplayName() { + return normalized; + } + + @NonNull String getRegion() { + return region; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Country country = (Country) o; + return displayName.equals(country.displayName) && + code == country.code; + } + + @Override + public int hashCode() { + return Objects.hash(displayName, code); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java new file mode 100644 index 00000000..eafaa4f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.delete; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.Objects; + +class DeleteAccountCountryPickerAdapter extends ListAdapter { + + private final Callback callback; + + protected DeleteAccountCountryPickerAdapter(@NonNull Callback callback) { + super(new CountryDiffCallback()); + this.callback = callback; + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.delete_account_country_adapter_item, parent, false); + + return new ViewHolder(view, position -> callback.onItemSelected(getItem(position))); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.textView.setText(getItem(position).getDisplayName()); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final TextView textView; + + public ViewHolder(@NonNull View itemView, @NonNull Consumer onItemClickedConsumer) { + super(itemView); + textView = itemView.findViewById(android.R.id.text1); + + itemView.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + onItemClickedConsumer.accept(getAdapterPosition()); + } + }); + } + } + + private static class CountryDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull Country oldItem, @NonNull Country newItem) { + return Objects.equals(oldItem.getCode(), newItem.getCode()); + } + + @Override + public boolean areContentsTheSame(@NonNull Country oldItem, @NonNull Country newItem) { + return Objects.equals(oldItem, newItem); + } + } + + interface Callback { + void onItemSelected(@NonNull Country country); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java new file mode 100644 index 00000000..41e173b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.delete; + +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +public class DeleteAccountCountryPickerFragment extends DialogFragment { + + private DeleteAccountViewModel viewModel; + + public static void show(@NonNull FragmentManager fragmentManager) { + new DeleteAccountCountryPickerFragment().show(fragmentManager, null); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.delete_account_country_picker, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.delete_account_country_picker_toolbar); + EditText searchFilter = view.findViewById(R.id.delete_account_country_picker_filter); + RecyclerView recycler = view.findViewById(R.id.delete_account_country_picker_recycler); + DeleteAccountCountryPickerAdapter adapter = new DeleteAccountCountryPickerAdapter(this::onCountryPicked); + + recycler.setAdapter(adapter); + + toolbar.setNavigationOnClickListener(unused -> dismiss()); + + viewModel = ViewModelProviders.of(requireActivity()).get(DeleteAccountViewModel.class); + viewModel.getFilteredCountries().observe(getViewLifecycleOwner(), adapter::submitList); + + searchFilter.addTextChangedListener(new AfterTextChanged(this::onQueryChanged)); + } + + private void onQueryChanged(@NonNull Editable e) { + viewModel.onQueryChanged(e.toString()); + } + + private void onCountryPicked(@NonNull Country country) { + viewModel.onRegionSelected(country.getRegion()); + dismissAllowingStateLoss(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java new file mode 100644 index 00000000..0691c3a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java @@ -0,0 +1,285 @@ +package org.thoughtcrime.securesms.delete; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.text.Editable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.snackbar.Snackbar; +import com.google.i18n.phonenumbers.AsYouTypeFormatter; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.LabeledEditText; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +public class DeleteAccountFragment extends Fragment { + + private ArrayAdapter countrySpinnerAdapter; + private LabeledEditText countryCode; + private LabeledEditText number; + private AsYouTypeFormatter countryFormatter; + private DeleteAccountViewModel viewModel; + private DialogInterface deletionProgressDialog; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.delete_account_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + TextView bullets = view.findViewById(R.id.delete_account_fragment_bullets); + Spinner countrySpinner = view.findViewById(R.id.delete_account_fragment_country_spinner); + View confirm = view.findViewById(R.id.delete_account_fragment_delete); + + countryCode = view.findViewById(R.id.delete_account_fragment_country_code); + number = view.findViewById(R.id.delete_account_fragment_number); + + viewModel = ViewModelProviders.of(requireActivity(), new DeleteAccountViewModel.Factory(new DeleteAccountRepository())) + .get(DeleteAccountViewModel.class); + viewModel.getCountryDisplayName().observe(getViewLifecycleOwner(), this::setCountryDisplay); + viewModel.getRegionCode().observe(getViewLifecycleOwner(), this::handleRegionUpdated); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::handleEvent); + + initializeNumberInput(); + + countryCode.getInput().addTextChangedListener(new AfterTextChanged(this::afterCountryCodeChanged)); + countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT); + confirm.setOnClickListener(unused -> viewModel.submit()); + + bullets.setText(buildBulletsText()); + initializeSpinner(countrySpinner); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__delete_account); + } + + private @NonNull CharSequence buildBulletsText() { + return new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo))) + .append("\n") + .append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages))); + } + + @SuppressLint("ClickableViewAccessibility") + private void initializeSpinner(@NonNull Spinner countrySpinner) { + countrySpinnerAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item); + countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + countrySpinner.setAdapter(countrySpinnerAdapter); + countrySpinner.setOnTouchListener((view, event) -> { + if (event.getAction() == MotionEvent.ACTION_UP) { + pickCountry(); + } + return true; + }); + countrySpinner.setOnKeyListener((view, keyCode, event) -> { + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) { + pickCountry(); + return true; + } + return false; + }); + } + + private void pickCountry() { + countryCode.clearFocus(); + DeleteAccountCountryPickerFragment.show(requireFragmentManager()); + } + + private void setCountryDisplay(@NonNull String regionDisplayName) { + countrySpinnerAdapter.clear(); + if (TextUtils.isEmpty(regionDisplayName)) { + countrySpinnerAdapter.add(requireContext().getString(R.string.RegistrationActivity_select_your_country)); + } else { + countrySpinnerAdapter.add(regionDisplayName); + } + } + + private void handleRegionUpdated(@Nullable String regionCode) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null; + + reformatText(number.getText()); + + if (!TextUtils.isEmpty(regionCode) && !"ZZ".equals(regionCode)) { + number.requestFocus(); + + int numberLength = number.getText().length(); + number.getInput().setSelection(numberLength, numberLength); + + countryCode.setText(String.valueOf(util.getCountryCodeForRegion(regionCode))); + } + } + + private Long reformatText(Editable s) { + if (countryFormatter == null) { + return null; + } + + if (TextUtils.isEmpty(s)) { + return null; + } + + countryFormatter.clear(); + + String formattedNumber = null; + StringBuilder justDigits = new StringBuilder(); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isDigit(c)) { + formattedNumber = countryFormatter.inputDigit(c); + justDigits.append(c); + } + } + + if (formattedNumber != null && !s.toString().equals(formattedNumber)) { + s.replace(0, s.length(), formattedNumber); + } + + if (justDigits.length() == 0) { + return null; + } + + return Long.parseLong(justDigits.toString()); + } + + private void initializeNumberInput() { + EditText numberInput = number.getInput(); + Long nationalNumber = viewModel.getNationalNumber(); + + if (nationalNumber != null) { + number.setText(String.valueOf(nationalNumber)); + } else { + number.setText(""); + } + + numberInput.addTextChangedListener(new AfterTextChanged(this::afterNumberChanged)); + numberInput.setImeOptions(EditorInfo.IME_ACTION_DONE); + numberInput.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + ViewUtil.hideKeyboard(requireContext(), v); + viewModel.submit(); + return true; + } + return false; + }); + } + + private void afterCountryCodeChanged(@Nullable Editable s) { + if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) { + viewModel.onCountrySelected(0); + return; + } + + viewModel.onCountrySelected(Integer.parseInt(s.toString())); + } + + private void afterNumberChanged(@Nullable Editable s) { + Long number = reformatText(s); + + if (number == null) return; + + viewModel.setNationalNumber(number); + } + + private void handleEvent(@NonNull DeleteAccountViewModel.EventType eventType) { + switch (eventType) { + case NO_COUNTRY_CODE: + Snackbar.make(requireView(), R.string.DeleteAccountFragment__no_country_code, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show(); + break; + case NO_NATIONAL_NUMBER: + Snackbar.make(requireView(), R.string.DeleteAccountFragment__no_number, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show(); + break; + case NOT_A_MATCH: + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.DeleteAccountFragment__the_phone_number) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .show(); + break; + case CONFIRM_DELETION: + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.DeleteAccountFragment__are_you_sure) + .setMessage(R.string.DeleteAccountFragment__this_will_delete_your_signal_account) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton(R.string.DeleteAccountFragment__delete_account, (dialog, which) -> { + dialog.dismiss(); + deletionProgressDialog = SimpleProgressDialog.show(requireContext()); + viewModel.deleteAccount(); + }) + .setCancelable(true) + .show(); + break; + case PIN_DELETION_FAILED: + case SERVER_DELETION_FAILED: + dismissDeletionProgressDialog(); + showNetworkDeletionFailedDialog(); + break; + case LOCAL_DATA_DELETION_FAILED: + dismissDeletionProgressDialog(); + showLocalDataDeletionFailedDialog(); + break; + default: + throw new IllegalStateException("Unknown error type: " + eventType); + } + } + + private void dismissDeletionProgressDialog() { + if (deletionProgressDialog != null) { + deletionProgressDialog.dismiss(); + deletionProgressDialog = null; + } + } + + private void showNetworkDeletionFailedDialog() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.DeleteAccountFragment__failed_to_delete_account) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .show(); + } + + private void showLocalDataDeletionFailedDialog() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.DeleteAccountFragment__failed_to_delete_local_data) + .setPositiveButton(R.string.DeleteAccountFragment__launch_app_settings, (dialog, which) -> { + Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + settingsIntent.setData(Uri.fromParts("package", requireActivity().getPackageName(), null)); + startActivity(settingsIntent); + }) + .setCancelable(false) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java new file mode 100644 index 00000000..663dbfb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.delete; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.pin.KbsEnclaves; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.text.Collator; +import java.util.Comparator; +import java.util.List; + +class DeleteAccountRepository { + private static final String TAG = Log.tag(DeleteAccountRepository.class); + + @NonNull List getAllCountries() { + return Stream.of(PhoneNumberUtil.getInstance().getSupportedRegions()) + .map(DeleteAccountRepository::getCountryForRegion) + .sorted(new RegionComparator()) + .toList(); + } + + @NonNull String getRegionDisplayName(@NonNull String region) { + return PhoneNumberFormatter.getRegionDisplayName(region).or(""); + } + + int getRegionCountryCode(@NonNull String region) { + return PhoneNumberUtil.getInstance().getCountryCodeForRegion(region); + } + + void deleteAccount(@NonNull Runnable onFailureToRemovePin, + @NonNull Runnable onFailureToDeleteFromService, + @NonNull Runnable onFailureToDeleteLocalData) + { + SignalExecutors.BOUNDED.execute(() -> { + Log.i(TAG, "deleteAccount: attempting to remove pin..."); + + try { + ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()).newPinChangeSession().removePin(); + } catch (UnauthenticatedResponseException | IOException e) { + Log.w(TAG, "deleteAccount: failed to remove PIN", e); + onFailureToRemovePin.run(); + return; + } + + Log.i(TAG, "deleteAccount: successfully removed pin."); + Log.i(TAG, "deleteAccount: attempting to delete account from server..."); + + try { + ApplicationDependencies.getSignalServiceAccountManager().deleteAccount(); + } catch (IOException e) { + Log.w(TAG, "deleteAccount: failed to delete account from signal service", e); + onFailureToDeleteFromService.run(); + return; + } + + Log.i(TAG, "deleteAccount: successfully removed account from server"); + Log.i(TAG, "deleteAccount: attempting to delete user data and close process..."); + + if (!ServiceUtil.getActivityManager(ApplicationDependencies.getApplication()).clearApplicationUserData()) { + Log.w(TAG, "deleteAccount: failed to delete user data"); + onFailureToDeleteLocalData.run(); + } + }); + } + + private static @NonNull Country getCountryForRegion(@NonNull String region) { + return new Country(PhoneNumberFormatter.getRegionDisplayName(region).or(""), + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region), + region); + } + + private static class RegionComparator implements Comparator { + + private final Collator collator; + + RegionComparator() { + collator = Collator.getInstance(); + collator.setStrength(Collator.PRIMARY); + } + + @Override + public int compare(Country lhs, Country rhs) { + return collator.compare(lhs.getDisplayName(), rhs.getDisplayName()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java new file mode 100644 index 00000000..e59ea09f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.delete; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; + +import java.util.List; + +public class DeleteAccountViewModel extends ViewModel { + + private final DeleteAccountRepository repository; + private final List allCountries; + private final LiveData> filteredCountries; + private final MutableLiveData regionCode; + private final LiveData countryDisplayName; + private final MutableLiveData nationalNumber; + private final MutableLiveData query; + private final SingleLiveEvent events; + + public DeleteAccountViewModel(@NonNull DeleteAccountRepository repository) { + this.repository = repository; + this.allCountries = repository.getAllCountries(); + this.regionCode = new DefaultValueLiveData<>(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY); + this.nationalNumber = new MutableLiveData<>(); + this.query = new DefaultValueLiveData<>(""); + this.countryDisplayName = Transformations.map(regionCode, repository::getRegionDisplayName); + this.filteredCountries = Transformations.map(query, q -> Stream.of(allCountries).filter(country -> isMatch(q, country)).toList()); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData> getFilteredCountries() { + return filteredCountries; + } + + @NonNull LiveData getCountryDisplayName() { + return Transformations.distinctUntilChanged(countryDisplayName); + } + + @NonNull LiveData getRegionCode() { + return Transformations.distinctUntilChanged(regionCode); + } + + @NonNull SingleLiveEvent getEvents() { + return events; + } + + @Nullable Long getNationalNumber() { + return nationalNumber.getValue(); + } + + void onQueryChanged(@NonNull String query) { + this.query.setValue(query.toLowerCase()); + } + + void deleteAccount() { + repository.deleteAccount(() -> events.postValue(EventType.PIN_DELETION_FAILED), + () -> events.postValue(EventType.SERVER_DELETION_FAILED), + () -> events.postValue(EventType.LOCAL_DATA_DELETION_FAILED)); + } + + void submit() { + String region = this.regionCode.getValue(); + Integer countryCode = region != null ? repository.getRegionCountryCode(region) : null; + Long nationalNumber = this.nationalNumber.getValue(); + + if (countryCode == null || countryCode == 0) { + events.setValue(EventType.NO_COUNTRY_CODE); + return; + } + + if (nationalNumber == null) { + events.setValue(EventType.NO_NATIONAL_NUMBER); + return; + } + + Phonenumber.PhoneNumber number = new Phonenumber.PhoneNumber(); + number.setCountryCode(countryCode); + number.setNationalNumber(nationalNumber); + + if (PhoneNumberUtil.getInstance().isNumberMatch(number, Recipient.self().requireE164()) == PhoneNumberUtil.MatchType.EXACT_MATCH) { + events.setValue(EventType.CONFIRM_DELETION); + } else { + events.setValue(EventType.NOT_A_MATCH); + } + } + + void onCountrySelected(int countryCode) { + String region = this.regionCode.getValue(); + List regions = PhoneNumberUtil.getInstance().getRegionCodesForCountryCode(countryCode); + + if (!regions.contains(region)) { + this.regionCode.setValue(PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode)); + } + } + + void onRegionSelected(@NonNull String region) { + this.regionCode.setValue(region); + } + + void setNationalNumber(long nationalNumber) { + this.nationalNumber.setValue(nationalNumber); + + try { + String phoneNumberRegion = PhoneNumberUtil.getInstance() + .getRegionCodeForNumber(PhoneNumberUtil.getInstance().parse(String.valueOf(nationalNumber), + regionCode.getValue())); + if (phoneNumberRegion != null) { + regionCode.setValue(phoneNumberRegion); + } + } catch (NumberParseException ignored) { + } + } + + private static boolean isMatch(@NonNull String query, @NonNull Country country) { + if (TextUtils.isEmpty(query)) { + return true; + } else { + return country.getNormalizedDisplayName().contains(query.toLowerCase()); + } + } + + enum EventType { + NO_COUNTRY_CODE, + NO_NATIONAL_NUMBER, + NOT_A_MATCH, + CONFIRM_DELETION, + PIN_DELETION_FAILED, + SERVER_DELETION_FAILED, + LOCAL_DATA_DELETION_FAILED + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final DeleteAccountRepository repository; + + public Factory(DeleteAccountRepository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new DeleteAccountViewModel(repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java new file mode 100644 index 00000000..92aec9c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -0,0 +1,414 @@ +package org.thoughtcrime.securesms.dependencies; + +import android.app.Application; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.KbsEnclave; +import org.thoughtcrime.securesms.components.TypingStatusRepository; +import org.thoughtcrime.securesms.components.TypingStatusSender; +import org.thoughtcrime.securesms.database.DatabaseObserver; +import org.thoughtcrime.securesms.groups.GroupsV2Authorization; +import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; +import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever; +import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.messages.IncomingMessageProcessor; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.notifications.MessageNotifier; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.recipients.LiveRecipientCache; +import org.thoughtcrime.securesms.service.TrimThreadsByDateManager; +import org.thoughtcrime.securesms.shakereport.ShakeToReport; +import org.thoughtcrime.securesms.util.AppForegroundObserver; +import org.thoughtcrime.securesms.util.EarlyMessageCache; +import org.thoughtcrime.securesms.util.FrameRateTracker; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.IasKeyStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.KeyBackupService; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; + +/** + * Location for storing and retrieving application-scoped singletons. Users must call + * {@link #init(Application, Provider)} before using any of the methods, preferably early on in + * {@link Application#onCreate()}. + * + * All future application-scoped singletons should be written as normal objects, then placed here + * to manage their singleton-ness. + */ +public class ApplicationDependencies { + + private static final Object LOCK = new Object(); + private static final Object FRAME_RATE_TRACKER_LOCK = new Object(); + private static final Object JOB_MANAGER_LOCK = new Object(); + + private static Application application; + private static Provider provider; + private static MessageNotifier messageNotifier; + private static AppForegroundObserver appForegroundObserver; + + private static volatile SignalServiceAccountManager accountManager; + private static volatile SignalServiceMessageSender messageSender; + private static volatile SignalServiceMessageReceiver messageReceiver; + private static volatile IncomingMessageObserver incomingMessageObserver; + private static volatile IncomingMessageProcessor incomingMessageProcessor; + private static volatile BackgroundMessageRetriever backgroundMessageRetriever; + private static volatile LiveRecipientCache recipientCache; + private static volatile JobManager jobManager; + private static volatile FrameRateTracker frameRateTracker; + private static volatile MegaphoneRepository megaphoneRepository; + private static volatile GroupsV2Authorization groupsV2Authorization; + private static volatile GroupsV2StateProcessor groupsV2StateProcessor; + private static volatile GroupsV2Operations groupsV2Operations; + private static volatile EarlyMessageCache earlyMessageCache; + private static volatile TypingStatusRepository typingStatusRepository; + private static volatile TypingStatusSender typingStatusSender; + private static volatile DatabaseObserver databaseObserver; + private static volatile TrimThreadsByDateManager trimThreadsByDateManager; + private static volatile ShakeToReport shakeToReport; + + @MainThread + public static void init(@NonNull Application application, @NonNull Provider provider) { + synchronized (LOCK) { + if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) { + throw new IllegalStateException("Already initialized!"); + } + + ApplicationDependencies.application = application; + ApplicationDependencies.provider = provider; + ApplicationDependencies.messageNotifier = provider.provideMessageNotifier(); + ApplicationDependencies.appForegroundObserver = provider.provideAppForegroundObserver(); + + ApplicationDependencies.appForegroundObserver.begin(); + } + } + + public static @NonNull Application getApplication() { + return application; + } + + public static @NonNull PipeConnectivityListener getPipeListener() { + return provider.providePipeListener(); + } + + public static @NonNull SignalServiceAccountManager getSignalServiceAccountManager() { + SignalServiceAccountManager local = accountManager; + + if (local != null) { + return local; + } + + synchronized (LOCK) { + if (accountManager == null) { + accountManager = provider.provideSignalServiceAccountManager(); + } + return accountManager; + } + } + + public static @NonNull GroupsV2Authorization getGroupsV2Authorization() { + if (groupsV2Authorization == null) { + synchronized (LOCK) { + if (groupsV2Authorization == null) { + GroupsV2Authorization.ValueCache authCache = new GroupsV2AuthorizationMemoryValueCache(SignalStore.groupsV2AuthorizationCache()); + groupsV2Authorization = new GroupsV2Authorization(getSignalServiceAccountManager().getGroupsV2Api(), authCache); + } + } + } + + return groupsV2Authorization; + } + + public static @NonNull GroupsV2Operations getGroupsV2Operations() { + if (groupsV2Operations == null) { + synchronized (LOCK) { + if (groupsV2Operations == null) { + groupsV2Operations = provider.provideGroupsV2Operations(); + } + } + } + + return groupsV2Operations; + } + + public static @NonNull KeyBackupService getKeyBackupService(@NonNull KbsEnclave enclave) { + return getSignalServiceAccountManager().getKeyBackupService(IasKeyStore.getIasKeyStore(application), + enclave.getEnclaveName(), + Hex.fromStringOrThrow(enclave.getServiceId()), + enclave.getMrEnclave(), + 10); + } + + public static @NonNull GroupsV2StateProcessor getGroupsV2StateProcessor() { + if (groupsV2StateProcessor == null) { + synchronized (LOCK) { + if (groupsV2StateProcessor == null) { + groupsV2StateProcessor = new GroupsV2StateProcessor(application); + } + } + } + + return groupsV2StateProcessor; + } + + public static @NonNull SignalServiceMessageSender getSignalServiceMessageSender() { + SignalServiceMessageSender local = messageSender; + + if (local != null) { + return local; + } + + synchronized (LOCK) { + if (messageSender == null) { + messageSender = provider.provideSignalServiceMessageSender(); + } else { + messageSender.update( + IncomingMessageObserver.getPipe(), + IncomingMessageObserver.getUnidentifiedPipe(), + TextSecurePreferences.isMultiDevice(application)); + } + return messageSender; + } + } + + public static @NonNull SignalServiceMessageReceiver getSignalServiceMessageReceiver() { + SignalServiceMessageReceiver local = messageReceiver; + + if (local != null) { + return local; + } + + synchronized (LOCK) { + if (messageReceiver == null) { + messageReceiver = provider.provideSignalServiceMessageReceiver(); + } + return messageReceiver; + } + } + + public static void resetSignalServiceMessageReceiver() { + synchronized (LOCK) { + messageReceiver = null; + } + } + + public static void closeConnectionsAfterProxyFailure() { + synchronized (LOCK) { + if (incomingMessageObserver != null) { + incomingMessageObserver.terminateAsync(); + } + + if (messageSender != null) { + messageSender.cancelInFlightRequests(); + } + + incomingMessageObserver = null; + messageReceiver = null; + accountManager = null; + messageSender = null; + } + } + + public static void resetNetworkConnectionsAfterProxyChange() { + synchronized (LOCK) { + getPipeListener().reset(); + closeConnectionsAfterProxyFailure(); + } + } + + public static @NonNull SignalServiceNetworkAccess getSignalServiceNetworkAccess() { + return provider.provideSignalServiceNetworkAccess(); + } + + public static @NonNull IncomingMessageProcessor getIncomingMessageProcessor() { + if (incomingMessageProcessor == null) { + synchronized (LOCK) { + if (incomingMessageProcessor == null) { + incomingMessageProcessor = provider.provideIncomingMessageProcessor(); + } + } + } + + return incomingMessageProcessor; + } + + public static @NonNull BackgroundMessageRetriever getBackgroundMessageRetriever() { + if (backgroundMessageRetriever == null) { + synchronized (LOCK) { + if (backgroundMessageRetriever == null) { + backgroundMessageRetriever = provider.provideBackgroundMessageRetriever(); + } + } + } + + return backgroundMessageRetriever; + } + + public static @NonNull LiveRecipientCache getRecipientCache() { + if (recipientCache == null) { + synchronized (LOCK) { + if (recipientCache == null) { + recipientCache = provider.provideRecipientCache(); + } + } + } + + return recipientCache; + } + + public static @NonNull JobManager getJobManager() { + if (jobManager == null) { + synchronized (JOB_MANAGER_LOCK) { + if (jobManager == null) { + jobManager = provider.provideJobManager(); + } + } + } + + return jobManager; + } + + public static @NonNull FrameRateTracker getFrameRateTracker() { + if (frameRateTracker == null) { + synchronized (FRAME_RATE_TRACKER_LOCK) { + if (frameRateTracker == null) { + frameRateTracker = provider.provideFrameRateTracker(); + } + } + } + + return frameRateTracker; + } + + public static @NonNull MegaphoneRepository getMegaphoneRepository() { + if (megaphoneRepository == null) { + synchronized (LOCK) { + if (megaphoneRepository == null) { + megaphoneRepository = provider.provideMegaphoneRepository(); + } + } + } + + return megaphoneRepository; + } + + public static @NonNull EarlyMessageCache getEarlyMessageCache() { + if (earlyMessageCache == null) { + synchronized (LOCK) { + if (earlyMessageCache == null) { + earlyMessageCache = provider.provideEarlyMessageCache(); + } + } + } + + return earlyMessageCache; + } + + public static @NonNull MessageNotifier getMessageNotifier() { + return messageNotifier; + } + + public static @NonNull IncomingMessageObserver getIncomingMessageObserver() { + IncomingMessageObserver local = incomingMessageObserver; + + if (local != null) { + return local; + } + + synchronized (LOCK) { + if (incomingMessageObserver == null) { + incomingMessageObserver = provider.provideIncomingMessageObserver(); + } + return incomingMessageObserver; + } + } + + public static @NonNull TrimThreadsByDateManager getTrimThreadsByDateManager() { + if (trimThreadsByDateManager == null) { + synchronized (LOCK) { + if (trimThreadsByDateManager == null) { + trimThreadsByDateManager = provider.provideTrimThreadsByDateManager(); + } + } + } + + return trimThreadsByDateManager; + } + + public static TypingStatusRepository getTypingStatusRepository() { + if (typingStatusRepository == null) { + typingStatusRepository = provider.provideTypingStatusRepository(); + } + + return typingStatusRepository; + } + + public static TypingStatusSender getTypingStatusSender() { + if (typingStatusSender == null) { + typingStatusSender = provider.provideTypingStatusSender(); + } + + return typingStatusSender; + } + + public static @NonNull DatabaseObserver getDatabaseObserver() { + if (databaseObserver == null) { + synchronized (LOCK) { + if (databaseObserver == null) { + databaseObserver = provider.provideDatabaseObserver(); + } + } + } + + return databaseObserver; + } + + public static @NonNull ShakeToReport getShakeToReport() { + if (shakeToReport == null) { + synchronized (LOCK) { + if (shakeToReport == null) { + shakeToReport = provider.provideShakeToReport(); + } + } + } + + return shakeToReport; + } + + public static @NonNull AppForegroundObserver getAppForegroundObserver() { + return appForegroundObserver; + } + + + public interface Provider { + @NonNull PipeConnectivityListener providePipeListener(); + @NonNull GroupsV2Operations provideGroupsV2Operations(); + @NonNull SignalServiceAccountManager provideSignalServiceAccountManager(); + @NonNull SignalServiceMessageSender provideSignalServiceMessageSender(); + @NonNull SignalServiceMessageReceiver provideSignalServiceMessageReceiver(); + @NonNull SignalServiceNetworkAccess provideSignalServiceNetworkAccess(); + @NonNull IncomingMessageProcessor provideIncomingMessageProcessor(); + @NonNull BackgroundMessageRetriever provideBackgroundMessageRetriever(); + @NonNull LiveRecipientCache provideRecipientCache(); + @NonNull JobManager provideJobManager(); + @NonNull FrameRateTracker provideFrameRateTracker(); + @NonNull MegaphoneRepository provideMegaphoneRepository(); + @NonNull EarlyMessageCache provideEarlyMessageCache(); + @NonNull MessageNotifier provideMessageNotifier(); + @NonNull IncomingMessageObserver provideIncomingMessageObserver(); + @NonNull TrimThreadsByDateManager provideTrimThreadsByDateManager(); + @NonNull TypingStatusRepository provideTypingStatusRepository(); + @NonNull TypingStatusSender provideTypingStatusSender(); + @NonNull DatabaseObserver provideDatabaseObserver(); + @NonNull ShakeToReport provideShakeToReport(); + @NonNull AppForegroundObserver provideAppForegroundObserver(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java new file mode 100644 index 00000000..44ca781a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.dependencies; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.components.TypingStatusRepository; +import org.thoughtcrime.securesms.components.TypingStatusSender; +import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseObserver; +import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JobMigrator; +import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate; +import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; +import org.thoughtcrime.securesms.jobs.FastJobStorage; +import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; +import org.thoughtcrime.securesms.jobs.JobManagerFactories; +import org.thoughtcrime.securesms.jobs.MarkerJob; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.jobs.PushGroupSendJob; +import org.thoughtcrime.securesms.jobs.PushMediaSendJob; +import org.thoughtcrime.securesms.jobs.PushProcessMessageJob; +import org.thoughtcrime.securesms.jobs.PushTextSendJob; +import org.thoughtcrime.securesms.jobs.ReactionSendJob; +import org.thoughtcrime.securesms.jobs.TypingSendJob; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; +import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever; +import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.messages.IncomingMessageProcessor; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; +import org.thoughtcrime.securesms.notifications.MessageNotifier; +import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier; +import org.thoughtcrime.securesms.push.SecurityEventListener; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.recipients.LiveRecipientCache; +import org.thoughtcrime.securesms.service.TrimThreadsByDateManager; +import org.thoughtcrime.securesms.shakereport.ShakeToReport; +import org.thoughtcrime.securesms.util.AlarmSleepTimer; +import org.thoughtcrime.securesms.util.AppForegroundObserver; +import org.thoughtcrime.securesms.util.ByteUnit; +import org.thoughtcrime.securesms.util.EarlyMessageCache; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.FrameRateTracker; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.api.util.CredentialsProvider; +import org.whispersystems.signalservice.api.util.SleepTimer; +import org.whispersystems.signalservice.api.util.UptimeSleepTimer; + +import java.util.UUID; + +/** + * Implementation of {@link ApplicationDependencies.Provider} that provides real app dependencies. + */ +public class ApplicationDependencyProvider implements ApplicationDependencies.Provider { + + private static final String TAG = Log.tag(ApplicationDependencyProvider.class); + + private final Application context; + private final PipeConnectivityListener pipeListener; + + public ApplicationDependencyProvider(@NonNull Application context) { + this.context = context; + this.pipeListener = new PipeConnectivityListener(context); + } + + private @NonNull ClientZkOperations provideClientZkOperations() { + return ClientZkOperations.create(provideSignalServiceNetworkAccess().getConfiguration(context)); + } + + @Override + public @NonNull PipeConnectivityListener providePipeListener() { + return pipeListener; + } + + @Override + public @NonNull GroupsV2Operations provideGroupsV2Operations() { + return new GroupsV2Operations(provideClientZkOperations()); + } + + @Override + public @NonNull SignalServiceAccountManager provideSignalServiceAccountManager() { + return new SignalServiceAccountManager(provideSignalServiceNetworkAccess().getConfiguration(context), + new DynamicCredentialsProvider(context), + BuildConfig.SIGNAL_AGENT, + provideGroupsV2Operations(), + FeatureFlags.okHttpAutomaticRetry()); + } + + @Override + public @NonNull SignalServiceMessageSender provideSignalServiceMessageSender() { + return new SignalServiceMessageSender(provideSignalServiceNetworkAccess().getConfiguration(context), + new DynamicCredentialsProvider(context), + new SignalProtocolStoreImpl(context), + DatabaseSessionLock.INSTANCE, + BuildConfig.SIGNAL_AGENT, + TextSecurePreferences.isMultiDevice(context), + Optional.fromNullable(IncomingMessageObserver.getPipe()), + Optional.fromNullable(IncomingMessageObserver.getUnidentifiedPipe()), + Optional.of(new SecurityEventListener(context)), + provideClientZkOperations().getProfileOperations(), + SignalExecutors.newCachedBoundedExecutor("signal-messages", 1, 16), + ByteUnit.KILOBYTES.toBytes(512), + FeatureFlags.okHttpAutomaticRetry()); + } + + @Override + public @NonNull SignalServiceMessageReceiver provideSignalServiceMessageReceiver() { + SleepTimer sleepTimer = TextSecurePreferences.isFcmDisabled(context) ? new AlarmSleepTimer(context) + : new UptimeSleepTimer(); + return new SignalServiceMessageReceiver(provideSignalServiceNetworkAccess().getConfiguration(context), + new DynamicCredentialsProvider(context), + BuildConfig.SIGNAL_AGENT, + pipeListener, + sleepTimer, + provideClientZkOperations().getProfileOperations(), + FeatureFlags.okHttpAutomaticRetry()); + } + + @Override + public @NonNull SignalServiceNetworkAccess provideSignalServiceNetworkAccess() { + return new SignalServiceNetworkAccess(context); + } + + @Override + public @NonNull IncomingMessageProcessor provideIncomingMessageProcessor() { + return new IncomingMessageProcessor(context); + } + + @Override + public @NonNull BackgroundMessageRetriever provideBackgroundMessageRetriever() { + return new BackgroundMessageRetriever(); + } + + @Override + public @NonNull LiveRecipientCache provideRecipientCache() { + return new LiveRecipientCache(context); + } + + @Override + public @NonNull JobManager provideJobManager() { + JobManager.Configuration config = new JobManager.Configuration.Builder() + .setDataSerializer(new JsonDataSerializer()) + .setJobFactories(JobManagerFactories.getJobFactories(context)) + .setConstraintFactories(JobManagerFactories.getConstraintFactories(context)) + .setConstraintObservers(JobManagerFactories.getConstraintObservers(context)) + .setJobStorage(new FastJobStorage(JobDatabase.getInstance(context))) + .setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context))) + .addReservedJobRunner(new FactoryJobPredicate(PushDecryptMessageJob.KEY, PushProcessMessageJob.KEY, MarkerJob.KEY)) + .addReservedJobRunner(new FactoryJobPredicate(PushTextSendJob.KEY, PushMediaSendJob.KEY, PushGroupSendJob.KEY, ReactionSendJob.KEY, TypingSendJob.KEY, GroupCallUpdateSendJob.KEY)) + .build(); + return new JobManager(context, config); + } + + @Override + public @NonNull FrameRateTracker provideFrameRateTracker() { + return new FrameRateTracker(context); + } + + public @NonNull MegaphoneRepository provideMegaphoneRepository() { + return new MegaphoneRepository(context); + } + + @Override + public @NonNull EarlyMessageCache provideEarlyMessageCache() { + return new EarlyMessageCache(); + } + + @Override + public @NonNull MessageNotifier provideMessageNotifier() { + return new OptimizedMessageNotifier(new DefaultMessageNotifier()); + } + + @Override + public @NonNull IncomingMessageObserver provideIncomingMessageObserver() { + return new IncomingMessageObserver(context); + } + + @Override + public @NonNull TrimThreadsByDateManager provideTrimThreadsByDateManager() { + return new TrimThreadsByDateManager(context); + } + + @Override + public @NonNull TypingStatusRepository provideTypingStatusRepository() { + return new TypingStatusRepository(); + } + + @Override + public @NonNull TypingStatusSender provideTypingStatusSender() { + return new TypingStatusSender(); + } + + @Override + public @NonNull DatabaseObserver provideDatabaseObserver() { + return new DatabaseObserver(context); + } + + @Override + public @NonNull ShakeToReport provideShakeToReport() { + return new ShakeToReport(context); + } + + @Override + public @NonNull AppForegroundObserver provideAppForegroundObserver() { + return new AppForegroundObserver(); + } + + private static class DynamicCredentialsProvider implements CredentialsProvider { + + private final Context context; + + private DynamicCredentialsProvider(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public UUID getUuid() { + return TextSecurePreferences.getLocalUuid(context); + } + + @Override + public String getE164() { + return TextSecurePreferences.getLocalNumber(context); + } + + @Override + public String getPassword() { + return TextSecurePreferences.getPushServerPassword(context); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicelist/Device.java b/app/src/main/java/org/thoughtcrime/securesms/devicelist/Device.java new file mode 100644 index 00000000..1cf30259 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicelist/Device.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.devicelist; + +public class Device { + + private final long id; + private final String name; + private final long created; + private final long lastSeen; + + public Device(long id, String name, long created, long lastSeen) { + this.id = id; + this.name = name; + this.created = created; + this.lastSeen = lastSeen; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public long getCreated() { + return created; + } + + public long getLastSeen() { + return lastSeen; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java new file mode 100644 index 00000000..81dea3d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java @@ -0,0 +1,216 @@ +package org.thoughtcrime.securesms.events; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.whispersystems.libsignal.IdentityKey; + +import java.util.Objects; + +public final class CallParticipant { + + public static final CallParticipant EMPTY = createRemote(new CallParticipantId(Recipient.UNKNOWN), Recipient.UNKNOWN, null, new BroadcastVideoSink(null), false, false, 0, true, 0, DeviceOrdinal.PRIMARY); + + private final @NonNull CallParticipantId callParticipantId; + private final @NonNull CameraState cameraState; + private final @NonNull Recipient recipient; + private final @Nullable IdentityKey identityKey; + private final @NonNull BroadcastVideoSink videoSink; + private final boolean videoEnabled; + private final boolean microphoneEnabled; + private final long lastSpoke; + private final boolean mediaKeysReceived; + private final long addedToCallTime; + private final @NonNull DeviceOrdinal deviceOrdinal; + + public static @NonNull CallParticipant createLocal(@NonNull CameraState cameraState, + @NonNull BroadcastVideoSink renderer, + boolean microphoneEnabled) + { + return new CallParticipant(new CallParticipantId(Recipient.self()), + Recipient.self(), + null, + renderer, + cameraState, + cameraState.isEnabled() && cameraState.getCameraCount() > 0, + microphoneEnabled, + 0, + true, + 0, + DeviceOrdinal.PRIMARY); + } + + public static @NonNull CallParticipant createRemote(@NonNull CallParticipantId callParticipantId, + @NonNull Recipient recipient, + @Nullable IdentityKey identityKey, + @NonNull BroadcastVideoSink renderer, + boolean audioEnabled, + boolean videoEnabled, + long lastSpoke, + boolean mediaKeysReceived, + long addedToCallTime, + @NonNull DeviceOrdinal deviceOrdinal) + { + return new CallParticipant(callParticipantId, recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, audioEnabled, lastSpoke, mediaKeysReceived, addedToCallTime, deviceOrdinal); + } + + private CallParticipant(@NonNull CallParticipantId callParticipantId, + @NonNull Recipient recipient, + @Nullable IdentityKey identityKey, + @NonNull BroadcastVideoSink videoSink, + @NonNull CameraState cameraState, + boolean videoEnabled, + boolean microphoneEnabled, + long lastSpoke, + boolean mediaKeysReceived, + long addedToCallTime, + @NonNull DeviceOrdinal deviceOrdinal) + { + this.callParticipantId = callParticipantId; + this.recipient = recipient; + this.identityKey = identityKey; + this.videoSink = videoSink; + this.cameraState = cameraState; + this.videoEnabled = videoEnabled; + this.microphoneEnabled = microphoneEnabled; + this.lastSpoke = lastSpoke; + this.mediaKeysReceived = mediaKeysReceived; + this.addedToCallTime = addedToCallTime; + this.deviceOrdinal = deviceOrdinal; + } + + public @NonNull CallParticipant withIdentityKey(@Nullable IdentityKey identityKey) { + return new CallParticipant(callParticipantId, recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived, addedToCallTime, deviceOrdinal); + } + + public @NonNull CallParticipant withVideoEnabled(boolean videoEnabled) { + return new CallParticipant(callParticipantId, recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived, addedToCallTime, deviceOrdinal); + } + + public @NonNull CallParticipantId getCallParticipantId() { + return callParticipantId; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @NonNull String getRecipientDisplayName(@NonNull Context context) { + if (recipient.isSelf() && isPrimary()) { + return context.getString(R.string.CallParticipant__you); + } else if (recipient.isSelf()) { + return context.getString(R.string.CallParticipant__you_on_another_device); + } else if (isPrimary()) { + return recipient.getDisplayName(context); + } else { + return context.getString(R.string.CallParticipant__s_on_another_device, recipient.getDisplayName(context)); + } + } + + public @NonNull String getShortRecipientDisplayName(@NonNull Context context) { + if (recipient.isSelf() && isPrimary()) { + return context.getString(R.string.CallParticipant__you); + } else if (recipient.isSelf()) { + return context.getString(R.string.CallParticipant__you_on_another_device); + } else if (isPrimary()) { + return recipient.getShortDisplayName(context); + } else { + return context.getString(R.string.CallParticipant__s_on_another_device, recipient.getShortDisplayName(context)); + } + } + + public @Nullable IdentityKey getIdentityKey() { + return identityKey; + } + + public @NonNull BroadcastVideoSink getVideoSink() { + return videoSink; + } + + public @NonNull CameraState getCameraState() { + return cameraState; + } + + public boolean isVideoEnabled() { + return videoEnabled; + } + + public boolean isMicrophoneEnabled() { + return microphoneEnabled; + } + + public @NonNull CameraState.Direction getCameraDirection() { + if (cameraState.getActiveDirection() == CameraState.Direction.BACK) { + return cameraState.getActiveDirection(); + } + return CameraState.Direction.FRONT; + } + + public boolean isMoreThanOneCameraAvailable() { + return cameraState.getCameraCount() > 1; + } + + public long getLastSpoke() { + return lastSpoke; + } + + public boolean isMediaKeysReceived() { + return mediaKeysReceived; + } + + public long getAddedToCallTime() { + return addedToCallTime; + } + + public boolean isPrimary() { + return deviceOrdinal == DeviceOrdinal.PRIMARY; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CallParticipant that = (CallParticipant) o; + return callParticipantId.equals(that.callParticipantId) && + videoEnabled == that.videoEnabled && + microphoneEnabled == that.microphoneEnabled && + lastSpoke == that.lastSpoke && + mediaKeysReceived == that.mediaKeysReceived && + addedToCallTime == that.addedToCallTime && + cameraState.equals(that.cameraState) && + recipient.equals(that.recipient) && + Objects.equals(identityKey, that.identityKey) && + Objects.equals(videoSink, that.videoSink); + } + + @Override + public int hashCode() { + return Objects.hash(callParticipantId, cameraState, recipient, identityKey, videoSink, videoEnabled, microphoneEnabled, lastSpoke, mediaKeysReceived, addedToCallTime); + } + + @Override + public @NonNull String toString() { + return "CallParticipant{" + + "cameraState=" + cameraState + + ", recipient=" + recipient.getId() + + ", identityKey=" + (identityKey == null ? "absent" : "present") + + ", videoSink=" + (videoSink.getEglBase() == null ? "not initialized" : "initialized") + + ", videoEnabled=" + videoEnabled + + ", microphoneEnabled=" + microphoneEnabled + + ", lastSpoke=" + lastSpoke + + ", mediaKeysReceived=" + mediaKeysReceived + + ", addedToCallTime=" + addedToCallTime + + '}'; + } + + public enum DeviceOrdinal { + PRIMARY, + SECONDARY + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java new file mode 100644 index 00000000..16e43551 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +/** + * Allow system to identify a call participant by their device demux id and their + * recipient id. + */ +public final class CallParticipantId { + + public static final long DEFAULT_ID = -1; + + private final long demuxId; + private final RecipientId recipientId; + + public CallParticipantId(@NonNull Recipient recipient) { + this(DEFAULT_ID, recipient.getId()); + } + + public CallParticipantId(long demuxId, @NonNull RecipientId recipientId) { + this.demuxId = demuxId; + this.recipientId = recipientId; + } + + public long getDemuxId() { + return demuxId; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final CallParticipantId that = (CallParticipantId) o; + return demuxId == that.demuxId && + recipientId.equals(that.recipientId); + } + + @Override + public int hashCode() { + return Objects.hash(demuxId, recipientId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallPeekEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallPeekEvent.java new file mode 100644 index 00000000..93728f87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallPeekEvent.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +public final class GroupCallPeekEvent { + private final RecipientId groupRecipientId; + private final String eraId; + private final long deviceCount; + private final long deviceLimit; + + public GroupCallPeekEvent(@NonNull RecipientId groupRecipientId, @Nullable String eraId, @Nullable Long deviceCount, @Nullable Long deviceLimit) { + this.groupRecipientId = groupRecipientId; + this.eraId = eraId; + this.deviceCount = deviceCount != null ? deviceCount : 0; + this.deviceLimit = deviceLimit != null ? deviceLimit : 0; + } + + public @NonNull RecipientId getGroupRecipientId() { + return groupRecipientId; + } + + public boolean isOngoing() { + return eraId != null && deviceCount > 0; + } + + public boolean callHasCapacity() { + return isOngoing() && deviceCount < deviceLimit; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java new file mode 100644 index 00000000..1c669d82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; + +public final class PartProgressEvent { + + public final Attachment attachment; + public final Type type; + public final long total; + public final long progress; + + public enum Type { + COMPRESSION, + NETWORK + } + + public PartProgressEvent(@NonNull Attachment attachment, @NonNull Type type, long total, long progress) { + this.attachment = attachment; + this.type = type; + this.total = total; + this.progress = progress; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/RedPhoneEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/RedPhoneEvent.java new file mode 100644 index 00000000..92f949aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/RedPhoneEvent.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class RedPhoneEvent { + + public enum Type { + CALL_CONNECTED, + WAITING_FOR_RESPONDER, + SERVER_FAILURE, + PERFORMING_HANDSHAKE, + HANDSHAKE_FAILED, + CONNECTING_TO_INITIATOR, + CALL_DISCONNECTED, + CALL_RINGING, + SERVER_MESSAGE, + RECIPIENT_UNAVAILABLE, + INCOMING_CALL, + OUTGOING_CALL, + CALL_BUSY, + LOGIN_FAILED, + CLIENT_FAILURE, + DEBUG_INFO, + NO_SUCH_USER + } + + private final @NonNull Type type; + private final @NonNull Recipient recipient; + private final @Nullable String extra; + + public RedPhoneEvent(@NonNull Type type, @NonNull Recipient recipient, @Nullable String extra) { + this.type = type; + this.recipient = recipient; + this.extra = extra; + } + + public @NonNull Type getType() { + return type; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @Nullable String getExtra() { + return extra; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/ReminderUpdateEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/ReminderUpdateEvent.java new file mode 100644 index 00000000..582143a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/ReminderUpdateEvent.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.events; + + +public class ReminderUpdateEvent { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java new file mode 100644 index 00000000..00d8f35f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.OptionalLong; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; + +import java.util.List; +import java.util.Set; + +public class WebRtcViewModel { + + public enum State { + IDLE, + + // Normal states + CALL_PRE_JOIN, + CALL_INCOMING, + CALL_OUTGOING, + CALL_CONNECTED, + CALL_RINGING, + CALL_BUSY, + CALL_DISCONNECTED, + CALL_NEEDS_PERMISSION, + + // Error states + NETWORK_FAILURE, + RECIPIENT_UNAVAILABLE, + NO_SUCH_USER, + UNTRUSTED_IDENTITY, + + // Multiring Hangup States + CALL_ACCEPTED_ELSEWHERE, + CALL_DECLINED_ELSEWHERE, + CALL_ONGOING_ELSEWHERE; + + public boolean isErrorState() { + return this == NETWORK_FAILURE || + this == RECIPIENT_UNAVAILABLE || + this == NO_SUCH_USER || + this == UNTRUSTED_IDENTITY; + } + + public boolean isPreJoinOrNetworkUnavailable() { + return this == CALL_PRE_JOIN || this == NETWORK_FAILURE; + } + + public boolean isPassedPreJoin() { + return this.ordinal() > CALL_PRE_JOIN.ordinal(); + } + } + + public enum GroupCallState { + IDLE, + DISCONNECTED, + CONNECTING, + RECONNECTING, + CONNECTED, + CONNECTED_AND_JOINING, + CONNECTED_AND_JOINED; + + public boolean isNotIdle() { + return this != IDLE; + } + + public boolean isConnected() { + switch (this) { + case CONNECTED: + case CONNECTED_AND_JOINING: + case CONNECTED_AND_JOINED: + return true; + } + + return false; + } + + public boolean isNotIdleOrConnected() { + switch (this) { + case DISCONNECTED: + case CONNECTING: + case RECONNECTING: + return true; + } + + return false; + } + } + + private final @NonNull State state; + private final @NonNull GroupCallState groupState; + private final @NonNull Recipient recipient; + + private final boolean isBluetoothAvailable; + private final boolean isRemoteVideoOffer; + private final long callConnectedTime; + + private final CallParticipant localParticipant; + private final List remoteParticipants; + private final Set identityChangedRecipients; + private final OptionalLong remoteDevicesCount; + private final Long participantLimit; + + public WebRtcViewModel(@NonNull WebRtcServiceState state) { + this.state = state.getCallInfoState().getCallState(); + this.groupState = state.getCallInfoState().getGroupCallState(); + this.recipient = state.getCallInfoState().getCallRecipient(); + this.isRemoteVideoOffer = state.getCallSetupState().isRemoteVideoOffer(); + this.isBluetoothAvailable = state.getLocalDeviceState().isBluetoothAvailable(); + this.remoteParticipants = state.getCallInfoState().getRemoteCallParticipants(); + this.identityChangedRecipients = state.getCallInfoState().getIdentityChangedRecipients(); + this.callConnectedTime = state.getCallInfoState().getCallConnectedTime(); + this.remoteDevicesCount = state.getCallInfoState().getRemoteDevicesCount(); + this.participantLimit = state.getCallInfoState().getParticipantLimit(); + this.localParticipant = CallParticipant.createLocal(state.getLocalDeviceState().getCameraState(), + state.getVideoState().getLocalSink() != null ? state.getVideoState().getLocalSink() + : new BroadcastVideoSink(null), + state.getLocalDeviceState().isMicrophoneEnabled()); + } + + public @NonNull State getState() { + return state; + } + + public @NonNull GroupCallState getGroupState() { + return groupState; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public boolean isRemoteVideoEnabled() { + return Stream.of(remoteParticipants).anyMatch(CallParticipant::isVideoEnabled) || (groupState.isNotIdle() && remoteParticipants.size() > 1); + } + + public boolean isBluetoothAvailable() { + return isBluetoothAvailable; + } + + public boolean isRemoteVideoOffer() { + return isRemoteVideoOffer; + } + + public long getCallConnectedTime() { + return callConnectedTime; + } + + public @NonNull CallParticipant getLocalParticipant() { + return localParticipant; + } + + public @NonNull List getRemoteParticipants() { + return remoteParticipants; + } + + public @NonNull Set getIdentityChangedParticipants() { + return identityChangedRecipients; + } + + public OptionalLong getRemoteDevicesCount() { + return remoteDevicesCount; + } + + public @Nullable Long getParticipantLimit() { + return participantLimit; + } + + @Override + public @NonNull String toString() { + return "WebRtcViewModel{" + + "state=" + state + + ", recipient=" + recipient.getId() + + ", isBluetoothAvailable=" + isBluetoothAvailable + + ", isRemoteVideoOffer=" + isRemoteVideoOffer + + ", callConnectedTime=" + callConnectedTime + + ", localParticipant=" + localParticipant + + ", remoteParticipants=" + remoteParticipants + + ", identityChangedRecipients=" + identityChangedRecipients + + ", remoteDevicesCount=" + remoteDevicesCount + + ", participantLimit=" + participantLimit + + '}'; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmFetchService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmFetchService.java new file mode 100644 index 00000000..3f39ed4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmFetchService.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.gcm; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.firebase.messaging.RemoteMessage; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; +import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever; +import org.thoughtcrime.securesms.messages.RestStrategy; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This service does the actual network fetch in response to an FCM message. + * + * Our goals with FCM processing are as follows: + * (1) Ensure some service is active for the duration of the fetch and processing stages. + * (2) Do not make unnecessary network requests. + * + * To fulfill goal 1, this service will not call {@link #stopSelf()} until there is no more running + * requests. + * + * To fulfill goal 2, this service will not enqueue a fetch if there are already 2 active fetches + * (or rather, 1 active and 1 waiting, since we use a single thread executor). + * + * Unfortunately we can't do this all in {@link FcmReceiveService} because it won't let us process + * the next FCM message until {@link FcmReceiveService#onMessageReceived(RemoteMessage)} returns, + * but as soon as that method returns, it could also destroy the service. By not letting us control + * when the service is destroyed, we can't accomplish both goals within that service. + */ +public class FcmFetchService extends Service { + + private static final String TAG = Log.tag(FcmFetchService.class); + + private static final SerialMonoLifoExecutor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED); + + private final AtomicInteger activeCount = new AtomicInteger(0); + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + boolean performedReplace = EXECUTOR.enqueue(this::fetch); + + if (performedReplace) { + Log.i(TAG, "Already have one running and one enqueued. Ignoring."); + } else { + int count = activeCount.incrementAndGet(); + Log.i(TAG, "Incrementing active count to " + count); + } + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + Log.i(TAG, "onDestroy()"); + } + + @Override + public @Nullable IBinder onBind(Intent intent) { + return null; + } + + private void fetch() { + retrieveMessages(this); + + if (activeCount.decrementAndGet() == 0) { + Log.d(TAG, "No more active. Stopping."); + stopSelf(); + } + } + + static void retrieveMessages(@NonNull Context context) { + BackgroundMessageRetriever retriever = ApplicationDependencies.getBackgroundMessageRetriever(); + boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy()); + + if (success) { + Log.i(TAG, "Successfully retrieved messages."); + } else { + if (Build.VERSION.SDK_INT >= 26) { + Log.w(TAG, "Failed to retrieve messages. Scheduling on the system JobScheduler (API " + Build.VERSION.SDK_INT + ")."); + FcmJobService.schedule(context); + } else { + Log.w(TAG, "Failed to retrieve messages. Scheduling on JobManager (API " + Build.VERSION.SDK_INT + ")."); + ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob()); + } + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmJobService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmJobService.java new file mode 100644 index 00000000..974bc68d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmJobService.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.gcm; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever; +import org.thoughtcrime.securesms.messages.RestStrategy; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Pulls down messages. Used when we fail to pull down messages in {@link FcmReceiveService}. + */ +@RequiresApi(26) +public class FcmJobService extends JobService { + + private static final String TAG = FcmJobService.class.getSimpleName(); + + private static final int ID = 1337; + + @RequiresApi(26) + public static void schedule(@NonNull Context context) { + JobInfo.Builder jobInfoBuilder = new JobInfo.Builder(ID, new ComponentName(context, FcmJobService.class)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setBackoffCriteria(0, JobInfo.BACKOFF_POLICY_LINEAR) + .setPersisted(true); + + ServiceUtil.getJobScheduler(context).schedule(jobInfoBuilder.build()); + } + + @Override + public boolean onStartJob(JobParameters params) { + Log.d(TAG, "onStartJob()"); + + if (BackgroundMessageRetriever.shouldIgnoreFetch(this)) { + Log.i(TAG, "App is foregrounded. No need to run."); + return false; + } + + SignalExecutors.UNBOUNDED.execute(() -> { + Context context = getApplicationContext(); + BackgroundMessageRetriever retriever = ApplicationDependencies.getBackgroundMessageRetriever(); + boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy()); + + if (success) { + Log.i(TAG, "Successfully retrieved messages."); + jobFinished(params, false); + } else { + Log.w(TAG, "Failed to retrieve messages. Scheduling a retry."); + jobFinished(params, true); + } + }); + + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + Log.d(TAG, "onStopJob()"); + return TextSecurePreferences.getNeedsMessagePull(getApplicationContext()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java new file mode 100644 index 00000000..96e6af42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.gcm; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.registration.PushChallengeRequest; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; + +public class FcmReceiveService extends FirebaseMessagingService { + + private static final String TAG = FcmReceiveService.class.getSimpleName(); + + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + Log.i(TAG, String.format(Locale.US, + "onMessageReceived() ID: %s, Delay: %d, Priority: %d, Original Priority: %d", + remoteMessage.getMessageId(), + (System.currentTimeMillis() - remoteMessage.getSentTime()), + remoteMessage.getPriority(), + remoteMessage.getOriginalPriority())); + + String challenge = remoteMessage.getData().get("challenge"); + if (challenge != null) { + handlePushChallenge(challenge); + } else { + handleReceivedNotification(ApplicationDependencies.getApplication()); + } + } + + @Override + public void onDeletedMessages() { + Log.w(TAG, "onDeleteMessages() -- Messages may have been dropped. Doing a normal message fetch."); + handleReceivedNotification(ApplicationDependencies.getApplication()); + } + + @Override + public void onNewToken(String token) { + Log.i(TAG, "onNewToken()"); + + if (!TextSecurePreferences.isPushRegistered(ApplicationDependencies.getApplication())) { + Log.i(TAG, "Got a new FCM token, but the user isn't registered."); + return; + } + + ApplicationDependencies.getJobManager().add(new FcmRefreshJob()); + } + + @Override + public void onMessageSent(@NonNull String s) { + Log.i(TAG, "onMessageSent()" + s); + } + + @Override + public void onSendError(@NonNull String s, @NonNull Exception e) { + Log.w(TAG, "onSendError()", e); + } + + private static void handleReceivedNotification(Context context) { + try { + context.startService(new Intent(context, FcmFetchService.class)); + } catch (Exception e) { + Log.w(TAG, "Failed to start service. Falling back to legacy approach."); + FcmFetchService.retrieveMessages(context); + } + } + + private static void handlePushChallenge(@NonNull String challenge) { + Log.d(TAG, String.format("Got a push challenge \"%s\"", challenge)); + + PushChallengeRequest.postChallengeResponse(challenge); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmUtil.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmUtil.java new file mode 100644 index 00000000..f2ff0daf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmUtil.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.gcm; + +import android.text.TextUtils; + +import androidx.annotation.WorkerThread; + +import com.google.firebase.iid.FirebaseInstanceId; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +public final class FcmUtil { + + private static final String TAG = FcmUtil.class.getSimpleName(); + + /** + * Retrieves the current FCM token. If one isn't available, it'll be generated. + */ + @WorkerThread + public static Optional getToken() { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference token = new AtomicReference<>(null); + + FirebaseInstanceId.getInstance().getInstanceId().addOnCompleteListener(task -> { + if (task.isSuccessful() && task.getResult() != null && !TextUtils.isEmpty(task.getResult().getToken())) { + token.set(task.getResult().getToken()); + } else { + Log.w(TAG, "Failed to get the token.", task.getException()); + } + + latch.countDown(); + }); + + try { + latch.await(); + } catch (InterruptedException e) { + Log.w(TAG, "Was interrupted while waiting for the token."); + } + + return Optional.fromNullable(token.get()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java b/app/src/main/java/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java new file mode 100644 index 00000000..fcfbcb76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/model/ChunkedImageUrl.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.giph.model; + + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.Key; + +import org.signal.core.util.Conversions; + +import java.security.MessageDigest; + +public class ChunkedImageUrl implements Key { + + public static final long SIZE_UNKNOWN = -1; + + private final String url; + private final long size; + + public ChunkedImageUrl(@NonNull String url) { + this(url, SIZE_UNKNOWN); + } + + public ChunkedImageUrl(@NonNull String url, long size) { + if (url == null) throw new RuntimeException(); + this.url = url; + this.size = size; + } + + public String getUrl() { + return url; + } + + public long getSize() { + return size; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(url.getBytes()); + messageDigest.update(Conversions.longToByteArray(size)); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof ChunkedImageUrl)) return false; + + ChunkedImageUrl that = (ChunkedImageUrl)other; + + return this.url.equals(that.url) && this.size == that.size; + } + + @Override + public int hashCode() { + return url.hashCode() ^ (int)size; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyImage.java b/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyImage.java new file mode 100644 index 00000000..2b0ebbab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyImage.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.giph.model; + + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GiphyImage { + + @JsonProperty + private ImageTypes images; + + @JsonProperty("is_sticker") + private boolean isSticker; + + public boolean isSticker() { + return isSticker; + } + + public String getGifUrl() { + ImageData data = getGifData(); + return data != null ? data.url : null; + } + + public long getGifSize() { + ImageData data = getGifData(); + return data != null ? data.size : 0; + } + + public String getGifMmsUrl() { + ImageData data = getGifMmsData(); + return data != null ? data.url : null; + } + + public long getMmsGifSize() { + ImageData data = getGifMmsData(); + return data != null ? data.size : 0; + } + + public float getGifAspectRatio() { + return (float)images.downsized.width / (float)images.downsized.height; + } + + public int getGifWidth() { + ImageData data = getGifData(); + return data != null ? data.width : 0; + } + + public int getGifHeight() { + ImageData data = getGifData(); + return data != null ? data.height : 0; + } + + public String getStillUrl() { + ImageData data = getStillData(); + return data != null ? data.url : null; + } + + public long getStillSize() { + ImageData data = getStillData(); + return data != null ? data.size : 0; + } + + private @Nullable ImageData getGifData() { + return getFirstNonEmpty(images.downsized, images.downsized_medium, images.fixed_height, images.fixed_width); + } + + private @Nullable ImageData getGifMmsData() { + return getFirstNonEmpty(images.fixed_height_downsampled, images.fixed_width_downsampled); + } + + private @Nullable ImageData getStillData() { + return getFirstNonEmpty(images.downsized_still, images.fixed_height_still, images.fixed_width_still); + } + + private static @Nullable ImageData getFirstNonEmpty(ImageData... data) { + for (ImageData image : data) { + if (!TextUtils.isEmpty(image.url)) { + return image; + } + } + + return null; + } + + public static class ImageTypes { + @JsonProperty + private ImageData fixed_height; + @JsonProperty + private ImageData fixed_height_still; + @JsonProperty + private ImageData fixed_height_downsampled; + @JsonProperty + private ImageData fixed_width; + @JsonProperty + private ImageData fixed_width_still; + @JsonProperty + private ImageData fixed_width_downsampled; + @JsonProperty + private ImageData fixed_width_small; + @JsonProperty + private ImageData downsized_medium; + @JsonProperty + private ImageData downsized; + @JsonProperty + private ImageData downsized_still; + } + + public static class ImageData { + @JsonProperty + private String url; + + @JsonProperty + private int width; + + @JsonProperty + private int height; + + @JsonProperty + private int size; + + @JsonProperty + private String mp4; + + @JsonProperty + private String webp; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyResponse.java b/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyResponse.java new file mode 100644 index 00000000..4ab61b57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/model/GiphyResponse.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.giph.model; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class GiphyResponse { + + @JsonProperty + private List data; + + public List getData() { + return data; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java new file mode 100644 index 00000000..b88ea200 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class GiphyGifLoader extends GiphyLoader { + + public GiphyGifLoader(@NonNull Context context, @Nullable String searchString) { + super(context, searchString); + } + + @Override + protected String getTrendingUrl() { + return "https://api.giphy.com/v1/gifs/trending?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE; + } + + @Override + protected String getSearchUrl() { + return "https://api.giphy.com/v1/gifs/search?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s"; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyLoader.java b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyLoader.java new file mode 100644 index 00000000..de3677d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyLoader.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.model.GiphyResponse; +import org.thoughtcrime.securesms.net.ContentProxySelector; +import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.util.AsyncLoader; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public abstract class GiphyLoader extends AsyncLoader> { + + private static final String TAG = GiphyLoader.class.getSimpleName(); + + public static int PAGE_SIZE = 100; + + @Nullable private String searchString; + + private final OkHttpClient client; + + protected GiphyLoader(@NonNull Context context, @Nullable String searchString) { + super(context); + this.searchString = searchString; + this.client = new OkHttpClient.Builder() + .proxySelector(new ContentProxySelector()) + .addInterceptor(new StandardUserAgentInterceptor()) + .dns(SignalServiceNetworkAccess.DNS) + .build(); + } + + @Override + public List loadInBackground() { + return loadPage(0); + } + + public @NonNull List loadPage(int offset) { + try { + String url; + + if (TextUtils.isEmpty(searchString)) url = String.format(getTrendingUrl(), offset); + else url = String.format(getSearchUrl(), offset, Uri.encode(searchString)); + + Request request = new Request.Builder().url(url).build(); + Response response = client.newCall(request).execute(); + + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + GiphyResponse giphyResponse = JsonUtils.fromJson(response.body().byteStream(), GiphyResponse.class); + List results = Stream.of(giphyResponse.getData()) + .filterNot(g -> TextUtils.isEmpty(g.getGifUrl())) + .filterNot(g -> TextUtils.isEmpty(g.getGifMmsUrl())) + .filterNot(g -> TextUtils.isEmpty(g.getStillUrl())) + .toList(); + + if (results == null) return new LinkedList<>(); + else return results; + + } catch (IOException e) { + Log.w(TAG, e); + return new LinkedList<>(); + } + } + + protected abstract String getTrendingUrl(); + protected abstract String getSearchUrl(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java new file mode 100644 index 00000000..06fcb18a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class GiphyStickerLoader extends GiphyLoader { + + public GiphyStickerLoader(@NonNull Context context, @Nullable String searchString) { + super(context, searchString); + } + + @Override + protected String getTrendingUrl() { + return "https://api.giphy.com/v1/stickers/trending?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE; + } + + @Override + protected String getSearchUrl() { + return "https://api.giphy.com/v1/stickers/search?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s"; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java new file mode 100644 index 00000000..1a772d5f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2015 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.thoughtcrime.securesms.giph.ui; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.appcompat.widget.AppCompatImageView; + +/** + * AspectRatioImageView maintains an aspect ratio by adjusting the width or height dimension. The + * aspect ratio (width to height ratio) and adjustment dimension can be configured. + */ +public class AspectRatioImageView extends AppCompatImageView { + + private static final float DEFAULT_ASPECT_RATIO = 1.0f; + private static final int DEFAULT_ADJUST_DIMENSION = 0; + // defined by attrs.xml enum + static final int ADJUST_DIMENSION_HEIGHT = 0; + static final int ADJUST_DIMENSION_WIDTH = 1; + + private double aspectRatio; // width to height ratio + private int dimensionToAdjust; // ADJUST_DIMENSION_HEIGHT or ADJUST_DIMENSION_WIDTH + + public AspectRatioImageView(Context context) { + this(context, null); + } + + public AspectRatioImageView(Context context, AttributeSet attrs) { + super(context, attrs); +// final TypedArray a = context.obtainStyledAttributes(attrs, +// R.styleable.tw__AspectRatioImageView); +// try { +// aspectRatio = a.getFloat(R.styleable.tw__AspectRatioImageView_tw__image_aspect_ratio, +// DEFAULT_ASPECT_RATIO); +// dimensionToAdjust +// = a.getInt(R.styleable.tw__AspectRatioImageView_tw__image_dimension_to_adjust, +// DEFAULT_ADJUST_DIMENSION); +// } finally { +// a.recycle(); +// } + } + + public double getAspectRatio() { + return aspectRatio; + } + + public int getDimensionToAdjust() { + return dimensionToAdjust; + } + + /** + * Sets the aspect ratio that should be respected during measurement. + * + * @param aspectRatio desired width to height ratio + */ + public void setAspectRatio(final double aspectRatio) { + this.aspectRatio = aspectRatio; + } + + /** + * Resets the size to 0. + */ + public void resetSize() { + if (getMeasuredWidth() == 0 && getMeasuredHeight() == 0) { + return; + } + measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY)); + layout(0, 0, 0, 0); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int width = getMeasuredWidth(); + int height = getMeasuredHeight(); + if (dimensionToAdjust == ADJUST_DIMENSION_HEIGHT) { + height = calculateHeight(width, aspectRatio); + } else { + width = calculateWidth(height, aspectRatio); + } + setMeasuredDimension(width, height); + } + + /** + * Returns the height that will satisfy the width to height aspect ratio, keeping the given + * width fixed. + */ + int calculateHeight(int width, double ratio) { + if (ratio == 0) { + return 0; + } + return (int) Math.round(width / ratio); + } + + /** + * Returns the width that will satisfy the width to height aspect ratio, keeping the given + * height fixed. + */ + int calculateWidth(int height, double ratio) { + return (int) Math.round(height * ratio); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java new file mode 100644 index 00000000..bd0688a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -0,0 +1,200 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.WindowUtil; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +public class GiphyActivity extends PassphraseRequiredActivity + implements GiphyActivityToolbar.OnLayoutChangedListener, + GiphyActivityToolbar.OnFilterChangedListener, + GiphyAdapter.OnItemClickListener +{ + + private static final String TAG = GiphyActivity.class.getSimpleName(); + + public static final String EXTRA_IS_MMS = "extra_is_mms"; + public static final String EXTRA_WIDTH = "extra_width"; + public static final String EXTRA_HEIGHT = "extra_height"; + public static final String EXTRA_COLOR = "extra_color"; + public static final String EXTRA_BORDERLESS = "extra_borderless"; + + private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private GiphyGifFragment gifFragment; + private GiphyStickerFragment stickerFragment; + private boolean forMms; + + private GiphyAdapter.GiphyViewHolder finishingImage; + + @Override + public void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.giphy_activity); + + initializeToolbar(); + initializeResources(); + } + + private void initializeToolbar() { + + GiphyActivityToolbar toolbar = findViewById(R.id.giphy_toolbar); + toolbar.setOnFilterChangedListener(this); + toolbar.setOnLayoutChangedListener(this); + toolbar.setPersistence(GiphyActivityToolbarTextSecurePreferencesPersistence.fromContext(this)); + + final int conversationColor = getConversationColor(); + toolbar.setBackgroundColor(conversationColor); + WindowUtil.setStatusBarColor(getWindow(), conversationColor); + + setSupportActionBar(toolbar); + + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + getSupportActionBar().setDisplayShowTitleEnabled(false); + } + + private void initializeResources() { + ViewPager viewPager = findViewById(R.id.giphy_pager); + TabLayout tabLayout = findViewById(R.id.tab_layout); + + this.gifFragment = new GiphyGifFragment(); + this.stickerFragment = new GiphyStickerFragment(); + this.forMms = getIntent().getBooleanExtra(EXTRA_IS_MMS, false); + + gifFragment.setClickListener(this); + stickerFragment.setClickListener(this); + + viewPager.setAdapter(new GiphyFragmentPagerAdapter(this, getSupportFragmentManager(), + gifFragment, stickerFragment)); + tabLayout.setupWithViewPager(viewPager); + tabLayout.setBackgroundColor(getConversationColor()); + } + + private @ColorInt int getConversationColor() { + return getIntent().getIntExtra(EXTRA_COLOR, ActivityCompat.getColor(this, R.color.core_ultramarine)); + } + + @Override + public void onFilterChanged(String filter) { + this.gifFragment.setSearchString(filter); + this.stickerFragment.setSearchString(filter); + } + + @Override + public void onLayoutChanged(boolean gridLayout) { + gifFragment.setLayoutManager(gridLayout); + stickerFragment.setLayoutManager(gridLayout); + } + + @SuppressLint("StaticFieldLeak") + @Override + public void onClick(final GiphyAdapter.GiphyViewHolder viewHolder) { + if (finishingImage != null) finishingImage.gifProgress.setVisibility(View.GONE); + finishingImage = viewHolder; + finishingImage.gifProgress.setVisibility(View.VISIBLE); + + new AsyncTask() { + @Override + protected Uri doInBackground(Void... params) { + try { + byte[] data = viewHolder.getData(forMms); + + return BlobProvider.getInstance() + .forData(data) + .withMimeType(MediaUtil.IMAGE_GIF) + .createForSingleSessionOnDisk(GiphyActivity.this); + } catch (InterruptedException | ExecutionException | IOException e) { + Log.w(TAG, e); + return null; + } + } + + protected void onPostExecute(@Nullable Uri uri) { + if (uri == null) { + Toast.makeText(GiphyActivity.this, R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show(); + } else if (viewHolder == finishingImage) { + Intent intent = new Intent(); + intent.setData(uri); + intent.putExtra(EXTRA_WIDTH, viewHolder.image.getGifWidth()); + intent.putExtra(EXTRA_HEIGHT, viewHolder.image.getGifHeight()); + intent.putExtra(EXTRA_BORDERLESS, viewHolder.image.isSticker()); + setResult(RESULT_OK, intent); + finish(); + } else { + Log.w(TAG, "Resolved Uri is no longer the selected element..."); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private static class GiphyFragmentPagerAdapter extends FragmentPagerAdapter { + + private final Context context; + private final GiphyGifFragment gifFragment; + private final GiphyStickerFragment stickerFragment; + + private GiphyFragmentPagerAdapter(@NonNull Context context, + @NonNull FragmentManager fragmentManager, + @NonNull GiphyGifFragment gifFragment, + @NonNull GiphyStickerFragment stickerFragment) + { + super(fragmentManager); + this.context = context.getApplicationContext(); + this.gifFragment = gifFragment; + this.stickerFragment = stickerFragment; + } + + @Override + public Fragment getItem(int position) { + if (position == 0) return gifFragment; + else return stickerFragment; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public CharSequence getPageTitle(int position) { + if (position == 0) return context.getString(R.string.GiphyFragmentPagerAdapter_gifs); + else return context.getString(R.string.GiphyFragmentPagerAdapter_stickers); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java new file mode 100644 index 00000000..ad95c4af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.content.Context; +import android.graphics.Rect; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AnimatingToggle; + +public class GiphyActivityToolbar extends Toolbar { + + @Nullable private OnFilterChangedListener filterListener; + @Nullable private OnLayoutChangedListener layoutListener; + + private EditText searchText; + private AnimatingToggle toggle; + private ImageView action; + private ImageView clearToggle; + private LinearLayout toggleContainer; + private View listLayoutToggle; + private View gridLayoutToggle; + private Persistence persistence; + + public GiphyActivityToolbar(Context context) { + this(context, null); + } + + public GiphyActivityToolbar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + } + + public GiphyActivityToolbar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.giphy_activity_toolbar, this); + + this.action = findViewById(R.id.action_icon); + this.searchText = findViewById(R.id.search_view); + this.toggle = findViewById(R.id.button_toggle); + this.clearToggle = findViewById(R.id.search_clear); + this.toggleContainer = findViewById(R.id.toggle_container); + this.listLayoutToggle = findViewById(R.id.view_stream); + this.gridLayoutToggle = findViewById(R.id.view_grid); + + setupGridLayoutToggles(); + + this.clearToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchText.setText(""); + clearToggle.setVisibility(View.INVISIBLE); + } + }); + + this.searchText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (SearchUtil.isEmpty(searchText)) clearToggle.setVisibility(View.INVISIBLE); + else clearToggle.setVisibility(View.VISIBLE); + + notifyListener(); + } + }); + + this.searchText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + InputMethodManager inputMethodManager = (InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(searchText.getWindowToken(), 0); + } + + return false; + } + }); + + setLogo(null); + setNavigationIcon(null); + setContentInsetStartWithNavigation(0); + expandTapArea(this, action); + } + + public void setPersistence(@NonNull Persistence persistence) { + this.persistence = persistence; + displayTogglingView(persistence.getGridSelected() ? listLayoutToggle : gridLayoutToggle); + } + + private void setupGridLayoutToggles() { + setUpGridToggle(listLayoutToggle, gridLayoutToggle, false); + setUpGridToggle(gridLayoutToggle, listLayoutToggle, true); + displayTogglingView(gridLayoutToggle); + } + + private void setUpGridToggle(View gridToggle, View otherToggle, boolean gridLayout) { + gridToggle.setOnClickListener(v -> { + displayTogglingView(otherToggle); + if (layoutListener != null) { + layoutListener.onLayoutChanged(gridLayout); + } + if (persistence != null) { + persistence.setGridSelected(gridLayout); + } + }); + } + + @Override + public void setNavigationIcon(int resId) { + action.setImageResource(resId); + } + + public void clear() { + searchText.setText(""); + notifyListener(); + } + + public void setOnLayoutChangedListener(@Nullable OnLayoutChangedListener layoutListener) { + this.layoutListener = layoutListener; + } + + public void setOnFilterChangedListener(@Nullable OnFilterChangedListener filterListener) { + this.filterListener = filterListener; + } + + private void notifyListener() { + if (filterListener != null) filterListener.onFilterChanged(searchText.getText().toString()); + } + + private void displayTogglingView(View view) { + toggle.display(view); + expandTapArea(toggleContainer, view); + } + + private void expandTapArea(final View container, final View child) { + final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area); + + container.post(new Runnable() { + @Override + public void run() { + Rect rect = new Rect(); + child.getHitRect(rect); + + rect.top -= padding; + rect.left -= padding; + rect.right += padding; + rect.bottom += padding; + + container.setTouchDelegate(new TouchDelegate(rect, child)); + } + }); + } + + private static class SearchUtil { + public static boolean isTextInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT; + } + + public static boolean isPhoneInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE; + } + + public static boolean isEmpty(EditText editText) { + return editText.getText().length() <= 0; + } + } + + public interface OnFilterChangedListener { + void onFilterChanged(String filter); + } + + public interface OnLayoutChangedListener { + void onLayoutChanged(boolean gridLayout); + } + + public interface Persistence { + boolean getGridSelected(); + void setGridSelected(boolean isGridSelected); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbarTextSecurePreferencesPersistence.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbarTextSecurePreferencesPersistence.java new file mode 100644 index 00000000..bc3c87cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbarTextSecurePreferencesPersistence.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.giph.ui; + +import android.content.Context; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +class GiphyActivityToolbarTextSecurePreferencesPersistence implements GiphyActivityToolbar.Persistence { + + static GiphyActivityToolbar.Persistence fromContext(Context context) { + return new GiphyActivityToolbarTextSecurePreferencesPersistence(context.getApplicationContext()); + } + + private final Context context; + + private GiphyActivityToolbarTextSecurePreferencesPersistence(Context context) { + this.context = context; + } + + @Override + public boolean getGridSelected() { + return TextSecurePreferences.isGifSearchInGridLayout(context); + } + + @Override + public void setGridSelected(boolean isGridSelected) { + TextSecurePreferences.setIsGifSearchInGridLayout(context, isGridSelected); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java new file mode 100644 index 00000000..53139056 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java @@ -0,0 +1,193 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.util.ByteBufferUtil; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; +import java.util.concurrent.ExecutionException; + + +class GiphyAdapter extends RecyclerView.Adapter { + + private static final String TAG = GiphyAdapter.class.getSimpleName(); + + private final Context context; + private final GlideRequests glideRequests; + + private List images; + private OnItemClickListener listener; + + class GiphyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, RequestListener { + + public AspectRatioImageView thumbnail; + public GiphyImage image; + public ProgressBar gifProgress; + public volatile boolean modelReady; + + GiphyViewHolder(View view) { + super(view); + thumbnail = view.findViewById(R.id.thumbnail); + gifProgress = view.findViewById(R.id.gif_progress); + thumbnail.setOnClickListener(this); + gifProgress.setVisibility(View.GONE); + } + + @Override + public void onClick(View v) { + if (listener != null) listener.onClick(this); + } + + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + Log.w(TAG, e); + + synchronized (this) { + if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) { + this.modelReady = true; + notifyAll(); + } + } + + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + synchronized (this) { + if (new ChunkedImageUrl(image.getGifUrl(), image.getGifSize()).equals(model)) { + this.modelReady = true; + notifyAll(); + } + } + + return false; + } + + + public byte[] getData(boolean forMms) throws ExecutionException, InterruptedException { + synchronized (this) { + while (!modelReady) { + Util.wait(this, 0); + } + } + + GifDrawable drawable = glideRequests.asGif() + .load(forMms ? new ChunkedImageUrl(image.getGifMmsUrl(), image.getMmsGifSize()) : + new ChunkedImageUrl(image.getGifUrl(), image.getGifSize())) + .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get(); + + return ByteBufferUtil.toBytes(drawable.getBuffer()); + } + + public synchronized void setModelReady() { + this.modelReady = true; + notifyAll(); + } + } + + GiphyAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, @NonNull List images) { + this.context = context.getApplicationContext(); + this.glideRequests = glideRequests; + this.images = images; + } + + public void setImages(@NonNull List images) { + this.images = images; + notifyDataSetChanged(); + } + + public void addImages(List images) { + this.images.addAll(images); + notifyDataSetChanged(); + } + + @Override + public @NonNull GiphyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.giphy_thumbnail, parent, false); + + return new GiphyViewHolder(itemView); + } + + @Override + public void onBindViewHolder(@NonNull GiphyViewHolder holder, int position) { + GiphyImage image = images.get(position); + + holder.modelReady = false; + holder.image = image; + holder.thumbnail.setAspectRatio(image.getGifAspectRatio()); + holder.gifProgress.setVisibility(View.GONE); + + RequestBuilder thumbnailRequest = GlideApp.with(context) + .load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize())) + .diskCacheStrategy(DiskCacheStrategy.ALL); + + if (Util.isLowMemory(context)) { + glideRequests.load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize())) + .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .transition(DrawableTransitionOptions.withCrossFade()) + .listener(holder) + .into(holder.thumbnail); + + holder.setModelReady(); + } else { + glideRequests.load(new ChunkedImageUrl(image.getGifUrl(), image.getGifSize())) + .thumbnail(thumbnailRequest) + .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .transition(DrawableTransitionOptions.withCrossFade()) + .listener(holder) + .into(holder.thumbnail); + } + } + + @Override + public void onViewRecycled(@NonNull GiphyViewHolder holder) { + super.onViewRecycled(holder); + glideRequests.clear(holder.thumbnail); + } + + @Override + public int getItemCount() { + return images.size(); + } + + public void setListener(OnItemClickListener listener) { + this.listener = listener; + } + + public interface OnItemClickListener { + void onClick(GiphyViewHolder viewHolder); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java new file mode 100644 index 00000000..aa8b28e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.giph.ui; + +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyLoader; +import org.thoughtcrime.securesms.giph.util.InfiniteScrollListener; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.LinkedList; +import java.util.List; + +public abstract class GiphyFragment extends LoggingFragment implements LoaderManager.LoaderCallbacks>, GiphyAdapter.OnItemClickListener { + + private static final String TAG = GiphyFragment.class.getSimpleName(); + + private GiphyAdapter giphyAdapter; + private RecyclerView recyclerView; + private ProgressBar loadingProgress; + private TextView noResultsView; + private GiphyAdapter.OnItemClickListener listener; + + protected String searchString; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.giphy_fragment); + this.recyclerView = container.findViewById(R.id.giphy_list); + this.loadingProgress = container.findViewById(R.id.loading_progress); + this.noResultsView = container.findViewById(R.id.no_results); + + return container; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + this.giphyAdapter = new GiphyAdapter(getActivity(), GlideApp.with(this), new LinkedList<>()); + this.giphyAdapter.setListener(this); + + setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); + this.recyclerView.setItemAnimator(new DefaultItemAnimator()); + this.recyclerView.setAdapter(giphyAdapter); + this.recyclerView.addOnScrollListener(new GiphyScrollListener()); + + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onLoadFinished(@NonNull Loader> loader, @NonNull List data) { + this.loadingProgress.setVisibility(View.GONE); + + if (data.isEmpty()) noResultsView.setVisibility(View.VISIBLE); + else noResultsView.setVisibility(View.GONE); + + this.giphyAdapter.setImages(data); + } + + @Override + public void onLoaderReset(@NonNull Loader> loader) { + noResultsView.setVisibility(View.GONE); + this.giphyAdapter.setImages(new LinkedList()); + } + + public void setLayoutManager(boolean gridLayout) { + recyclerView.setLayoutManager(getLayoutManager(gridLayout)); + } + + private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { + return gridLayout ? new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) + : new LinearLayoutManager(getActivity()); + } + + public void setClickListener(GiphyAdapter.OnItemClickListener listener) { + this.listener = listener; + } + + public void setSearchString(@Nullable String searchString) { + this.searchString = searchString; + this.noResultsView.setVisibility(View.GONE); + this.getLoaderManager().restartLoader(0, null, this); + } + + @Override + public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { + if (listener != null) listener.onClick(viewHolder); + } + + private class GiphyScrollListener extends InfiniteScrollListener { + @Override + public void onLoadMore(final int currentPage) { + final Loader> loader = getLoaderManager().getLoader(0); + if (loader == null) return; + + new AsyncTask>() { + @Override + protected List doInBackground(Void... params) { + return ((GiphyLoader)loader).loadPage(currentPage * GiphyLoader.PAGE_SIZE); + } + + protected void onPostExecute(List images) { + giphyAdapter.addImages(images); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java new file mode 100644 index 00000000..6f4edeee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.loader.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyGifLoader; + +import java.util.List; + +public class GiphyGifFragment extends GiphyFragment { + + @Override + public @NonNull Loader> onCreateLoader(int id, Bundle args) { + return new GiphyGifLoader(getActivity(), searchString); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java new file mode 100644 index 00000000..d6adbbb5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.loader.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyStickerLoader; + +import java.util.List; + +public class GiphyStickerFragment extends GiphyFragment { + @Override + public @NonNull Loader> onCreateLoader(int id, Bundle args) { + return new GiphyStickerLoader(getActivity(), searchString); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java b/app/src/main/java/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java new file mode 100644 index 00000000..ef0dfbed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java @@ -0,0 +1,49 @@ +// From https://gist.github.com/mipreamble/b6d4b3d65b0b4775a22e#file-recyclerviewpositionhelper-java + +package org.thoughtcrime.securesms.giph.util; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class InfiniteScrollListener extends RecyclerView.OnScrollListener { + + public static String TAG = InfiniteScrollListener.class.getSimpleName(); + + private int previousTotal = 0; // The total number of items in the dataset after the last load + private boolean loading = true; // True if we are still waiting for the last set of data to load. + private int visibleThreshold = 5; // The minimum amount of items to have below your current scroll position before loading more. + + int firstVisibleItem, visibleItemCount, totalItemCount; + + private int currentPage = 1; + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + + RecyclerViewPositionHelper recyclerViewPositionHelper = RecyclerViewPositionHelper.createHelper(recyclerView); + + visibleItemCount = recyclerView.getChildCount(); + totalItemCount = recyclerViewPositionHelper.getItemCount(); + firstVisibleItem = recyclerViewPositionHelper.findFirstVisibleItemPosition(); + + if (loading) { + if (totalItemCount > previousTotal) { + loading = false; + previousTotal = totalItemCount; + } + } + if (!loading && (totalItemCount - visibleItemCount) + <= (firstVisibleItem + visibleThreshold)) { + // End has been reached + // Do something + currentPage++; + + onLoadMore(currentPage); + + loading = true; + } + } + + public abstract void onLoadMore(int currentPage); +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java b/app/src/main/java/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java new file mode 100644 index 00000000..4de39d22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java @@ -0,0 +1,116 @@ +// From https://gist.github.com/mipreamble/b6d4b3d65b0b4775a22e#file-recyclerviewpositionhelper-java + +package org.thoughtcrime.securesms.giph.util; + + +import android.view.View; + +import androidx.recyclerview.widget.OrientationHelper; +import androidx.recyclerview.widget.RecyclerView; + +public class RecyclerViewPositionHelper { + + final RecyclerView recyclerView; + final RecyclerView.LayoutManager layoutManager; + + RecyclerViewPositionHelper(RecyclerView recyclerView) { + this.recyclerView = recyclerView; + this.layoutManager = recyclerView.getLayoutManager(); + } + + public static RecyclerViewPositionHelper createHelper(RecyclerView recyclerView) { + if (recyclerView == null) { + throw new NullPointerException("Recycler View is null"); + } + return new RecyclerViewPositionHelper(recyclerView); + } + + /** + * Returns the adapter item count. + * + * @return The total number on items in a layout manager + */ + public int getItemCount() { + return layoutManager == null ? 0 : layoutManager.getItemCount(); + } + + /** + * Returns the adapter position of the first visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the first visible item or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items. + */ + public int findFirstVisibleItemPosition() { + final View child = findOneVisibleChild(0, layoutManager.getChildCount(), false, true); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the first fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the first fully visible item or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + */ + public int findFirstCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(0, layoutManager.getChildCount(), true, false); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the last visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the last visible view or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items + */ + public int findLastVisibleItemPosition() { + final View child = findOneVisibleChild(layoutManager.getChildCount() - 1, -1, false, true); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the last fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the last fully visible view or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + */ + public int findLastCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(layoutManager.getChildCount() - 1, -1, true, false); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + View findOneVisibleChild(int fromIndex, int toIndex, boolean completelyVisible, + boolean acceptPartiallyVisible) { + OrientationHelper helper; + if (layoutManager.canScrollVertically()) { + helper = OrientationHelper.createVerticalHelper(layoutManager); + } else { + helper = OrientationHelper.createHorizontalHelper(layoutManager); + } + + final int start = helper.getStartAfterPadding(); + final int end = helper.getEndAfterPadding(); + final int next = toIndex > fromIndex ? 1 : -1; + View partiallyVisible = null; + for (int i = fromIndex; i != toIndex; i += next) { + final View child = layoutManager.getChildAt(i); + final int childStart = helper.getDecoratedStart(child); + final int childEnd = helper.getDecoratedEnd(child); + if (childStart < end && childEnd > start) { + if (completelyVisible) { + if (childStart >= start && childEnd <= end) { + return child; + } else if (acceptPartiallyVisible && partiallyVisible == null) { + partiallyVisible = child; + } + } else { + return child; + } + } + } + return partiallyVisible; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java new file mode 100644 index 00000000..11ab0c4f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlFetcher.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.glide; + + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.net.ChunkedDataFetcher; +import org.thoughtcrime.securesms.net.RequestController; + +import java.io.InputStream; + +import okhttp3.OkHttpClient; + +class ChunkedImageUrlFetcher implements DataFetcher { + + private static final String TAG = ChunkedImageUrlFetcher.class.getSimpleName(); + + private final OkHttpClient client; + private final ChunkedImageUrl url; + + private RequestController requestController; + + ChunkedImageUrlFetcher(@NonNull OkHttpClient client, @NonNull ChunkedImageUrl url) { + this.client = client; + this.url = url; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + ChunkedDataFetcher fetcher = new ChunkedDataFetcher(client); + requestController = fetcher.fetch(url.getUrl(), url.getSize(), new ChunkedDataFetcher.Callback() { + @Override + public void onSuccess(InputStream stream) { + callback.onDataReady(stream); + } + + @Override + public void onFailure(Exception e) { + callback.onLoadFailed(e); + } + }); + } + + @Override + public void cleanup() { + if (requestController != null) { + requestController.cancel(); + } + } + + @Override + public void cancel() { + Log.d(TAG, "Canceled."); + if (requestController != null) { + requestController.cancel(); + } + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java new file mode 100644 index 00000000..7b9455e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ChunkedImageUrlLoader.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.net.ContentProxySafetyInterceptor; +import org.thoughtcrime.securesms.net.ContentProxySelector; +import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; + +import java.io.InputStream; + +import okhttp3.OkHttpClient; + +public class ChunkedImageUrlLoader implements ModelLoader { + + private final OkHttpClient client; + + private ChunkedImageUrlLoader(OkHttpClient client) { + this.client = client; + } + + @Override + public @Nullable LoadData buildLoadData(@NonNull ChunkedImageUrl url, int width, int height, @NonNull Options options) { + return new LoadData<>(url, new ChunkedImageUrlFetcher(client, url)); + } + + @Override + public boolean handles(@NonNull ChunkedImageUrl url) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + + private final OkHttpClient client; + + public Factory() { + this.client = new OkHttpClient.Builder() + .proxySelector(new ContentProxySelector()) + .cache(null) + .addInterceptor(new StandardUserAgentInterceptor()) + .addNetworkInterceptor(new ContentProxySafetyInterceptor()) + .addNetworkInterceptor(new PaddedHeadersInterceptor()) + .dns(SignalServiceNetworkAccess.DNS) + .build(); + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new ChunkedImageUrlLoader(client); + } + + @Override + public void teardown() {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java new file mode 100644 index 00000000..4eb81527 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.glide; + + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +class ContactPhotoFetcher implements DataFetcher { + + private final Context context; + private final ContactPhoto contactPhoto; + + private InputStream inputStream; + + ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { + this.context = context.getApplicationContext(); + this.contactPhoto = contactPhoto; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + inputStream = contactPhoto.openInputStream(context); + callback.onDataReady(inputStream); + } catch (FileNotFoundException e) { + callback.onDataReady(null); + } catch (IOException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + try { + if (inputStream != null) inputStream.close(); + } catch (IOException e) {} + } + + @Override + public void cancel() { + + } + + @Override + public @NonNull Class getDataClass() { + return InputStream.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.LOCAL; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java new file mode 100644 index 00000000..105e20bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoLoader.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.glide; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; + +import java.io.InputStream; + +public class ContactPhotoLoader implements ModelLoader { + + private final Context context; + + private ContactPhotoLoader(Context context) { + this.context = context; + } + + @Override + public @Nullable LoadData buildLoadData(@NonNull ContactPhoto contactPhoto, int width, int height, @NonNull Options options) { + return new LoadData<>(contactPhoto, new ContactPhotoFetcher(context, contactPhoto)); + } + + @Override + public boolean handles(@NonNull ContactPhoto contactPhoto) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + + private final Context context; + + public Factory(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new ContactPhotoLoader(context); + } + + @Override + public void teardown() {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java new file mode 100644 index 00000000..9b0bbf20 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.util.ContentLengthInputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Fetches an {@link InputStream} using the okhttp library. + */ +class OkHttpStreamFetcher implements DataFetcher { + + private static final String TAG = OkHttpStreamFetcher.class.getSimpleName(); + + private final OkHttpClient client; + private final GlideUrl url; + private InputStream stream; + private ResponseBody responseBody; + + OkHttpStreamFetcher(OkHttpClient client, GlideUrl url) { + this.client = client; + this.url = url; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + Request.Builder requestBuilder = new Request.Builder() + .url(url.toStringUrl()); + + for (Map.Entry headerEntry : url.getHeaders().entrySet()) { + String key = headerEntry.getKey(); + requestBuilder.addHeader(key, headerEntry.getValue()); + } + + Request request = requestBuilder.build(); + Response response = client.newCall(request).execute(); + + responseBody = response.body(); + + if (!response.isSuccessful()) { + throw new IOException("Request failed with code: " + response.code()); + } + + long contentLength = responseBody.contentLength(); + stream = ContentLengthInputStream.obtain(responseBody.byteStream(), contentLength); + + callback.onDataReady(stream); + } catch (IOException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + // Ignored + } + } + if (responseBody != null) { + responseBody.close(); + } + } + + @Override + public void cancel() { + // TODO: call cancel on the client when this method is called on a background thread. See #257 + } + + @Override + public @NonNull Class getDataClass() { + return InputStream.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.REMOTE; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java new file mode 100644 index 00000000..3aabb2c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.net.ContentProxySelector; +import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; + +import java.io.InputStream; + +import okhttp3.OkHttpClient; + +/** + * A simple model loader for fetching media over http/https using OkHttp. + */ +public class OkHttpUrlLoader implements ModelLoader { + + private final OkHttpClient client; + + private OkHttpUrlLoader(OkHttpClient client) { + this.client = client; + } + + @Override + public @Nullable LoadData buildLoadData(@NonNull GlideUrl glideUrl, int width, int height, @NonNull Options options) { + return new LoadData<>(glideUrl, new OkHttpStreamFetcher(client, glideUrl)); + } + + @Override + public boolean handles(@NonNull GlideUrl glideUrl) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + private static volatile OkHttpClient internalClient; + private OkHttpClient client; + + private static OkHttpClient getInternalClient() { + if (internalClient == null) { + synchronized (Factory.class) { + if (internalClient == null) { + internalClient = new OkHttpClient.Builder() + .proxySelector(new ContentProxySelector()) + .addInterceptor(new StandardUserAgentInterceptor()) + .dns(SignalServiceNetworkAccess.DNS) + .build(); + } + } + } + return internalClient; + } + + public Factory() { + this(getInternalClient()); + } + + private Factory(OkHttpClient client) { + this.client = client; + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new OkHttpUrlLoader(client); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java new file mode 100644 index 00000000..5d0ab584 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.security.SecureRandom; + +import okhttp3.Headers; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +/** + * An interceptor that adds a header with a random amount of bytes to disguise header length. + */ +public class PaddedHeadersInterceptor implements Interceptor { + + private static final String PADDING_HEADER = "X-SignalPadding"; + private static final int MIN_RANDOM_BYTES = 1; + private static final int MAX_RANDOM_BYTES = 64; + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + Request padded = chain.request().newBuilder() + .headers(getPaddedHeaders(chain.request().headers())) + .build(); + + return chain.proceed(padded); + } + + private @NonNull Headers getPaddedHeaders(@NonNull Headers headers) { + return headers.newBuilder() + .add(PADDING_HEADER, getRandomString(new SecureRandom(), MIN_RANDOM_BYTES, MAX_RANDOM_BYTES)) + .build(); + } + + private static @NonNull String getRandomString(@NonNull SecureRandom secureRandom, int minLength, int maxLength) { + char[] buffer = new char[secureRandom.nextInt(maxLength - minLength) + minLength]; + + for (int i = 0 ; i < buffer.length; i++) { + buffer[i] = (char) (secureRandom.nextInt(74) + 48); // Random char from 0-Z + } + + return new String(buffer); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngBufferCacheDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngBufferCacheDecoder.java new file mode 100644 index 00000000..7b72f838 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngBufferCacheDecoder.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.glide.cache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; + +import org.signal.glide.apng.decode.APNGDecoder; +import org.signal.glide.apng.decode.APNGParser; +import org.signal.glide.common.io.ByteBufferReader; +import org.signal.glide.common.loader.ByteBufferLoader; +import org.signal.glide.common.loader.Loader; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public class ApngBufferCacheDecoder implements ResourceDecoder { + + @Override + public boolean handles(@NonNull ByteBuffer source, @NonNull Options options) { + if (options.get(ApngOptions.ANIMATE)) { + return APNGParser.isAPNG(new ByteBufferReader(source)); + } else { + return false; + } + } + + @Override + public @Nullable Resource decode(@NonNull final ByteBuffer source, int width, int height, @NonNull Options options) throws IOException { + if (!APNGParser.isAPNG(new ByteBufferReader(source))) { + return null; + } + + Loader loader = new ByteBufferLoader() { + @Override + public ByteBuffer getByteBuffer() { + source.position(0); + return source; + } + }; + + return new FrameSeqDecoderResource(new APNGDecoder(loader, null), source.limit()); + } + + private static class FrameSeqDecoderResource implements Resource { + private final APNGDecoder decoder; + private final int size; + + FrameSeqDecoderResource(@NonNull APNGDecoder decoder, int size) { + this.decoder = decoder; + this.size = size; + } + + @Override + public @NonNull Class getResourceClass() { + return APNGDecoder.class; + } + + @Override + public @NonNull APNGDecoder get() { + return this.decoder; + } + + @Override + public int getSize() { + return this.size; + } + + @Override + public void recycle() { + this.decoder.stop(); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngFrameDrawableTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngFrameDrawableTranscoder.java new file mode 100644 index 00000000..f12af6a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngFrameDrawableTranscoder.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.glide.cache; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.drawable.DrawableResource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; + +import org.signal.glide.apng.APNGDrawable; +import org.signal.glide.apng.decode.APNGDecoder; + +public class ApngFrameDrawableTranscoder implements ResourceTranscoder { + + @Override + public @Nullable Resource transcode(@NonNull Resource toTranscode, @NonNull Options options) { + APNGDecoder decoder = toTranscode.get(); + APNGDrawable drawable = new APNGDrawable(decoder); + + drawable.setAutoPlay(false); + drawable.setLoopLimit(0); + + return new DrawableResource(drawable) { + @Override + public @NonNull Class getResourceClass() { + return Drawable.class; + } + + @Override + public int getSize() { + return 0; + } + + @Override + public void recycle() { + } + + @Override + public void initialize() { + super.initialize(); + } + }; + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngOptions.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngOptions.java new file mode 100644 index 00000000..99622193 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngOptions.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.glide.cache; + +import com.bumptech.glide.load.Option; + +import org.signal.core.util.Conversions; + +/** + * Holds options that can be used to alter how APNGs are decoded in Glide. + */ +public final class ApngOptions { + + private static final String KEY = "org.signal.skip_apng"; + + public static Option ANIMATE = Option.disk(KEY, true, (keyBytes, value, messageDigest) -> { + messageDigest.update(keyBytes); + messageDigest.update(Conversions.intToByteArray(value ? 1 : 0)); + }); + + private ApngOptions() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngStreamCacheDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngStreamCacheDecoder.java new file mode 100644 index 00000000..fdce779f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngStreamCacheDecoder.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.glide.cache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; + +import org.signal.core.util.StreamUtil; +import org.signal.glide.apng.decode.APNGDecoder; +import org.signal.glide.apng.decode.APNGParser; +import org.signal.glide.common.io.StreamReader; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +public class ApngStreamCacheDecoder implements ResourceDecoder { + + private final ResourceDecoder byteBufferDecoder; + + public ApngStreamCacheDecoder(ResourceDecoder byteBufferDecoder) { + this.byteBufferDecoder = byteBufferDecoder; + } + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) { + if (options.get(ApngOptions.ANIMATE)) { + return APNGParser.isAPNG(new StreamReader(source)); + } else { + return false; + } + } + + @Override + public @Nullable Resource decode(@NonNull final InputStream source, int width, int height, @NonNull Options options) throws IOException { + byte[] data = StreamUtil.readFully(source); + + if (data == null) { + return null; + } + + ByteBuffer byteBuffer = ByteBuffer.wrap(data); + return byteBufferDecoder.decode(byteBuffer, width, height, options); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngCacheEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngCacheEncoder.java new file mode 100644 index 00000000..2962fde0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngCacheEncoder.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.glide.cache; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.EncodeStrategy; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceEncoder; +import com.bumptech.glide.load.engine.Resource; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.signal.glide.apng.decode.APNGDecoder; +import org.signal.glide.common.loader.Loader; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class EncryptedApngCacheEncoder extends EncryptedCoder implements ResourceEncoder { + + private static final String TAG = Log.tag(EncryptedApngCacheEncoder.class); + + private final byte[] secret; + + public EncryptedApngCacheEncoder(@NonNull byte[] secret) { + this.secret = secret; + } + + @Override + public @NonNull EncodeStrategy getEncodeStrategy(@NonNull Options options) { + return EncodeStrategy.SOURCE; + } + + @Override + public boolean encode(@NonNull Resource data, @NonNull File file, @NonNull Options options) { + try { + Loader loader = data.get().getLoader(); + InputStream input = loader.obtain().toInputStream(); + OutputStream output = createEncryptedOutputStream(secret, file); + + StreamUtil.copy(input, output); + return true; + } catch (IOException e) { + Log.w(TAG, e); + } + + return false; + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedBitmapResourceEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedBitmapResourceEncoder.java new file mode 100644 index 00000000..4edd33e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedBitmapResourceEncoder.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.glide.cache; + + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.EncodeStrategy; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceEncoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.bitmap.BitmapEncoder; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +public class EncryptedBitmapResourceEncoder extends EncryptedCoder implements ResourceEncoder { + + private static final String TAG = EncryptedBitmapResourceEncoder.class.getSimpleName(); + + private final byte[] secret; + + public EncryptedBitmapResourceEncoder(@NonNull byte[] secret) { + this.secret = secret; + } + + @Override + public EncodeStrategy getEncodeStrategy(@NonNull Options options) { + return EncodeStrategy.TRANSFORMED; + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public boolean encode(@NonNull Resource data, @NonNull File file, @NonNull Options options) { + Bitmap bitmap = data.get(); + Bitmap.CompressFormat format = getFormat(bitmap, options); + int quality = options.get(BitmapEncoder.COMPRESSION_QUALITY); + + try (OutputStream os = createEncryptedOutputStream(secret, file)) { + bitmap.compress(format, quality, os); + os.close(); + return true; + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } + + private Bitmap.CompressFormat getFormat(Bitmap bitmap, Options options) { + Bitmap.CompressFormat format = options.get(BitmapEncoder.COMPRESSION_FORMAT); + + if (format != null) { + return format; + } else if (bitmap.hasAlpha()) { + return Bitmap.CompressFormat.PNG; + } else { + return Bitmap.CompressFormat.JPEG; + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheDecoder.java new file mode 100644 index 00000000..a56e8152 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheDecoder.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.glide.cache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +public class EncryptedCacheDecoder extends EncryptedCoder implements ResourceDecoder { + + private static final String TAG = Log.tag(EncryptedCacheDecoder.class); + + private final byte[] secret; + private final ResourceDecoder decoder; + + public EncryptedCacheDecoder(byte[] secret, ResourceDecoder decoder) { + this.secret = secret; + this.decoder = decoder; + } + + @Override + public boolean handles(@NonNull File source, @NonNull Options options) throws IOException { + try (InputStream inputStream = createEncryptedInputStream(secret, source)) { + return decoder.handles(inputStream, options); + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } + + @Override + public @Nullable Resource decode(@NonNull File source, int width, int height, @NonNull Options options) throws IOException { + try (InputStream inputStream = createEncryptedInputStream(secret, source)) { + return decoder.decode(inputStream, width, height, options); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java new file mode 100644 index 00000000..3cae9230 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.glide.cache; + + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.Encoder; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketException; + +public class EncryptedCacheEncoder extends EncryptedCoder implements Encoder { + + private static final String TAG = EncryptedCacheEncoder.class.getSimpleName(); + + private final byte[] secret; + private final ArrayPool byteArrayPool; + + public EncryptedCacheEncoder(@NonNull byte[] secret, @NonNull ArrayPool byteArrayPool) { + this.secret = secret; + this.byteArrayPool = byteArrayPool; + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public boolean encode(@NonNull InputStream data, @NonNull File file, @NonNull Options options) { + byte[] buffer = byteArrayPool.get(ArrayPool.STANDARD_BUFFER_SIZE_BYTES, byte[].class); + + try (OutputStream outputStream = createEncryptedOutputStream(secret, file)) { + int read; + + while ((read = data.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + + return true; + } catch (IOException e) { + if (e instanceof SocketException) { + Log.d(TAG, "Socket exception. Likely a cancellation."); + } else { + Log.w(TAG, e); + } + return false; + } finally { + byteArrayPool.put(buffer); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCoder.java new file mode 100644 index 00000000..7e8f7514 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCoder.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.glide.cache; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.StreamUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +class EncryptedCoder { + + private static byte[] MAGIC_BYTES = {(byte)0x91, (byte)0x5e, (byte)0x6d, (byte)0xb4, + (byte)0x09, (byte)0xa6, (byte)0x68, (byte)0xbe, + (byte)0xe5, (byte)0xb1, (byte)0x1b, (byte)0xd7, + (byte)0x29, (byte)0xe5, (byte)0x04, (byte)0xcc}; + + OutputStream createEncryptedOutputStream(@NonNull byte[] masterKey, @NonNull File file) + throws IOException + { + try { + byte[] random = Util.getSecretBytes(32); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(masterKey, "HmacSHA256")); + + FileOutputStream fileOutputStream = new FileOutputStream(file); + byte[] iv = new byte[16]; + byte[] key = mac.doFinal(random); + + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); + + fileOutputStream.write(MAGIC_BYTES); + fileOutputStream.write(random); + + CipherOutputStream outputStream = new CipherOutputStream(fileOutputStream, cipher); + outputStream.write(MAGIC_BYTES); + + return outputStream; + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + CipherInputStream createEncryptedInputStream(@NonNull byte[] masterKey, @NonNull File file) throws IOException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(masterKey, "HmacSHA256")); + + FileInputStream fileInputStream = new FileInputStream(file); + byte[] theirMagic = new byte[MAGIC_BYTES.length]; + byte[] theirRandom = new byte[32]; + byte[] theirEncryptedMagic = new byte[MAGIC_BYTES.length]; + + StreamUtil.readFully(fileInputStream, theirMagic); + StreamUtil.readFully(fileInputStream, theirRandom); + + if (!MessageDigest.isEqual(theirMagic, MAGIC_BYTES)) { + throw new IOException("Not an encrypted cache file!"); + } + + byte[] iv = new byte[16]; + byte[] key = mac.doFinal(theirRandom); + + Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); + + CipherInputStream inputStream = new CipherInputStream(fileInputStream, cipher); + StreamUtil.readFully(inputStream, theirEncryptedMagic); + + if (!MessageDigest.isEqual(theirEncryptedMagic, MAGIC_BYTES)) { + throw new IOException("Key change on encrypted cache file!"); + } + + return inputStream; + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { + throw new AssertionError(e); + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedGifDrawableResourceEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedGifDrawableResourceEncoder.java new file mode 100644 index 00000000..be1c09b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedGifDrawableResourceEncoder.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.glide.cache; + + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.EncodeStrategy; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceEncoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.util.ByteBufferUtil; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +public class EncryptedGifDrawableResourceEncoder extends EncryptedCoder implements ResourceEncoder { + + private static final String TAG = EncryptedGifDrawableResourceEncoder.class.getSimpleName(); + + private final byte[] secret; + + public EncryptedGifDrawableResourceEncoder(@NonNull byte[] secret) { + this.secret = secret; + } + + @Override + public EncodeStrategy getEncodeStrategy(@NonNull Options options) { + return EncodeStrategy.TRANSFORMED; + } + + @Override + public boolean encode(@NonNull Resource data, @NonNull File file, @NonNull Options options) { + GifDrawable drawable = data.get(); + + try (OutputStream outputStream = createEncryptedOutputStream(secret, file)) { + ByteBufferUtil.toStream(drawable.getBuffer(), outputStream); + return true; + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java new file mode 100644 index 00000000..1f419a16 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public final class BadGroupIdException extends Exception { + + BadGroupIdException() { + super(); + } + + BadGroupIdException(@NonNull String message) { + super(message); + } + + BadGroupIdException(@NonNull Exception e) { + super(e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java new file mode 100644 index 00000000..38f9277d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.storageservice.protos.groups.AccessControl; +import org.thoughtcrime.securesms.R; + +public final class GV2AccessLevelUtil { + + private GV2AccessLevelUtil() { + } + + public static String toString(@NonNull Context context, @NonNull AccessControl.AccessRequired attributeAccess) { + switch (attributeAccess) { + case ANY : return context.getString(R.string.GroupManagement_access_level_anyone); + case MEMBER : return context.getString(R.string.GroupManagement_access_level_all_members); + case ADMINISTRATOR : return context.getString(R.string.GroupManagement_access_level_only_admins); + default : return context.getString(R.string.GroupManagement_access_level_unknown); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java new file mode 100644 index 00000000..7a0d7252 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +public enum GroupAccessControl { + ALL_MEMBERS(R.string.GroupManagement_access_level_all_members), + ONLY_ADMINS(R.string.GroupManagement_access_level_only_admins), + NO_ONE(R.string.GroupManagement_access_level_no_one); + + private final @StringRes int string; + + GroupAccessControl(@StringRes int string) { + this.string = string; + } + + public @StringRes int getString() { + return string; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAlreadyExistsException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAlreadyExistsException.java new file mode 100644 index 00000000..8e4a50cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAlreadyExistsException.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupAlreadyExistsException extends GroupChangeException { + + public GroupAlreadyExistsException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java new file mode 100644 index 00000000..f93204be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public final class GroupChangeBusyException extends GroupChangeException { + + public GroupChangeBusyException(@NonNull Throwable throwable) { + super(throwable); + } + + public GroupChangeBusyException(@NonNull String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeException.java new file mode 100644 index 00000000..40041580 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeException.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public abstract class GroupChangeException extends Exception { + + GroupChangeException() { + } + + GroupChangeException(@NonNull Throwable throwable) { + super(throwable); + } + + GroupChangeException(@NonNull String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java new file mode 100644 index 00000000..6d4566a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public final class GroupChangeFailedException extends GroupChangeException { + + GroupChangeFailedException() { + } + + GroupChangeFailedException(@NonNull Throwable throwable) { + super(throwable); + } + + GroupChangeFailedException(@NonNull String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupDoesNotExistException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupDoesNotExistException.java new file mode 100644 index 00000000..cc95199b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupDoesNotExistException.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupDoesNotExistException extends GroupChangeException { + + public GroupDoesNotExistException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java new file mode 100644 index 00000000..db2618b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java @@ -0,0 +1,292 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupIdentifier; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.kdf.HKDFv3; + +import java.io.IOException; +import java.security.SecureRandom; + +public abstract class GroupId { + + private static final String ENCODED_SIGNAL_GROUP_V1_PREFIX = "__textsecure_group__!"; + private static final String ENCODED_SIGNAL_GROUP_V2_PREFIX = "__signal_group__v2__!"; + private static final String ENCODED_MMS_GROUP_PREFIX = "__signal_mms_group__!"; + private static final int MMS_BYTE_LENGTH = 16; + private static final int V1_MMS_BYTE_LENGTH = 16; + private static final int V1_BYTE_LENGTH = 16; + private static final int V2_BYTE_LENGTH = GroupIdentifier.SIZE; + + private final String encodedId; + + private GroupId(@NonNull String prefix, @NonNull byte[] bytes) { + this.encodedId = prefix + Hex.toStringCondensed(bytes); + } + + public static @NonNull GroupId.Mms mms(byte[] mmsGroupIdBytes) { + return new GroupId.Mms(mmsGroupIdBytes); + } + + public static @NonNull GroupId.V1 v1orThrow(byte[] gv1GroupIdBytes) { + try { + return v1(gv1GroupIdBytes); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + public static @NonNull GroupId.V1 v1(byte[] gv1GroupIdBytes) throws BadGroupIdException { + if (gv1GroupIdBytes.length != V1_BYTE_LENGTH) { + throw new BadGroupIdException(); + } + return new GroupId.V1(gv1GroupIdBytes); + } + + public static GroupId.V1 createV1(@NonNull SecureRandom secureRandom) { + return v1orThrow(Util.getSecretBytes(secureRandom, V1_MMS_BYTE_LENGTH)); + } + + public static GroupId.Mms createMms(@NonNull SecureRandom secureRandom) { + return mms(Util.getSecretBytes(secureRandom, MMS_BYTE_LENGTH)); + } + + /** + * Private because it's too easy to pass the {@link GroupMasterKey} bytes directly to this as they + * are the same length as the {@link GroupIdentifier}. + */ + private static GroupId.V2 v2(@NonNull byte[] bytes) throws BadGroupIdException { + if (bytes.length != V2_BYTE_LENGTH) { + throw new BadGroupIdException(); + } + return new GroupId.V2(bytes); + } + + public static GroupId.V2 v2(@NonNull GroupIdentifier groupIdentifier) { + try { + return v2(groupIdentifier.serialize()); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + public static GroupId.V2 v2(@NonNull GroupMasterKey masterKey) { + return v2(GroupSecretParams.deriveFromMasterKey(masterKey) + .getPublicParams() + .getGroupIdentifier()); + } + + public static GroupId.Push push(byte[] bytes) throws BadGroupIdException { + return bytes.length == V2_BYTE_LENGTH ? v2(bytes) : v1(bytes); + } + + public static GroupId.Push pushOrThrow(byte[] bytes) { + try { + return push(bytes); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + public static @NonNull GroupId parseOrThrow(@NonNull String encodedGroupId) { + try { + return parse(encodedGroupId); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + public static @NonNull GroupId parse(@NonNull String encodedGroupId) throws BadGroupIdException { + try { + if (!isEncodedGroup(encodedGroupId)) { + throw new BadGroupIdException("Invalid encoding"); + } + + byte[] bytes = extractDecodedId(encodedGroupId); + + if (encodedGroupId.startsWith(ENCODED_SIGNAL_GROUP_V2_PREFIX)) return v2(bytes); + else if (encodedGroupId.startsWith(ENCODED_SIGNAL_GROUP_V1_PREFIX)) return v1(bytes); + else if (encodedGroupId.startsWith(ENCODED_MMS_GROUP_PREFIX)) return mms(bytes); + + throw new BadGroupIdException(); + } catch (IOException e) { + throw new BadGroupIdException(e); + } + } + + public static @Nullable GroupId parseNullable(@Nullable String encodedGroupId) throws BadGroupIdException { + if (encodedGroupId == null) { + return null; + } + + return parse(encodedGroupId); + } + + public static @Nullable GroupId parseNullableOrThrow(@Nullable String encodedGroupId) { + if (encodedGroupId == null) { + return null; + } + + return parseOrThrow(encodedGroupId); + } + + public static boolean isEncodedGroup(@NonNull String groupId) { + return groupId.startsWith(ENCODED_SIGNAL_GROUP_V2_PREFIX) || + groupId.startsWith(ENCODED_SIGNAL_GROUP_V1_PREFIX) || + groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); + } + + private static byte[] extractDecodedId(@NonNull String encodedGroupId) throws IOException { + return Hex.fromStringCondensed(encodedGroupId.split("!", 2)[1]); + } + + public byte[] getDecodedId() { + try { + return extractDecodedId(encodedId); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof GroupId) { + return ((GroupId) obj).encodedId.equals(encodedId); + } + + return false; + } + + @Override + public int hashCode() { + return encodedId.hashCode(); + } + + @Override + public @NonNull String toString() { + return encodedId; + } + + public abstract boolean isMms(); + + public abstract boolean isV1(); + + public abstract boolean isV2(); + + public abstract boolean isPush(); + + public GroupId.Mms requireMms() { + if (this instanceof GroupId.Mms) return (GroupId.Mms) this; + throw new AssertionError(); + } + + public GroupId.V1 requireV1() { + if (this instanceof GroupId.V1) return (GroupId.V1) this; + throw new AssertionError(); + } + + public GroupId.V2 requireV2() { + if (this instanceof GroupId.V2) return (GroupId.V2) this; + throw new AssertionError(); + } + + public GroupId.Push requirePush() { + if (this instanceof GroupId.Push) return (GroupId.Push) this; + throw new AssertionError(); + } + + public static final class Mms extends GroupId { + + private Mms(@NonNull byte[] bytes) { + super(ENCODED_MMS_GROUP_PREFIX, bytes); + } + + @Override + public boolean isMms() { + return true; + } + + @Override + public boolean isV1() { + return false; + } + + @Override + public boolean isV2() { + return false; + } + + @Override + public boolean isPush() { + return false; + } + } + + public static abstract class Push extends GroupId { + private Push(@NonNull String prefix, @NonNull byte[] bytes) { + super(prefix, bytes); + } + + @Override + public boolean isMms() { + return false; + } + + @Override + public boolean isPush() { + return true; + } + } + + public static final class V1 extends GroupId.Push { + + private V1(@NonNull byte[] bytes) { + super(ENCODED_SIGNAL_GROUP_V1_PREFIX, bytes); + } + + @Override + public boolean isV1() { + return true; + } + + @Override + public boolean isV2() { + return false; + } + + public GroupMasterKey deriveV2MigrationMasterKey() { + try { + return new GroupMasterKey(new HKDFv3().deriveSecrets(getDecodedId(), "GV2 Migration".getBytes(), GroupMasterKey.SIZE)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } + + public GroupId.V2 deriveV2MigrationGroupId() { + return v2(deriveV2MigrationMasterKey()); + } + } + + public static final class V2 extends GroupId.Push { + + private V2(@NonNull byte[] bytes) { + super(ENCODED_SIGNAL_GROUP_V2_PREFIX, bytes); + } + + @Override + public boolean isV1() { + return false; + } + + @Override + public boolean isV2() { + return true; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java new file mode 100644 index 00000000..24554b4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupInsufficientRightsException extends GroupChangeException { + + GroupInsufficientRightsException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java new file mode 100644 index 00000000..da93ced7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public final class GroupJoinAlreadyAMemberException extends GroupChangeException { + + GroupJoinAlreadyAMemberException(@NonNull Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java new file mode 100644 index 00000000..7282fffd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -0,0 +1,460 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.GroupExternalCredential; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.UuidCiphertext; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class GroupManager { + + private static final String TAG = Log.tag(GroupManager.class); + + @WorkerThread + public static @NonNull GroupActionResult createGroup(@NonNull Context context, + @NonNull Set members, + @Nullable byte[] avatar, + @Nullable String name, + boolean mms) + throws GroupChangeBusyException, GroupChangeFailedException, IOException + { + boolean shouldAttemptToCreateV2 = !mms && !SignalStore.internalValues().gv2DoNotCreateGv2Groups(); + Set memberIds = getMemberIds(members); + + if (shouldAttemptToCreateV2) { + try { + try (GroupManagerV2.GroupCreator groupCreator = new GroupManagerV2(context).create()) { + return groupCreator.createGroup(memberIds, name, avatar); + } + } catch (MembershipNotSuitableForV2Exception e) { + Log.w(TAG, "Attempted to make a GV2, but membership was not suitable, falling back to GV1", e); + + return GroupManagerV1.createGroup(context, memberIds, avatar, name, false); + } + } else { + return GroupManagerV1.createGroup(context, memberIds, avatar, name, mms); + } + } + + @WorkerThread + public static GroupActionResult updateGroupDetails(@NonNull Context context, + @NonNull GroupId groupId, + @Nullable byte[] avatar, + boolean avatarChanged, + @NonNull String name, + boolean nameChanged) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + if (groupId.isV2()) { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + return edit.updateGroupTitleAndAvatar(nameChanged ? name : null, avatar, avatarChanged); + } + } else if (groupId.isV1()) { + List members = DatabaseFactory.getGroupDatabase(context) + .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + Set recipientIds = getMemberIds(new HashSet<>(members)); + + return GroupManagerV1.updateGroup(context, groupId.requireV1(), recipientIds, avatar, name, 0); + } else { + return GroupManagerV1.updateGroup(context, groupId.requireMms(), avatar, name); + } + } + + @WorkerThread + public static void migrateGroupToServer(@NonNull Context context, + @NonNull GroupId.V1 groupIdV1, + @NonNull Collection members) + throws IOException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException + { + new GroupManagerV2(context).migrateGroupOnToServer(groupIdV1, members); + } + + private static Set getMemberIds(Collection recipients) { + Set results = new HashSet<>(recipients.size()); + + for (Recipient recipient : recipients) { + results.add(recipient.getId()); + } + + return results; + } + + @WorkerThread + public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId) + throws GroupChangeBusyException, GroupChangeFailedException, IOException + { + if (groupId.isV2()) { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.leaveGroup(); + Log.i(TAG, "Left group " + groupId); + } catch (GroupInsufficientRightsException e) { + Log.w(TAG, "Unexpected prevention from leaving " + groupId + " due to rights", e); + throw new GroupChangeFailedException(e); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "Already left group " + groupId, e); + } + } else { + if (!GroupManagerV1.leaveGroup(context, groupId.requireV1())) { + Log.w(TAG, "GV1 group leave failed" + groupId); + throw new GroupChangeFailedException(); + } + } + } + + @WorkerThread + public static void leaveGroupFromBlockOrMessageRequest(@NonNull Context context, @NonNull GroupId.Push groupId) + throws IOException, GroupChangeBusyException, GroupChangeFailedException + { + if (groupId.isV2()) { + leaveGroup(context, groupId.requireV2()); + } else { + if (!GroupManagerV1.silentLeaveGroup(context, groupId.requireV1())) { + throw new GroupChangeFailedException(); + } + } + } + + @WorkerThread + public static void addMemberAdminsAndLeaveGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection newAdmins) + throws GroupChangeBusyException, GroupChangeFailedException, IOException, GroupInsufficientRightsException, GroupNotAMemberException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.addMemberAdminsAndLeaveGroup(newAdmins); + Log.i(TAG, "Left group " + groupId); + } + } + + @WorkerThread + public static void ejectFromGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Recipient recipient) + throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.ejectMember(recipient.getId()); + Log.i(TAG, "Member removed from group " + groupId); + } + } + + /** + * @throws GroupNotAMemberException When Self is not a member of the group. + * The exception to this is when Self is a requesting member and + * there is a supplied signedGroupChange. This allows for + * processing deny messages. + */ + @WorkerThread + public static void updateGroupFromServer(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey, + int revision, + long timestamp, + @Nullable byte[] signedGroupChange) + throws GroupChangeBusyException, IOException, GroupNotAMemberException + { + try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { + updater.updateLocalToServerRevision(revision, timestamp, signedGroupChange); + } + } + + @WorkerThread + public static V2GroupServerStatus v2GroupStatus(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey) + throws IOException + { + try { + new GroupManagerV2(context).groupServerQuery(groupMasterKey); + return V2GroupServerStatus.FULL_OR_PENDING_MEMBER; + } catch (GroupNotAMemberException e) { + return V2GroupServerStatus.NOT_A_MEMBER; + } catch (GroupDoesNotExistException e) { + return V2GroupServerStatus.DOES_NOT_EXIST; + } + } + + /** + * Tries to gets the exact version of the group at the time you joined. + *

+ * If it fails to get the exact version, it will give the latest. + */ + @WorkerThread + public static DecryptedGroup addedGroupVersion(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey) + throws IOException, GroupDoesNotExistException, GroupNotAMemberException + { + return new GroupManagerV2(context).addedGroupVersion(groupMasterKey); + } + + @WorkerThread + public static void setMemberAdmin(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull RecipientId recipientId, + boolean admin) + throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.setMemberAdmin(recipientId, admin); + } + } + + @WorkerThread + public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) + throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException + { + if (!DatabaseFactory.getGroupDatabase(context).groupExists(groupId)) { + Log.i(TAG, "Group is not available locally " + groupId); + return; + } + + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.updateSelfProfileKeyInGroup(); + } + } + + @WorkerThread + public static void acceptInvite(@NonNull Context context, @NonNull GroupId.V2 groupId) + throws GroupChangeBusyException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.acceptInvite(); + DatabaseFactory.getGroupDatabase(context) + .setActive(groupId, true); + } + } + + @WorkerThread + public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + if (groupId.isV2()) { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.updateGroupTimer(expirationTime); + } + } else { + GroupManagerV1.updateGroupTimer(context, groupId.requireV1(), expirationTime); + } + } + + @WorkerThread + public static void revokeInvites(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection uuidCipherTexts) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.revokeInvites(uuidCipherTexts); + } + } + + @WorkerThread + public static void applyMembershipAdditionRightsChange(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull GroupAccessControl newRights) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.updateMembershipRights(newRights); + } + } + + @WorkerThread + public static void applyAttributesRightsChange(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull GroupAccessControl newRights) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.updateAttributesRights(newRights); + } + } + + @WorkerThread + public static void cycleGroupLinkPassword(@NonNull Context context, + @NonNull GroupId.V2 groupId) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.cycleGroupLinkPassword(); + } + } + + @WorkerThread + public static GroupInviteLinkUrl setGroupLinkEnabledState(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull GroupLinkState state) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + return editor.setJoinByGroupLinkState(state); + } + } + + @WorkerThread + public static void approveRequests(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.approveRequests(recipientIds); + } + } + + @WorkerThread + public static void denyRequests(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.denyRequests(recipientIds); + } + } + + @WorkerThread + public static @NonNull GroupActionResult addMembers(@NonNull Context context, + @NonNull GroupId.Push groupId, + @NonNull Collection newMembers) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException, MembershipNotSuitableForV2Exception + { + if (groupId.isV2()) { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + return editor.addMembers(newMembers); + } + } else { + GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).requireGroup(groupId); + List members = groupRecord.getMembers(); + byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; + Set recipientIds = new HashSet<>(members); + int originalSize = recipientIds.size(); + + recipientIds.addAll(newMembers); + return GroupManagerV1.updateGroup(context, groupId, recipientIds, avatar, groupRecord.getTitle(), recipientIds.size() - originalSize); + } + } + + /** + * Use to get a group's details direct from server bypassing the database. + *

+ * Useful when you don't yet have the group in the database locally. + */ + @WorkerThread + public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey, + @Nullable GroupLinkPassword groupLinkPassword) + throws IOException, VerificationFailedException, GroupLinkNotActiveException + { + return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword); + } + + @WorkerThread + public static GroupActionResult joinGroup(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey, + @NonNull GroupLinkPassword groupLinkPassword, + @NonNull DecryptedGroupJoinInfo decryptedGroupJoinInfo, + @Nullable byte[] avatar) + throws IOException, GroupChangeBusyException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException + { + try (GroupManagerV2.GroupJoiner join = new GroupManagerV2(context).join(groupMasterKey, groupLinkPassword)) { + return join.joinGroup(decryptedGroupJoinInfo, avatar); + } + } + + @WorkerThread + public static void cancelJoinRequest(@NonNull Context context, + @NonNull GroupId.V2 groupId) + throws GroupChangeFailedException, IOException, GroupChangeBusyException + { + try (GroupManagerV2.GroupJoiner editor = new GroupManagerV2(context).cancelRequest(groupId.requireV2())) { + editor.cancelJoinRequest(); + } + } + + public static void sendNoopUpdate(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup currentState) { + new GroupManagerV2(context).sendNoopGroupUpdate(groupMasterKey, currentState); + } + + @WorkerThread + public static @NonNull GroupExternalCredential getGroupExternalCredential(@NonNull Context context, + @NonNull GroupId.V2 groupId) + throws IOException, VerificationFailedException + { + return new GroupManagerV2(context).getGroupExternalCredential(groupId); + } + + @WorkerThread + public static @NonNull Map getUuidCipherTexts(@NonNull Context context, @NonNull GroupId.V2 groupId) { + return new GroupManagerV2(context).getUuidCipherTexts(groupId); + } + + public static class GroupActionResult { + private final Recipient groupRecipient; + private final long threadId; + private final int addedMemberCount; + private final List invitedMembers; + + public GroupActionResult(@NonNull Recipient groupRecipient, + long threadId, + int addedMemberCount, + @NonNull List invitedMembers) + { + this.groupRecipient = groupRecipient; + this.threadId = threadId; + this.addedMemberCount = addedMemberCount; + this.invitedMembers = invitedMembers; + } + + public @NonNull Recipient getGroupRecipient() { + return groupRecipient; + } + + public long getThreadId() { + return threadId; + } + + public int getAddedMemberCount() { + return addedMemberCount; + } + + public @NonNull List getInvitedMembers() { + return invitedMembers; + } + } + + public enum GroupLinkState { + DISABLED, + ENABLED, + ENABLED_WITH_APPROVAL + } + + public enum V2GroupServerStatus { + /** The group does not exist. The expected pre-migration state for V1 groups. */ + DOES_NOT_EXIST, + /** Group exists but self is not in the group. */ + NOT_A_MEMBER, + /** Self is a full or pending member of the group. */ + FULL_OR_PENDING_MEMBER + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java new file mode 100644 index 00000000..58b23e1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java @@ -0,0 +1,263 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.protobuf.ByteString; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupManager.GroupActionResult; +import org.thoughtcrime.securesms.jobs.LeaveGroupJob; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +final class GroupManagerV1 { + + private static final String TAG = Log.tag(GroupManagerV1.class); + + static @NonNull GroupActionResult createGroup(@NonNull Context context, + @NonNull Set memberIds, + @Nullable byte[] avatarBytes, + @Nullable String name, + boolean mms) + { + final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + final SecureRandom secureRandom = new SecureRandom(); + final GroupId groupId = mms ? GroupId.createMms(secureRandom) : GroupId.createV1(secureRandom); + final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + final Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + memberIds.add(Recipient.self().getId()); + + if (groupId.isV1()) { + GroupId.V1 groupIdV1 = groupId.requireV1(); + + groupDatabase.create(groupIdV1, name, memberIds, null, null); + + try { + AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + } + groupDatabase.onAvatarUpdated(groupIdV1, avatarBytes != null); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); + return sendGroupUpdate(context, groupIdV1, memberIds, name, avatarBytes, memberIds.size() - 1); + } else { + groupDatabase.create(groupId.requireMms(), name, memberIds); + + try { + AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + } + groupDatabase.onAvatarUpdated(groupId, avatarBytes != null); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); + return new GroupActionResult(groupRecipient, threadId, memberIds.size() - 1, Collections.emptyList()); + } + } + + static GroupActionResult updateGroup(@NonNull Context context, + @NonNull GroupId groupId, + @NonNull Set memberAddresses, + @Nullable byte[] avatarBytes, + @Nullable String name, + int newMemberCount) + { + final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + + memberAddresses.add(Recipient.self().getId()); + groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses)); + + if (groupId.isPush()) { + GroupId.V1 groupIdV1 = groupId.requireV1(); + + groupDatabase.updateTitle(groupIdV1, name); + groupDatabase.onAvatarUpdated(groupIdV1, avatarBytes != null); + + try { + AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + } + return sendGroupUpdate(context, groupIdV1, memberAddresses, name, avatarBytes, newMemberCount); + } else { + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList()); + } + } + + static GroupActionResult updateGroup(@NonNull Context context, + @NonNull GroupId.Mms groupId, + @Nullable byte[] avatarBytes, + @Nullable String name) + { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + + groupDatabase.updateTitle(groupId, name); + groupDatabase.onAvatarUpdated(groupId, avatarBytes != null); + + try { + AvatarHelper.setAvatar(context, groupRecipientId, avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + } + + return new GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); + } + + private static GroupActionResult sendGroupUpdate(@NonNull Context context, + @NonNull GroupId.V1 groupId, + @NonNull Set members, + @Nullable String groupName, + @Nullable byte[] avatar, + int newMemberCount) + { + Attachment avatarAttachment = null; + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + List uuidMembers = new ArrayList<>(members.size()); + List e164Members = new ArrayList<>(members.size()); + + for (RecipientId member : members) { + Recipient recipient = Recipient.resolved(member); + if (recipient.hasE164()) { + e164Members.add(recipient.requireE164()); + uuidMembers.add(GroupV1MessageProcessor.createMember(recipient.requireE164())); + } + } + + GroupContext.Builder groupContextBuilder = GroupContext.newBuilder() + .setId(ByteString.copyFrom(groupId.getDecodedId())) + .setType(GroupContext.Type.UPDATE) + .addAllMembersE164(e164Members) + .addAllMembers(uuidMembers); + + if (groupName != null) groupContextBuilder.setName(groupName); + + GroupContext groupContext = groupContextBuilder.build(); + + if (avatar != null) { + Uri avatarUri = BlobProvider.getInstance().forData(avatar).createForSingleUseInMemory(); + avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, false, null, null, null, null, null); + } + + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); + + return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList()); + } + + @WorkerThread + static boolean leaveGroup(@NonNull Context context, @NonNull GroupId.V1 groupId) { + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + Optional leaveMessage = createGroupLeaveMessage(context, groupId, groupRecipient); + + if (threadId != -1 && leaveMessage.isPresent()) { + try { + long id = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(leaveMessage.get(), threadId, false, null); + DatabaseFactory.getMmsDatabase(context).markAsSent(id, true); + } catch (MmsException e) { + Log.w(TAG, "Failed to insert leave message.", e); + } + ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(groupRecipient)); + + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + groupDatabase.setActive(groupId, false); + groupDatabase.remove(groupId, Recipient.self().getId()); + return true; + } else { + Log.i(TAG, "Group was already inactive. Skipping."); + return false; + } + } + + @WorkerThread + static boolean silentLeaveGroup(@NonNull Context context, @NonNull GroupId.V1 groupId) { + if (DatabaseFactory.getGroupDatabase(context).isActive(groupId)) { + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + Optional leaveMessage = createGroupLeaveMessage(context, groupId, groupRecipient); + + if (threadId != -1 && leaveMessage.isPresent()) { + ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(groupRecipient)); + + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + groupDatabase.setActive(groupId, false); + groupDatabase.remove(groupId, Recipient.self().getId()); + return true; + } else { + Log.w(TAG, "Failed to leave group."); + return false; + } + } else { + Log.i(TAG, "Group was already inactive. Skipping."); + return true; + } + } + + @WorkerThread + static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.V1 groupId, int expirationTime) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Recipient recipient = Recipient.externalGroupExact(context, groupId); + long threadId = threadDatabase.getThreadIdFor(recipient); + + recipientDatabase.setExpireMessages(recipient.getId(), expirationTime); + OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(recipient, System.currentTimeMillis(), expirationTime * 1000L); + MessageSender.send(context, outgoingMessage, threadId, false, null); + } + + @WorkerThread + private static Optional createGroupLeaveMessage(@NonNull Context context, + @NonNull GroupId.V1 groupId, + @NonNull Recipient groupRecipient) + { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + if (!groupDatabase.isActive(groupId)) { + Log.w(TAG, "Group has already been left."); + return Optional.absent(); + } + + return Optional.of(GroupUtil.createGroupV1LeaveMessage(groupId, groupRecipient)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java new file mode 100644 index 00000000..8e9cc900 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -0,0 +1,1184 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.GroupExternalCredential; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.ClientZkGroupCipher; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.signal.zkgroup.groups.UuidCiphertext; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob; +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupCandidate; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; +import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.ConflictException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException; +import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; +import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; + +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +final class GroupManagerV2 { + + private static final String TAG = Log.tag(GroupManagerV2.class); + + private final Context context; + private final GroupDatabase groupDatabase; + private final GroupsV2Api groupsV2Api; + private final GroupsV2Operations groupsV2Operations; + private final GroupsV2Authorization authorization; + private final GroupsV2StateProcessor groupsV2StateProcessor; + private final UUID selfUuid; + private final GroupCandidateHelper groupCandidateHelper; + + GroupManagerV2(@NonNull Context context) { + this.context = context; + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); + this.groupsV2Operations = ApplicationDependencies.getGroupsV2Operations(); + this.authorization = ApplicationDependencies.getGroupsV2Authorization(); + this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor(); + this.selfUuid = Recipient.self().getUuid().get(); + this.groupCandidateHelper = new GroupCandidateHelper(context); + } + + @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password) + throws IOException, VerificationFailedException, GroupLinkNotActiveException + { + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + + return groupsV2Api.getGroupJoinInfo(groupSecretParams, + Optional.fromNullable(password).transform(GroupLinkPassword::serialize), + authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + } + + @WorkerThread + @NonNull GroupExternalCredential getGroupExternalCredential(@NonNull GroupId.V2 groupId) + throws IOException, VerificationFailedException + { + GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context) + .requireGroup(groupId) + .requireV2GroupProperties() + .getGroupMasterKey(); + + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + + return groupsV2Api.getGroupExternalCredential(authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + } + + @WorkerThread + @NonNull Map getUuidCipherTexts(@NonNull GroupId.V2 groupId) { + GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).requireGroup(groupId); + GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + GroupMasterKey groupMasterKey = v2GroupProperties.getGroupMasterKey(); + ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); + List recipients = v2GroupProperties.getMemberRecipients(GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF); + + Map uuidCipherTexts = new HashMap<>(); + for (Recipient recipient : recipients) { + uuidCipherTexts.put(recipient.requireUuid(), clientZkGroupCipher.encryptUuid(recipient.requireUuid())); + } + + return uuidCipherTexts; + } + + @WorkerThread + GroupCreator create() throws GroupChangeBusyException { + return new GroupCreator(GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + @WorkerThread + GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { + return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + @WorkerThread + GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException { + return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + @WorkerThread + GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { + GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context) + .requireGroup(groupId) + .requireV2GroupProperties() + .getGroupMasterKey(); + + return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + @WorkerThread + GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException { + return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + @WorkerThread + void groupServerQuery(@NonNull GroupMasterKey groupMasterKey) + throws GroupNotAMemberException, IOException, GroupDoesNotExistException + { + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .getCurrentGroupStateFromServer(); + } + + @WorkerThread + @NonNull DecryptedGroup addedGroupVersion(@NonNull GroupMasterKey groupMasterKey) + throws GroupNotAMemberException, IOException, GroupDoesNotExistException + { + GroupsV2StateProcessor.StateProcessorForGroup stateProcessorForGroup = new GroupsV2StateProcessor(context).forGroup(groupMasterKey); + DecryptedGroup latest = stateProcessorForGroup.getCurrentGroupStateFromServer(); + + if (latest.getRevision() == 0) { + return latest; + } + + Optional selfInFullMemberList = DecryptedGroupUtil.findMemberByUuid(latest.getMembersList(), Recipient.self().requireUuid()); + + if (!selfInFullMemberList.isPresent()) { + return latest; + } + + DecryptedGroup joinedVersion = stateProcessorForGroup.getSpecificVersionFromServer(selfInFullMemberList.get().getJoinedAtRevision()); + + if (joinedVersion != null) { + return joinedVersion; + } else { + Log.w(TAG, "Unable to retreive exact version joined at, using latest"); + return latest; + } + } + + @WorkerThread + void migrateGroupOnToServer(@NonNull GroupId.V1 groupIdV1, @NonNull Collection members) + throws IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException, GroupChangeFailedException + { + GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupIdV1); + String name = Util.emptyIfNull(groupRecord.getTitle()); + byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; + int messageTimer = Recipient.resolved(groupRecord.getRecipientId()).getExpireMessages(); + Set memberIds = Stream.of(members) + .map(Recipient::getId) + .filterNot(m -> m.equals(Recipient.self().getId())) + .collect(Collectors.toSet()); + + createGroupOnServer(groupSecretParams, name, avatar, memberIds, Member.Role.ADMINISTRATOR, messageTimer); + } + + @WorkerThread + void sendNoopGroupUpdate(@NonNull GroupMasterKey masterKey, @NonNull DecryptedGroup currentState) { + sendGroupUpdate(masterKey, new GroupMutation(currentState, DecryptedGroupChange.newBuilder().build(), currentState), null); + } + + + final class GroupCreator extends LockOwner { + + GroupCreator(@NonNull Closeable lock) { + super(lock); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult createGroup(@NonNull Collection members, + @Nullable String name, + @Nullable byte[] avatar) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception + { + return createGroup(name, avatar, members); + } + + @WorkerThread + private @NonNull GroupManager.GroupActionResult createGroup(@Nullable String name, + @Nullable byte[] avatar, + @NonNull Collection members) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception + { + GroupSecretParams groupSecretParams = GroupSecretParams.generate(); + DecryptedGroup decryptedGroup; + + try { + decryptedGroup = createGroupOnServer(groupSecretParams, name, avatar, members, Member.Role.DEFAULT, 0); + } catch (GroupAlreadyExistsException e) { + throw new GroupChangeFailedException(e); + } + + GroupMasterKey masterKey = groupSecretParams.getMasterKey(); + GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup); + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); + groupDatabase.onAvatarUpdated(groupId, avatar != null); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); + + DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder(GroupChangeReconstruct.reconstructGroupChange(DecryptedGroup.newBuilder().build(), decryptedGroup)) + .setEditor(UuidUtil.toByteString(selfUuid)) + .build(); + + RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); + + return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, + recipientAndThread.threadId, + decryptedGroup.getMembersCount() - 1, + getPendingMemberRecipientIds(decryptedGroup.getPendingMembersList())); + } + } + + final class GroupEditor extends LockOwner { + + private final GroupId.V2 groupId; + private final GroupMasterKey groupMasterKey; + private final GroupSecretParams groupSecretParams; + private final GroupsV2Operations.GroupOperations groupOperations; + + GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) { + super(lock); + + GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + + this.groupId = groupId; + this.groupMasterKey = v2GroupProperties.getGroupMasterKey(); + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + this.groupOperations = groupsV2Operations.forGroup(groupSecretParams); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection newMembers) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception + { + if (!GroupsV2CapabilityChecker.allHaveUuidAndSupportGroupsV2(newMembers)) { + throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities"); + } + + Set groupCandidates = groupCandidateHelper.recipientIdsToCandidates(new HashSet<>(newMembers)); + + if (SignalStore.internalValues().gv2ForceInvites()) { + groupCandidates = GroupCandidate.withoutProfileKeyCredentials(groupCandidates); + } + + return commitChangeWithConflictResolution(groupOperations.createModifyGroupMembershipChange(groupCandidates, selfUuid)); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult updateGroupTimer(int expirationTime) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(groupOperations.createModifyGroupTimerChange(expirationTime)); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult updateAttributesRights(@NonNull GroupAccessControl newRights) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(groupOperations.createChangeAttributesRights(rightsToAccessControl(newRights))); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult updateMembershipRights(@NonNull GroupAccessControl newRights) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(groupOperations.createChangeMembershipRights(rightsToAccessControl(newRights))); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult updateGroupTitleAndAvatar(@Nullable String title, @Nullable byte[] avatarBytes, boolean avatarChanged) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + try { + GroupChange.Actions.Builder change = title != null ? groupOperations.createModifyGroupTitle(title) + : GroupChange.Actions.newBuilder(); + + if (avatarChanged) { + String cdnKey = avatarBytes != null ? groupsV2Api.uploadAvatar(avatarBytes, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams)) + : ""; + change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder() + .setAvatar(cdnKey)); + } + + GroupManager.GroupActionResult groupActionResult = commitChangeWithConflictResolution(change); + + if (avatarChanged) { + AvatarHelper.setAvatar(context, Recipient.externalGroupExact(context, groupId).getId(), avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + groupDatabase.onAvatarUpdated(groupId, avatarBytes != null); + } + + return groupActionResult; + } catch (VerificationFailedException e) { + throw new GroupChangeFailedException(e); + } + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult revokeInvites(@NonNull Collection uuidCipherTexts) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts))); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult approveRequests(@NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Set uuids = Stream.of(recipientIds) + .map(r -> Recipient.resolved(r).getUuid().get()) + .collect(Collectors.toSet()); + + return commitChangeWithConflictResolution(groupOperations.createApproveGroupJoinRequest(uuids)); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Set uuids = Stream.of(recipientIds) + .map(r -> Recipient.resolved(r).getUuid().get()) + .collect(Collectors.toSet()); + + return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids)); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId, + boolean admin) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Recipient recipient = Recipient.resolved(recipientId); + return commitChangeWithConflictResolution(groupOperations.createChangeMemberRole(recipient.getUuid().get(), admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT)); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult leaveGroup() + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Recipient self = Recipient.self(); + GroupDatabase.GroupRecord groupRecord = groupDatabase.getGroup(groupId).get(); + List pendingMembersList = groupRecord.requireV2GroupProperties().getDecryptedGroup().getPendingMembersList(); + Optional selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid); + + if (selfPendingMember.isPresent()) { + try { + return revokeInvites(Collections.singleton(new UuidCiphertext(selfPendingMember.get().getUuidCipherText().toByteArray()))); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } else { + return ejectMember(self.getId()); + } + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult ejectMember(@NonNull RecipientId recipientId) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Recipient recipient = Recipient.resolved(recipientId); + + return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.getUuid().get()))); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult addMemberAdminsAndLeaveGroup(Collection newAdmins) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + Recipient self = Recipient.self(); + List newAdminRecipients = Stream.of(newAdmins).map(id -> Recipient.resolved(id).getUuid().get()).toList(); + + return commitChangeWithConflictResolution(groupOperations.createLeaveAndPromoteMembersToAdmin(self.getUuid().get(), + newAdminRecipients)); + } + + @WorkerThread + @Nullable GroupManager.GroupActionResult updateSelfProfileKeyInGroup() + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); + DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + Optional selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid); + + if (!selfInGroup.isPresent()) { + Log.w(TAG, "Self not in group " + groupId); + return null; + } + + if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) { + Log.i(TAG, "Own Profile Key is already up to date in group " + groupId); + return null; + } else { + Log.i(TAG, "Profile Key does not match that in group " + groupId); + } + + GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); + + if (!groupCandidate.hasProfileKeyCredential()) { + Log.w(TAG, "No credential available, repairing"); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + return null; + } + + return commitChangeWithConflictResolution(groupOperations.createUpdateProfileKeyCredentialChange(groupCandidate.getProfileKeyCredential().get())); + } + + @WorkerThread + @Nullable GroupManager.GroupActionResult acceptInvite() + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + Optional selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), Recipient.self().getUuid().get()); + + if (selfInGroup.isPresent()) { + Log.w(TAG, "Self already in group"); + return null; + } + + GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); + + if (!groupCandidate.hasProfileKeyCredential()) { + Log.w(TAG, "No credential available"); + return null; + } + + return commitChangeWithConflictResolution(groupOperations.createAcceptInviteChange(groupCandidate.getProfileKeyCredential().get())); + } + + @WorkerThread + public GroupManager.GroupActionResult cycleGroupLinkPassword() + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + return commitChangeWithConflictResolution(groupOperations.createModifyGroupLinkPasswordChange(GroupLinkPassword.createNew().serialize())); + } + + @WorkerThread + public @Nullable GroupInviteLinkUrl setJoinByGroupLinkState(@NonNull GroupManager.GroupLinkState state) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + AccessControl.AccessRequired access; + + switch (state) { + case DISABLED : access = AccessControl.AccessRequired.UNSATISFIABLE; break; + case ENABLED : access = AccessControl.AccessRequired.ANY; break; + case ENABLED_WITH_APPROVAL: access = AccessControl.AccessRequired.ADMINISTRATOR; break; + default: throw new AssertionError(); + } + + GroupChange.Actions.Builder change = groupOperations.createChangeJoinByLinkRights(access); + + if (state != GroupManager.GroupLinkState.DISABLED) { + DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + + if (group.getInviteLinkPassword().isEmpty()) { + Log.d(TAG, "First time enabling group links for group and password empty, generating"); + change = groupOperations.createModifyGroupLinkPasswordAndRightsChange(GroupLinkPassword.createNew().serialize(), access); + } + } + + commitChangeWithConflictResolution(change); + + if (state != GroupManager.GroupLinkState.DISABLED) { + GroupDatabase.V2GroupProperties v2GroupProperties = groupDatabase.requireGroup(groupId).requireV2GroupProperties(); + GroupMasterKey groupMasterKey = v2GroupProperties.getGroupMasterKey(); + DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup(); + + return GroupInviteLinkUrl.forGroup(groupMasterKey, decryptedGroup); + } else { + return null; + } + } + + private @NonNull GroupManager.GroupActionResult commitChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get())); + + for (int attempt = 0; attempt < 5; attempt++) { + try { + return commitChange(change); + } catch (GroupPatchNotAcceptedException e) { + throw new GroupChangeFailedException(e); + } catch (ConflictException e) { + Log.w(TAG, "Invalid group patch or conflict", e); + + change = resolveConflict(change); + + if (GroupChangeUtil.changeIsEmpty(change.build())) { + Log.i(TAG, "Change is empty after conflict resolution"); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + + return new GroupManager.GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); + } + } + } + + throw new GroupChangeFailedException("Unable to apply change to group after conflicts"); + } + + private GroupChange.Actions.Builder resolveConflict(@NonNull GroupChange.Actions.Builder change) + throws IOException, GroupNotAMemberException, GroupChangeFailedException + { + GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey) + .updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null); + + if (groupUpdateResult.getLatestServer() == null) { + Log.w(TAG, "Latest server state null."); + throw new GroupChangeFailedException(); + } + + if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED) { + int serverRevision = groupUpdateResult.getLatestServer().getRevision(); + int localRevision = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getGroupRevision(); + int revisionDelta = serverRevision - localRevision; + Log.w(TAG, String.format(Locale.US, "Server is ahead by %d revisions", revisionDelta)); + throw new GroupChangeFailedException(); + } + + Log.w(TAG, "Group has been updated"); + try { + GroupChange.Actions changeActions = change.build(); + + return GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(), + groupOperations.decryptChange(changeActions, selfUuid), + changeActions); + } catch (VerificationFailedException | InvalidGroupStateException ex) { + throw new GroupChangeFailedException(ex); + } + } + + private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change) + throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException + { + final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + final int nextRevision = v2GroupProperties.getGroupRevision() + 1; + final GroupChange.Actions changeActions = change.setRevision(nextRevision).build(); + final DecryptedGroupChange decryptedChange; + final DecryptedGroup decryptedGroupState; + final DecryptedGroup previousGroupState; + + try { + previousGroupState = v2GroupProperties.getDecryptedGroup(); + decryptedChange = groupOperations.decryptChange(changeActions, selfUuid); + decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); + } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { + Log.w(TAG, e); + throw new IOException(e); + } + + GroupChange signedGroupChange = commitToServer(changeActions); + groupDatabase.update(groupId, decryptedGroupState); + + GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState); + RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange); + int newMembersCount = decryptedChange.getNewMembersCount(); + List newPendingMembers = getPendingMemberRecipientIds(decryptedChange.getNewPendingMembersList()); + + return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, newMembersCount, newPendingMembers); + } + + private @NonNull GroupChange commitToServer(@NonNull GroupChange.Actions change) + throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException + { + try { + return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent()); + } catch (NotInGroupException e) { + Log.w(TAG, e); + throw new GroupNotAMemberException(e); + } catch (AuthorizationFailedException e) { + Log.w(TAG, e); + throw new GroupInsufficientRightsException(e); + } catch (VerificationFailedException e) { + Log.w(TAG, e); + throw new GroupChangeFailedException(e); + } + } + } + + final class GroupUpdater extends LockOwner { + + private final GroupMasterKey groupMasterKey; + + GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) { + super(lock); + + this.groupMasterKey = groupMasterKey; + } + + @WorkerThread + void updateLocalToServerRevision(int revision, long timestamp, @Nullable byte[] signedGroupChange) + throws IOException, GroupNotAMemberException + { + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange)); + } + + private DecryptedGroupChange getDecryptedGroupChange(@Nullable byte[] signedGroupChange) { + if (signedGroupChange != null) { + GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); + + try { + return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true) + .orNull(); + } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) { + Log.w(TAG, "Unable to verify supplied group change", e); + } + } + + return null; + } + } + + @WorkerThread + private @NonNull DecryptedGroup createGroupOnServer(@NonNull GroupSecretParams groupSecretParams, + @Nullable String name, + @Nullable byte[] avatar, + @NonNull Collection members, + @NonNull Member.Role memberRole, + int disappearingMessageTimerSeconds) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException + { + if (!GroupsV2CapabilityChecker.allAndSelfHaveUuidAndSupportGroupsV2(members)) { + throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 capability or we don't have their UUID"); + } + + GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); + Set candidates = new HashSet<>(groupCandidateHelper.recipientIdsToCandidates(members)); + + if (SignalStore.internalValues().gv2ForceInvites()) { + Log.w(TAG, "Forcing GV2 invites due to internal setting"); + candidates = GroupCandidate.withoutProfileKeyCredentials(candidates); + } + + if (!self.hasProfileKeyCredential()) { + Log.w(TAG, "Cannot create a V2 group as self does not have a versioned profile"); + throw new MembershipNotSuitableForV2Exception("Cannot create a V2 group as self does not have a versioned profile"); + } + + GroupsV2Operations.NewGroup newGroup = groupsV2Operations.createNewGroup(groupSecretParams, + name, + Optional.fromNullable(avatar), + self, + candidates, + memberRole, + disappearingMessageTimerSeconds); + + try { + groupsV2Api.putNewGroup(newGroup, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + + DecryptedGroup decryptedGroup = groupsV2Api.getGroup(groupSecretParams, ApplicationDependencies.getGroupsV2Authorization().getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + if (decryptedGroup == null) { + throw new GroupChangeFailedException(); + } + + return decryptedGroup; + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new GroupChangeFailedException(e); + } catch (GroupExistsException e) { + throw new GroupAlreadyExistsException(e); + } + } + + final class GroupJoiner extends LockOwner { + private final GroupId.V2 groupId; + private final GroupLinkPassword password; + private final GroupSecretParams groupSecretParams; + private final GroupsV2Operations.GroupOperations groupOperations; + private final GroupMasterKey groupMasterKey; + + public GroupJoiner(@NonNull GroupMasterKey groupMasterKey, + @Nullable GroupLinkPassword password, + @NonNull Closeable lock) + { + super(lock); + + this.groupId = GroupId.v2(groupMasterKey); + this.password = password; + this.groupMasterKey = groupMasterKey; + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + this.groupOperations = groupsV2Operations.forGroup(groupSecretParams); + } + + @WorkerThread + public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo, + @Nullable byte[] avatar) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException + { + boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR; + boolean alreadyAMember = false; + + if (requestToJoin) { + Log.i(TAG, "Requesting to join " + groupId); + } else { + Log.i(TAG, "Joining " + groupId); + } + + GroupChange signedGroupChange = null; + DecryptedGroupChange decryptedChange = null; + try { + signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision()); + + if (requestToJoin) { + Log.i(TAG, String.format("Successfully requested to join %s on server", groupId)); + } else { + Log.i(TAG, String.format("Successfully added self to %s on server", groupId)); + } + + decryptedChange = decryptChange(signedGroupChange); + } catch (GroupJoinAlreadyAMemberException e) { + Log.i(TAG, "Server reports that we are already a member of " + groupId); + alreadyAMember = true; + } + + Optional unmigratedV1Group = groupDatabase.getGroupV1ByExpectedV2(groupId); + + if (unmigratedV1Group.isPresent()) { + Log.i(TAG, "Group link was for a migrated V1 group we know about! Migrating it and using that as the base."); + GroupsV1MigrationUtil.performLocalMigration(context, unmigratedV1Group.get().getId().requireV1()); + } + + DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin); + + Optional group = groupDatabase.getGroup(groupId); + + if (group.isPresent()) { + Log.i(TAG, "Group already present locally"); + + DecryptedGroup currentGroupState = group.get() + .requireV2GroupProperties() + .getDecryptedGroup(); + + DecryptedGroup updatedGroup = currentGroupState; + + try { + if (decryptedChange != null) { + updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange); + } + updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision()); + } catch (NotAbleToApplyGroupV2ChangeException e) { + Log.w(TAG, e); + updatedGroup = decryptedGroup; + } + + groupDatabase.update(groupId, updatedGroup); + } else { + groupDatabase.create(groupMasterKey, decryptedGroup); + Log.i(TAG, "Created local group with placeholder"); + } + + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); + groupDatabase.onAvatarUpdated(groupId, avatar != null); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true); + + if (alreadyAMember) { + Log.i(TAG, "Already a member of the group"); + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1); + + return new GroupManager.GroupActionResult(groupRecipient, + threadId, + 0, + Collections.emptyList()); + } else if (requestToJoin) { + Log.i(TAG, "Requested to join, cannot send update"); + + RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); + + return new GroupManager.GroupActionResult(groupRecipient, + recipientAndThread.threadId, + 0, + Collections.emptyList()); + } else { + Log.i(TAG, "Joined group on server, fetching group state and sending update"); + + return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange); + } + } + + private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient, + @NonNull DecryptedGroup decryptedGroup, + @NonNull DecryptedGroupChange decryptedChange, + @NonNull GroupChange signedGroupChange) + throws GroupChangeFailedException, IOException + { + try { + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .updateLocalGroupToRevision(decryptedChange.getRevision(), + System.currentTimeMillis(), + decryptedChange); + + RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); + + return new GroupManager.GroupActionResult(groupRecipient, + recipientAndThread.threadId, + 1, + Collections.emptyList()); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e); + + ApplicationDependencies.getJobManager() + .add(new RequestGroupV2InfoJob(groupId)); + + throw new GroupChangeFailedException(e); + } catch (IOException e) { + Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e); + + ApplicationDependencies.getJobManager() + .add(new RequestGroupV2InfoJob(groupId)); + + throw e; + } + } + + private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange) + throws GroupChangeFailedException + { + try { + return groupOperations.decryptChange(signedGroupChange, false).get(); + } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) { + Log.w(TAG, e); + throw new GroupChangeFailedException(e); + } + } + + /** + * Creates a local group from what we know before joining. + *

+ * Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a + * full diff against this group once we learn more about this group as that would create a large + * update message. + */ + private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) { + DecryptedGroup.Builder group = DecryptedGroup.newBuilder() + .setTitle(joinInfo.getTitle()) + .setAvatar(joinInfo.getAvatar()) + .setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION); + + Recipient self = Recipient.self(); + ByteString selfUuid = UuidUtil.toByteString(self.requireUuid()); + ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey())); + + if (requestToJoin) { + group.addRequestingMembers(DecryptedRequestingMember.newBuilder() + .setUuid(selfUuid) + .setProfileKey(profileKey)); + } else { + group.addMembers(DecryptedMember.newBuilder() + .setUuid(selfUuid) + .setProfileKey(profileKey)); + } + + return group.build(); + } + + private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException + { + if (!GroupsV2CapabilityChecker.allAndSelfHaveUuidAndSupportGroupsV2(Collections.singleton(Recipient.self().getId()))) { + throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities"); + } + + GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); + + if (!self.hasProfileKeyCredential()) { + throw new MembershipNotSuitableForV2Exception("No profile key credential for self"); + } + + ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get(); + + GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential) + : groupOperations.createGroupJoinDirect(profileKeyCredential); + + change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get())); + + return commitJoinChangeWithConflictResolution(currentRevision, change); + } + + private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change) + throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException + { + for (int attempt = 0; attempt < 5; attempt++) { + try { + GroupChange.Actions changeActions = change.setRevision(currentRevision + 1) + .build(); + + Log.i(TAG, "Trying to join group at V" + changeActions.getRevision()); + GroupChange signedGroupChange = commitJoinToServer(changeActions); + + Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision()); + return signedGroupChange; + } catch (GroupPatchNotAcceptedException e) { + Log.w(TAG, "Patch not accepted", e); + + try { + if (alreadyPendingAdminApproval() || testGroupMembership()) { + throw new GroupJoinAlreadyAMemberException(e); + } else { + throw new GroupChangeFailedException(e); + } + } catch (VerificationFailedException | InvalidGroupStateException ex) { + throw new GroupChangeFailedException(ex); + } + } catch (ConflictException e) { + Log.w(TAG, "Revision conflict", e); + + currentRevision = getCurrentGroupRevisionFromServer(); + } + } + + throw new GroupChangeFailedException("Unable to join group after conflicts"); + } + + private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change) + throws GroupChangeFailedException, IOException, GroupLinkNotActiveException + { + try { + return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize)); + } catch (NotInGroupException | VerificationFailedException e) { + Log.w(TAG, e); + throw new GroupChangeFailedException(e); + } catch (AuthorizationFailedException e) { + Log.w(TAG, e); + throw new GroupLinkNotActiveException(e); + } + } + + private int getCurrentGroupRevisionFromServer() + throws IOException, GroupLinkNotActiveException, GroupChangeFailedException + { + try { + int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision(); + + Log.i(TAG, "Server now on V" + currentRevision); + + return currentRevision; + } catch (VerificationFailedException ex) { + throw new GroupChangeFailedException(ex); + } + } + + private boolean alreadyPendingAdminApproval() + throws IOException, GroupLinkNotActiveException, GroupChangeFailedException + { + try { + boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval(); + + if (pendingAdminApproval) { + Log.i(TAG, "User is already pending admin approval"); + } + + return pendingAdminApproval; + } catch (VerificationFailedException ex) { + throw new GroupChangeFailedException(ex); + } + } + + private boolean testGroupMembership() + throws IOException, VerificationFailedException, InvalidGroupStateException + { + try { + groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + return true; + } catch (NotInGroupException ex) { + return false; + } + } + + @WorkerThread + void cancelJoinRequest() + throws GroupChangeFailedException, IOException + { + Set uuids = Collections.singleton(Recipient.self().getUuid().get()); + + GroupChange signedGroupChange; + try { + signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids)); + } catch (GroupLinkNotActiveException e) { + Log.d(TAG, "Unexpected unable to leave group due to group link off"); + throw new GroupChangeFailedException(e); + } + + DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + + try { + DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get(); + DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange); + + groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision())); + + sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange); + } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { + throw new GroupChangeFailedException(e); + } + } + + private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) { + return DecryptedGroup.newBuilder(newGroup) + .setRevision(revision) + .build(); + } + + private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change) + throws GroupChangeFailedException, IOException, GroupLinkNotActiveException + { + int currentRevision = getCurrentGroupRevisionFromServer(); + + for (int attempt = 0; attempt < 5; attempt++) { + try { + GroupChange.Actions changeActions = change.setRevision(currentRevision + 1) + .build(); + + Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision()); + GroupChange signedGroupChange = commitJoinToServer(changeActions); + + Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision()); + return signedGroupChange; + } catch (GroupPatchNotAcceptedException e) { + throw new GroupChangeFailedException(e); + } catch (ConflictException e) { + Log.w(TAG, "Revision conflict", e); + + currentRevision = getCurrentGroupRevisionFromServer(); + } + } + + throw new GroupChangeFailedException("Unable to cancel group join request after conflicts"); + } +} + + private abstract static class LockOwner implements Closeable { + final Closeable lock; + + LockOwner(@NonNull Closeable lock) { + this.lock = lock; + } + + @Override + public void close() throws IOException { + lock.close(); + } + } + + private @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, + @NonNull GroupMutation groupMutation, + @Nullable GroupChange signedGroupChange) + { + GroupId.V2 groupId = GroupId.v2(masterKey); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, + decryptedGroupV2Context, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + + + DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); + + if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { + ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage)); + return new RecipientAndThread(groupRecipient, -1); + } else { + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); + return new RecipientAndThread(groupRecipient, threadId); + } + } + + private static @NonNull List getPendingMemberRecipientIds(@NonNull List newPendingMembersList) { + return Stream.of(DecryptedGroupUtil.pendingToUuidList(newPendingMembersList)) + .map(uuid-> RecipientId.from(uuid,null)) + .toList(); + } + + private static @NonNull AccessControl.AccessRequired rightsToAccessControl(@NonNull GroupAccessControl rights) { + switch (rights){ + case ALL_MEMBERS: + return AccessControl.AccessRequired.MEMBER; + case ONLY_ADMINS: + return AccessControl.AccessRequired.ADMINISTRATOR; + case NO_ONE: + return AccessControl.AccessRequired.UNSATISFIABLE; + default: + throw new AssertionError(); + } + } + + static class RecipientAndThread { + private final Recipient groupRecipient; + private final long threadId; + + RecipientAndThread(@NonNull Recipient groupRecipient, long threadId) { + this.groupRecipient = groupRecipient; + this.threadId = threadId; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMigrationMembershipChange.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMigrationMembershipChange.java new file mode 100644 index 00000000..19b3c5dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMigrationMembershipChange.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Collections; +import java.util.List; + +/** + * Describes a change in membership that results from a GV1->GV2 migration. + */ +public final class GroupMigrationMembershipChange { + private final List pending; + private final List dropped; + + public GroupMigrationMembershipChange(@NonNull List pending, @NonNull List dropped) { + this.pending = pending; + this.dropped = dropped; + } + + public static GroupMigrationMembershipChange empty() { + return new GroupMigrationMembershipChange(Collections.emptyList(), Collections.emptyList()); + } + + public static @NonNull GroupMigrationMembershipChange deserialize(@Nullable String serialized) { + if (Util.isEmpty(serialized)) { + return empty(); + } else { + String[] parts = serialized.split("\\|"); + if (parts.length == 1) { + return new GroupMigrationMembershipChange(RecipientId.fromSerializedList(parts[0]), Collections.emptyList()); + } else if (parts.length == 2) { + return new GroupMigrationMembershipChange(RecipientId.fromSerializedList(parts[0]), RecipientId.fromSerializedList(parts[1])); + } else { + return GroupMigrationMembershipChange.empty(); + } + } + } + + public @NonNull List getPending() { + return pending; + } + + public @NonNull List getDropped() { + return dropped; + } + + public @NonNull String serialize() { + return RecipientId.toSerializedList(pending) + "|" + RecipientId.toSerializedList(dropped); + } + + public boolean isEmpty() { + return pending.isEmpty() && dropped.isEmpty(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMutation.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMutation.java new file mode 100644 index 00000000..9108d3db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMutation.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; + +public final class GroupMutation { + @Nullable private final DecryptedGroup previousGroupState; + @Nullable private final DecryptedGroupChange groupChange; + @NonNull private final DecryptedGroup newGroupState; + + public GroupMutation(@Nullable DecryptedGroup previousGroupState, @Nullable DecryptedGroupChange groupChange, @NonNull DecryptedGroup newGroupState) { + this.previousGroupState = previousGroupState; + this.groupChange = groupChange; + this.newGroupState = newGroupState; + } + + public @Nullable DecryptedGroup getPreviousGroupState() { + return previousGroupState; + } + + public @Nullable DecryptedGroupChange getGroupChange() { + return groupChange; + } + + public @NonNull DecryptedGroup getNewGroupState() { + return newGroupState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java new file mode 100644 index 00000000..672bb642 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupNotAMemberException extends GroupChangeException { + + public GroupNotAMemberException(Throwable throwable) { + super(throwable); + } + + GroupNotAMemberException() { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java new file mode 100644 index 00000000..1a0d290a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.util.UUIDUtil; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.util.List; +import java.util.UUID; + +public final class GroupProtoUtil { + + private GroupProtoUtil() { + } + + public static int findRevisionWeWereAdded(@NonNull DecryptedGroup group, @NonNull UUID uuid) + throws GroupNotAMemberException + { + ByteString bytes = UuidUtil.toByteString(uuid); + for (DecryptedMember decryptedMember : group.getMembersList()) { + if (decryptedMember.getUuid().equals(bytes)) { + return decryptedMember.getJoinedAtRevision(); + } + } + for (DecryptedPendingMember decryptedMember : group.getPendingMembersList()) { + if (decryptedMember.getUuid().equals(bytes)) { + // Assume latest, we don't have any information about when pending members were invited + return group.getRevision(); + } + } + throw new GroupNotAMemberException(); + } + + public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey, + @NonNull GroupMutation groupMutation, + @Nullable GroupChange signedServerChange) + { + DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); + DecryptedGroup decryptedGroup = groupMutation.getNewGroupState(); + int revision = plainGroupChange != null ? plainGroupChange.getRevision() : decryptedGroup.getRevision(); + SignalServiceProtos.GroupContextV2.Builder contextBuilder = SignalServiceProtos.GroupContextV2.newBuilder() + .setMasterKey(ByteString.copyFrom(masterKey.serialize())) + .setRevision(revision); + + if (signedServerChange != null) { + contextBuilder.setGroupChange(signedServerChange.toByteString()); + } + + DecryptedGroupV2Context.Builder builder = DecryptedGroupV2Context.newBuilder() + .setContext(contextBuilder.build()) + .setGroupState(decryptedGroup); + + if (groupMutation.getPreviousGroupState() != null) { + builder.setPreviousGroupState(groupMutation.getPreviousGroupState()); + } + + if (plainGroupChange != null) { + builder.setChange(plainGroupChange); + } + + return builder.build(); + } + + @WorkerThread + public static Recipient pendingMemberToRecipient(@NonNull Context context, @NonNull DecryptedPendingMember pendingMember) { + return uuidByteStringToRecipient(context, pendingMember.getUuid()); + } + + @WorkerThread + public static Recipient uuidByteStringToRecipient(@NonNull Context context, @NonNull ByteString uuidByteString) { + UUID uuid = UUIDUtil.deserialize(uuidByteString.toByteArray()); + + if (uuid.equals(GroupsV2Operations.UNKNOWN_UUID)) { + return Recipient.UNKNOWN; + } + + return Recipient.externalPush(context, uuid, null, false); + } + + @WorkerThread + public static @NonNull RecipientId uuidByteStringToRecipientId(@NonNull ByteString uuidByteString) { + UUID uuid = UUIDUtil.deserialize(uuidByteString.toByteArray()); + + if (uuid.equals(GroupsV2Operations.UNKNOWN_UUID)) { + return RecipientId.UNKNOWN; + } + + return RecipientId.from(uuid, null); + } + + + public static boolean isMember(@NonNull UUID uuid, @NonNull List membersList) { + ByteString uuidBytes = UuidUtil.toByteString(uuid); + + for (DecryptedMember member : membersList) { + if (uuidBytes.equals(member.getUuid())) { + return true; + } + } + + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java new file mode 100644 index 00000000..2986c5f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java @@ -0,0 +1,308 @@ +package org.thoughtcrime.securesms.groups; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV1DownloadJob; +import org.thoughtcrime.securesms.jobs.PushGroupUpdateJob; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup.Type; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer; +import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; + +public final class GroupV1MessageProcessor { + + private static final String TAG = Log.tag(GroupV1MessageProcessor.class); + + public static @Nullable Long process(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + boolean outgoing) + throws BadGroupIdException + { + SignalServiceGroupContext signalServiceGroupContext = message.getGroupContext().get(); + Optional groupV1 = signalServiceGroupContext.getGroupV1(); + + if (signalServiceGroupContext.getGroupV2().isPresent()) { + throw new AssertionError("Cannot process GV2"); + } + + if (!groupV1.isPresent() || groupV1.get().getGroupId() == null) { + Log.w(TAG, "Received group message with no id! Ignoring..."); + return null; + } + + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + SignalServiceGroup group = groupV1.get(); + GroupId id = GroupId.v1(group.getGroupId()); + Optional record = database.getGroup(id); + + if (record.isPresent() && group.getType() == Type.UPDATE) { + return handleGroupUpdate(context, content, group, record.get(), outgoing); + } else if (!record.isPresent() && group.getType() == Type.UPDATE) { + return handleGroupCreate(context, content, group, outgoing); + } else if (record.isPresent() && group.getType() == Type.QUIT) { + return handleGroupLeave(context, content, group, record.get(), outgoing); + } else if (record.isPresent() && group.getType() == Type.REQUEST_INFO) { + return handleGroupInfoRequest(context, content, record.get()); + } else { + Log.w(TAG, "Received unknown type, ignoring..."); + return null; + } + } + + private static @Nullable Long handleGroupCreate(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull SignalServiceGroup group, + boolean outgoing) + { + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + GroupId.V1 id = GroupId.v1orThrow(group.getGroupId()); + GroupContext.Builder builder = createGroupContext(group); + builder.setType(GroupContext.Type.UPDATE); + + SignalServiceAttachment avatar = group.getAvatar().orNull(); + List members = new LinkedList<>(); + + if (group.getMembers().isPresent()) { + for (SignalServiceAddress member : group.getMembers().get()) { + members.add(Recipient.externalGV1Member(context, member).getId()); + } + } + + database.create(id, group.getName().orNull(), members, + avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null); + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + + if (sender.isSystemContact() || sender.isProfileSharing()) { + Log.i(TAG, "Auto-enabling profile sharing because 'adder' is trusted. contact: " + sender.isSystemContact() + ", profileSharing: " + sender.isProfileSharing()); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.externalGroupExact(context, id).getId(), true); + } + + return storeMessage(context, content, group, builder.build(), outgoing); + } + + private static @Nullable Long handleGroupUpdate(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull SignalServiceGroup group, + @NonNull GroupRecord groupRecord, + boolean outgoing) + { + + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + GroupId.V1 id = GroupId.v1orThrow(group.getGroupId()); + + Set recordMembers = new HashSet<>(groupRecord.getMembers()); + Set messageMembers = new HashSet<>(); + + if (group.getMembers().isPresent()) { + for (SignalServiceAddress messageMember : group.getMembers().get()) { + messageMembers.add(Recipient.externalGV1Member(context, messageMember).getId()); + } + } + + Set addedMembers = new HashSet<>(messageMembers); + addedMembers.removeAll(recordMembers); + + Set missingMembers = new HashSet<>(recordMembers); + missingMembers.removeAll(messageMembers); + + GroupContext.Builder builder = createGroupContext(group); + builder.setType(GroupContext.Type.UPDATE); + + if (addedMembers.size() > 0) { + Set unionMembers = new HashSet<>(recordMembers); + unionMembers.addAll(messageMembers); + database.updateMembers(id, new LinkedList<>(unionMembers)); + + builder.clearMembers(); + builder.clearMembersE164(); + + for (RecipientId addedMember : addedMembers) { + Recipient recipient = Recipient.resolved(addedMember); + + if (recipient.getE164().isPresent()) { + builder.addMembersE164(recipient.requireE164()); + builder.addMembers(createMember(recipient.requireE164())); + } + } + } else { + builder.clearMembers(); + builder.clearMembersE164(); + } + + if (missingMembers.size() > 0) { + // TODO We should tell added and missing about each-other. + } + + if (group.getName().isPresent() || group.getAvatar().isPresent()) { + SignalServiceAttachment avatar = group.getAvatar().orNull(); + database.update(id, group.getName().orNull(), avatar != null ? avatar.asPointer() : null); + } + + if (group.getName().isPresent() && group.getName().get().equals(groupRecord.getTitle())) { + builder.clearName(); + } + + if (!groupRecord.isActive()) database.setActive(id, true); + + return storeMessage(context, content, group, builder.build(), outgoing); + } + + private static Long handleGroupInfoRequest(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull GroupRecord record) + { + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + + if (record.getMembers().contains(sender.getId())) { + ApplicationDependencies.getJobManager().add(new PushGroupUpdateJob(sender.getId(), record.getId())); + } + + return null; + } + + private static Long handleGroupLeave(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull SignalServiceGroup group, + @NonNull GroupRecord record, + boolean outgoing) + { + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + GroupId id = GroupId.v1orThrow(group.getGroupId()); + List members = record.getMembers(); + + GroupContext.Builder builder = createGroupContext(group); + builder.setType(GroupContext.Type.QUIT); + + RecipientId senderId = RecipientId.fromHighTrust(content.getSender()); + + if (members.contains(senderId)) { + database.remove(id, senderId); + if (outgoing) database.setActive(id, false); + + return storeMessage(context, content, group, builder.build(), outgoing); + } + + return null; + } + + + private static @Nullable Long storeMessage(@NonNull Context context, + @NonNull SignalServiceContent content, + @NonNull SignalServiceGroup group, + @NonNull GroupContext storage, + boolean outgoing) + { + if (group.getAvatar().isPresent()) { + ApplicationDependencies.getJobManager() + .add(new AvatarGroupsV1DownloadJob(GroupId.v1orThrow(group.getGroupId()))); + } + + try { + if (outgoing) { + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1orThrow(group.getGroupId())); + Recipient recipient = Recipient.resolved(recipientId); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); + + mmsDatabase.markAsSent(messageId, true); + + return threadId; + } else { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + String body = Base64.encodeBytes(storage.toByteArray()); + IncomingTextMessage incoming = new IncomingTextMessage(Recipient.externalHighTrustPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), content.getServerReceivedTimestamp(), body, Optional.of(GroupId.v1orThrow(group.getGroupId())), 0, content.isNeedsReceipt()); + IncomingGroupUpdateMessage groupMessage = new IncomingGroupUpdateMessage(incoming, storage, body); + + Optional insertResult = smsDatabase.insertMessageInbox(groupMessage); + + if (insertResult.isPresent()) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + return insertResult.get().getThreadId(); + } else { + return null; + } + } + } catch (MmsException e) { + Log.w(TAG, e); + } + + return null; + } + + private static GroupContext.Builder createGroupContext(SignalServiceGroup group) { + GroupContext.Builder builder = GroupContext.newBuilder(); + builder.setId(ByteString.copyFrom(group.getGroupId())); + + if (group.getAvatar().isPresent() && + group.getAvatar().get().isPointer() && + group.getAvatar().get().asPointer().getRemoteId().getV2().isPresent()) + { + builder.setAvatar(AttachmentPointer.newBuilder() + .setCdnId(group.getAvatar().get().asPointer().getRemoteId().getV2().get()) + .setKey(ByteString.copyFrom(group.getAvatar().get().asPointer().getKey())) + .setContentType(group.getAvatar().get().getContentType())); + } + + if (group.getName().isPresent()) { + builder.setName(group.getName().get()); + } + + if (group.getMembers().isPresent()) { + builder.addAllMembersE164(Stream.of(group.getMembers().get()) + .filter(a -> a.getNumber().isPresent()) + .map(a -> a.getNumber().get()) + .toList()); + builder.addAllMembers(Stream.of(group.getMembers().get()) + .filter(address -> address.getNumber().isPresent()) + .map(address -> address.getNumber().get()) + .map(GroupV1MessageProcessor::createMember) + .toList()); + } + + return builder; + } + + public static GroupContext.Member createMember(@NonNull String e164) { + GroupContext.Member.Builder member = GroupContext.Member.newBuilder(); + member.setE164(e164); + return member.build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java new file mode 100644 index 00000000..11b85771 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.GroupUtil; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; + +import static org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.LATEST; + +public final class GroupsV1MigrationUtil { + + private static final String TAG = Log.tag(GroupsV1MigrationUtil.class); + + private GroupsV1MigrationUtil() {} + + public static void migrate(@NonNull Context context, @NonNull RecipientId recipientId, boolean forced) + throws IOException, RetryLaterException, GroupChangeBusyException, InvalidMigrationStateException + { + Recipient groupRecipient = Recipient.resolved(recipientId); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + if (threadId == null) { + Log.w(TAG, "No thread found!"); + throw new InvalidMigrationStateException(); + } + + if (!groupRecipient.isPushV1Group()) { + Log.w(TAG, "Not a V1 group!"); + throw new InvalidMigrationStateException(); + } + + if (groupRecipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + Log.w(TAG, "Too many members! Size: " + groupRecipient.getParticipants().size()); + throw new InvalidMigrationStateException(); + } + + GroupId.V1 gv1Id = groupRecipient.requireGroupId().requireV1(); + GroupId.V2 gv2Id = gv1Id.deriveV2MigrationGroupId(); + GroupMasterKey gv2MasterKey = gv1Id.deriveV2MigrationMasterKey(); + boolean newlyCreated = false; + + if (groupDatabase.groupExists(gv2Id)) { + Log.w(TAG, "We already have a V2 group for this V1 group! Must have been added before we were migration-capable."); + throw new InvalidMigrationStateException(); + } + + if (!groupRecipient.isActiveGroup()) { + Log.w(TAG, "Group is inactive! Can't migrate."); + throw new InvalidMigrationStateException(); + } + + switch (GroupManager.v2GroupStatus(context, gv2MasterKey)) { + case DOES_NOT_EXIST: + Log.i(TAG, "Group does not exist on the service."); + + if (!groupRecipient.isProfileSharing()) { + Log.w(TAG, "Profile sharing is disabled! Can't migrate."); + throw new InvalidMigrationStateException(); + } + + if (!forced && SignalStore.internalValues().disableGv1AutoMigrateInitiation()) { + Log.w(TAG, "Auto migration initiation has been disabled! Skipping."); + throw new InvalidMigrationStateException(); + } + + List registeredMembers = RecipientUtil.getEligibleForSending(groupRecipient.getParticipants()); + + if (RecipientUtil.ensureUuidsAreAvailable(context, registeredMembers)) { + Log.i(TAG, "Newly-discovered UUIDs. Getting fresh recipients."); + registeredMembers = Stream.of(registeredMembers).map(Recipient::fresh).toList(); + } + + List possibleMembers = forced ? getMigratableManualMigrationMembers(registeredMembers) + : getMigratableAutoMigrationMembers(registeredMembers); + + if (!forced && !groupRecipient.hasName()) { + Log.w(TAG, "Group has no name. Skipping auto-migration."); + throw new InvalidMigrationStateException(); + } + + if (!forced && possibleMembers.size() != registeredMembers.size()) { + Log.w(TAG, "Not allowed to invite or leave registered users behind in an auto-migration! Skipping."); + throw new InvalidMigrationStateException(); + } + + Log.i(TAG, "Attempting to create group."); + + try { + GroupManager.migrateGroupToServer(context, gv1Id, possibleMembers); + newlyCreated = true; + Log.i(TAG, "Successfully created!"); + } catch (GroupChangeFailedException e) { + Log.w(TAG, "Failed to migrate group. Retrying.", e); + throw new RetryLaterException(); + } catch (MembershipNotSuitableForV2Exception e) { + Log.w(TAG, "Failed to migrate job due to the membership not yet being suitable for GV2. Aborting.", e); + return; + } catch (GroupAlreadyExistsException e) { + Log.w(TAG, "Someone else created the group while we were trying to do the same! It exists now. Continuing on.", e); + } + break; + case NOT_A_MEMBER: + Log.w(TAG, "The migrated group already exists, but we are not a member. Doing a local leave."); + handleLeftBehind(context, gv1Id, groupRecipient, threadId); + return; + case FULL_OR_PENDING_MEMBER: + Log.w(TAG, "The migrated group already exists, and we're in it. Continuing on."); + break; + default: throw new AssertionError(); + } + + Log.i(TAG, "Migrating local group " + gv1Id + " to " + gv2Id); + + DecryptedGroup decryptedGroup = performLocalMigration(context, gv1Id, threadId, groupRecipient); + + if (newlyCreated && decryptedGroup != null && !SignalStore.internalValues().disableGv1AutoMigrateNotification()) { + Log.i(TAG, "Sending no-op update to notify others."); + GroupManager.sendNoopUpdate(context, gv2MasterKey, decryptedGroup); + } + } + + public static void performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id) throws IOException + { + Log.i(TAG, "Beginning local migration! V1 ID: " + gv1Id, new Throwable()); + try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()) { + if (DatabaseFactory.getGroupDatabase(context).groupExists(gv1Id.deriveV2MigrationGroupId())) { + Log.w(TAG, "Group was already migrated! Could have been waiting for the lock.", new Throwable()); + return; + } + + Recipient recipient = Recipient.externalGroupExact(context, gv1Id); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + performLocalMigration(context, gv1Id, threadId, recipient); + Log.i(TAG, "Migration complete! (" + gv1Id + ", " + threadId + ", " + recipient.getId() + ")", new Throwable()); + } catch (GroupChangeBusyException e) { + throw new IOException(e); + } + } + + private static @Nullable DecryptedGroup performLocalMigration(@NonNull Context context, + @NonNull GroupId.V1 gv1Id, + long threadId, + @NonNull Recipient groupRecipient) + throws IOException, GroupChangeBusyException + { + Log.i(TAG, "performLocalMigration(" + gv1Id + ", " + threadId + ", " + groupRecipient.getId()); + + try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()){ + DecryptedGroup decryptedGroup; + try { + decryptedGroup = GroupManager.addedGroupVersion(context, gv1Id.deriveV2MigrationMasterKey()); + } catch (GroupDoesNotExistException e) { + throw new IOException("[Local] The group should exist already!"); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "[Local] We are not in the group. Doing a local leave."); + handleLeftBehind(context, gv1Id, groupRecipient, threadId); + return null; + } + + Log.i(TAG, "[Local] Migrating group over to the version we were added to: V" + decryptedGroup.getRevision()); + DatabaseFactory.getGroupDatabase(context).migrateToV2(threadId, gv1Id, decryptedGroup); + + Log.i(TAG, "[Local] Applying all changes since V" + decryptedGroup.getRevision()); + try { + GroupManager.updateGroupFromServer(context, gv1Id.deriveV2MigrationMasterKey(), LATEST, System.currentTimeMillis(), null); + } catch (GroupChangeBusyException | GroupNotAMemberException e) { + Log.w(TAG, e); + } + + return decryptedGroup; + } + } + + private static void handleLeftBehind(@NonNull Context context, @NonNull GroupId.V1 gv1Id, @NonNull Recipient groupRecipient, long threadId) { + OutgoingMediaMessage leaveMessage = GroupUtil.createGroupV1LeaveMessage(gv1Id, groupRecipient); + try { + long id = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(leaveMessage, threadId, false, null); + DatabaseFactory.getMmsDatabase(context).markAsSent(id, true); + } catch (MmsException e) { + Log.w(TAG, "Failed to insert group leave message!", e); + } + + DatabaseFactory.getGroupDatabase(context).setActive(gv1Id, false); + DatabaseFactory.getGroupDatabase(context).remove(gv1Id, Recipient.self().getId()); + } + + /** + * In addition to meeting traditional requirements, you must also have a profile key for a member + * to consider them migratable in an auto-migration. + */ + private static @NonNull List getMigratableAutoMigrationMembers(@NonNull List registeredMembers) { + return Stream.of(getMigratableManualMigrationMembers(registeredMembers)) + .filter(r -> r.getProfileKey() != null) + .toList(); + } + + /** + * You can only migrate users that have the required capabilities. + */ + private static @NonNull List getMigratableManualMigrationMembers(@NonNull List registeredMembers) { + return Stream.of(registeredMembers) + .filter(r -> r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED && + r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED) + .toList(); + } + + /** + * True if the user meets all the requirements to be auto-migrated, otherwise false. + */ + public static boolean isAutoMigratable(@NonNull Recipient recipient) { + return recipient.hasUuid() && + recipient.getGroupsV2Capability() == Recipient.Capability.SUPPORTED && + recipient.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED && + recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED && + recipient.getProfileKey() != null; + } + + public static final class InvalidMigrationStateException extends Exception { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java new file mode 100644 index 00000000..75a6e8a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.auth.AuthCredentialResponse; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class GroupsV2Authorization { + + private static final String TAG = Log.tag(GroupsV2Authorization.class); + + private final ValueCache cache; + private final GroupsV2Api groupsV2Api; + + public GroupsV2Authorization(@NonNull GroupsV2Api groupsV2Api, @NonNull ValueCache cache) { + this.groupsV2Api = groupsV2Api; + this.cache = cache; + } + + public GroupsV2AuthorizationString getAuthorizationForToday(@NonNull UUID self, + @NonNull GroupSecretParams groupSecretParams) + throws IOException, VerificationFailedException + { + final int today = currentTimeDays(); + + Map credentials = cache.read(); + + try { + return getAuthorization(self, groupSecretParams, credentials, today); + } catch (NoCredentialForRedemptionTimeException e) { + Log.i(TAG, "Auth out of date, will update auth and try again"); + cache.clear(); + } catch (VerificationFailedException e) { + Log.w(TAG, "Verification failed, will update auth and try again", e); + cache.clear(); + } + + Log.i(TAG, "Getting new auth credential responses"); + credentials = groupsV2Api.getCredentials(today); + cache.write(credentials); + + try { + return getAuthorization(self, groupSecretParams, credentials, today); + } catch (NoCredentialForRedemptionTimeException e) { + Log.w(TAG, "The credentials returned did not include the day requested"); + throw new IOException("Failed to get credentials"); + } + } + + private static int currentTimeDays() { + return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); + } + + private GroupsV2AuthorizationString getAuthorization(UUID self, + GroupSecretParams groupSecretParams, + Map credentials, + int today) + throws NoCredentialForRedemptionTimeException, VerificationFailedException + { + AuthCredentialResponse authCredentialResponse = credentials.get(today); + + if (authCredentialResponse == null) { + throw new NoCredentialForRedemptionTimeException(); + } + + return groupsV2Api.getGroupsV2AuthorizationString(self, today, groupSecretParams, authCredentialResponse); + } + + public interface ValueCache { + + void clear(); + + @NonNull Map read(); + + void write(@NonNull Map values); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java new file mode 100644 index 00000000..5d5ebf1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +import org.signal.zkgroup.auth.AuthCredentialResponse; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public final class GroupsV2AuthorizationMemoryValueCache implements GroupsV2Authorization.ValueCache { + + private final GroupsV2Authorization.ValueCache inner; + private Map values; + + public GroupsV2AuthorizationMemoryValueCache(@NonNull GroupsV2Authorization.ValueCache inner) { + this.inner = inner; + } + + @Override + public synchronized void clear() { + inner.clear(); + values = null; + } + + @Override + public @NonNull synchronized Map read() { + Map map = values; + + if (map == null) { + map = inner.read(); + values = map; + } + + return map; + } + + @Override + public synchronized void write(@NonNull Map values) { + inner.write(values); + this.values = Collections.unmodifiableMap(new HashMap<>(values)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java new file mode 100644 index 00000000..e3919b12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public final class GroupsV2CapabilityChecker { + + private static final String TAG = Log.tag(GroupsV2CapabilityChecker.class); + + private GroupsV2CapabilityChecker() {} + + /** + * @param resolved A collection of resolved recipients. + */ + @WorkerThread + public static void refreshCapabilitiesIfNecessary(@NonNull Collection resolved) throws IOException { + Set needsRefresh = Stream.of(resolved) + .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED) + .map(Recipient::getId) + .collect(Collectors.toSet()); + + if (needsRefresh.size() > 0) { + Log.d(TAG, "[refreshCapabilitiesIfNecessary] Need to refresh " + needsRefresh.size() + " recipients."); + + List jobs = RetrieveProfileJob.forRecipients(needsRefresh); + JobManager jobManager = ApplicationDependencies.getJobManager(); + + for (Job job : jobs) { + if (!jobManager.runSynchronously(job, TimeUnit.SECONDS.toMillis(10)).isPresent()) { + throw new IOException("Recipient capability was not retrieved in time"); + } + } + } + } + + @WorkerThread + static boolean allAndSelfHaveUuidAndSupportGroupsV2(@NonNull Collection recipientIds) + throws IOException + { + HashSet recipientIdsSet = new HashSet<>(recipientIds); + + recipientIdsSet.add(Recipient.self().getId()); + + return allHaveUuidAndSupportGroupsV2(recipientIdsSet); + } + + @WorkerThread + static boolean allHaveUuidAndSupportGroupsV2(@NonNull Collection recipientIds) + throws IOException + { + Set recipientIdsSet = new HashSet<>(recipientIds); + refreshCapabilitiesIfNecessary(Recipient.resolvedList(recipientIdsSet)); + + boolean noSelfGV2Support = false; + int noGv2Count = 0; + int noUuidCount = 0; + + for (RecipientId id : recipientIds) { + Recipient member = Recipient.resolved(id); + Recipient.Capability gv2Capability = member.getGroupsV2Capability(); + + if (gv2Capability != Recipient.Capability.SUPPORTED) { + Log.w(TAG, "At least one recipient does not support GV2, capability was " + gv2Capability); + + noGv2Count++; + if (member.isSelf()) { + noSelfGV2Support = true; + } + } + + if (!member.hasUuid()) { + noUuidCount++; + } + } + + if (noGv2Count + noUuidCount > 0) { + if (noUuidCount > 0) { + Log.w(TAG, noUuidCount + " recipient(s) did not have a UUID known to us"); + } + if (noGv2Count > 0) { + Log.w(TAG, noGv2Count + " recipient(s) do not support GV2"); + if (noSelfGV2Support) { + Log.w(TAG, "Self does not support GV2"); + } + } + return false; + } + + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java new file mode 100644 index 00000000..af0dc24d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.io.Closeable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +final class GroupsV2ProcessingLock { + + private static final String TAG = Log.tag(GroupsV2ProcessingLock.class); + + private GroupsV2ProcessingLock() { + } + + private static final Lock lock = new ReentrantLock(); + + @WorkerThread + static Closeable acquireGroupProcessingLock() throws GroupChangeBusyException { + return acquireGroupProcessingLock(5000); + } + + @WorkerThread + static Closeable acquireGroupProcessingLock(long timeoutMs) throws GroupChangeBusyException { + Util.assertNotMainThread(); + + try { + if (!lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new GroupChangeBusyException("Failed to get a lock on the group processing in the timeout period"); + } + return lock::unlock; + } catch (InterruptedException e) { + Log.w(TAG, e); + throw new GroupChangeBusyException(e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java new file mode 100644 index 00000000..12134be5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; +import android.content.res.Resources; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; + +import com.annimon.stream.ComparatorCompat; +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public final class LiveGroup { + + private static final Comparator LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isSelf(), m1.getMember().isSelf()); + private static final Comparator ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin()); + private static final Comparator HAS_DISPLAY_NAME = (m1, m2) -> Boolean.compare(m2.getMember().hasAUserSetDisplayName(ApplicationDependencies.getApplication()), m1.getMember().hasAUserSetDisplayName(ApplicationDependencies.getApplication())); + private static final Comparator ALPHABETICAL = (m1, m2) -> m1.getMember().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(m2.getMember().getDisplayName(ApplicationDependencies.getApplication())); + private static final Comparator MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST) + .thenComparing(ADMIN_FIRST) + .thenComparing(HAS_DISPLAY_NAME) + .thenComparing(ALPHABETICAL); + + private final GroupDatabase groupDatabase; + private final LiveData recipient; + private final LiveData groupRecord; + private final LiveData> fullMembers; + private final LiveData> requestingMembers; + private final LiveData groupLink; + + public LiveGroup(@NonNull GroupId groupId) { + Context context = ApplicationDependencies.getApplication(); + MutableLiveData liveRecipient = new MutableLiveData<>(); + + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); + this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient -> groupDatabase.getGroup(groupRecipient.getId()).orNull())); + this.fullMembers = mapToFullMembers(this.groupRecord); + this.requestingMembers = mapToRequestingMembers(this.groupRecord); + + if (groupId.isV2()) { + LiveData v2Properties = Transformations.map(this.groupRecord, GroupDatabase.GroupRecord::requireV2GroupProperties); + this.groupLink = Transformations.map(v2Properties, g -> { + DecryptedGroup group = g.getDecryptedGroup(); + AccessControl.AccessRequired addFromInviteLink = group.getAccessControl().getAddFromInviteLink(); + + if (group.getInviteLinkPassword().isEmpty()) { + return GroupLinkUrlAndStatus.NONE; + } + + boolean enabled = addFromInviteLink == AccessControl.AccessRequired.ANY || addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; + boolean adminApproval = addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; + String url = GroupInviteLinkUrl.forGroup(g.getGroupMasterKey(), group) + .getUrl(); + + return new GroupLinkUrlAndStatus(enabled, adminApproval, url); + }); + } else { + this.groupLink = new MutableLiveData<>(GroupLinkUrlAndStatus.NONE); + } + + SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroupExact(context, groupId).live())); + } + + protected static LiveData> mapToFullMembers(@NonNull LiveData groupRecord) { + return LiveDataUtil.mapAsync(groupRecord, + g -> Stream.of(g.getMembers()) + .map(m -> { + Recipient recipient = Recipient.resolved(m); + return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); + }) + .sorted(MEMBER_ORDER) + .toList()); + } + + protected static LiveData> mapToRequestingMembers(@NonNull LiveData groupRecord) { + return LiveDataUtil.mapAsync(groupRecord, + g -> { + if (!g.isV2Group()) { + return Collections.emptyList(); + } + + boolean selfAdmin = g.isAdmin(Recipient.self()); + List requestingMembersList = g.requireV2GroupProperties().getDecryptedGroup().getRequestingMembersList(); + + return Stream.of(requestingMembersList) + .map(requestingMember -> { + Recipient recipient = Recipient.externalPush(ApplicationDependencies.getApplication(), UuidUtil.fromByteString(requestingMember.getUuid()), null, false); + return new GroupMemberEntry.RequestingMember(recipient, selfAdmin); + }) + .toList(); + }); + } + + public LiveData getTitle() { + return LiveDataUtil.combineLatest(groupRecord, recipient, (groupRecord, recipient) -> { + String title = groupRecord.getTitle(); + if (!TextUtils.isEmpty(title)) { + return title; + } + return recipient.getDisplayName(ApplicationDependencies.getApplication()); + }); + } + + public LiveData getGroupRecipient() { + return recipient; + } + + public LiveData isSelfAdmin() { + return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self())); + } + + public LiveData isActive() { + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::isActive); + } + + public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { + return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId))); + } + + public LiveData getPendingMemberCount() { + return Transformations.map(groupRecord, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0); + } + + public LiveData getPendingAndRequestingMemberCount() { + return Transformations.map(groupRecord, g -> { + if (g.isV2Group()) { + DecryptedGroup decryptedGroup = g.requireV2GroupProperties().getDecryptedGroup(); + + return decryptedGroup.getPendingMembersCount() + decryptedGroup.getRequestingMembersCount(); + } + return 0; + }); + } + + public LiveData getMembershipAdditionAccessControl() { + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl); + } + + public LiveData getAttributesAccessControl() { + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getAttributesAccessControl); + } + + public LiveData> getNonAdminFullMembers() { + return Transformations.map(fullMembers, + members -> Stream.of(members) + .filterNot(GroupMemberEntry.FullMember::isAdmin) + .toList()); + } + + public LiveData> getFullMembers() { + return fullMembers; + } + + public LiveData> getRequestingMembers() { + return requestingMembers; + } + + public LiveData getExpireMessages() { + return Transformations.map(recipient, Recipient::getExpireMessages); + } + + public LiveData selfCanEditGroupAttributes() { + return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl); + } + + public LiveData selfCanAddMembers() { + return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl); + } + + /** + * A string representing the count of full members and pending members if > 0. + */ + public LiveData getMembershipCountDescription(@NonNull Resources resources) { + return LiveDataUtil.combineLatest(getFullMembers(), + getPendingMemberCount(), + (fullMembers, invitedCount) -> getMembershipDescription(resources, invitedCount, fullMembers.size())); + } + + /** + * A string representing the count of full members. + */ + public LiveData getFullMembershipCountDescription(@NonNull Resources resources) { + return Transformations.map(getFullMembers(), fullMembers -> getMembershipDescription(resources, 0, fullMembers.size())); + } + + public LiveData getMemberLevel(@NonNull Recipient recipient) { + return Transformations.map(groupRecord, g -> g.memberLevel(recipient)); + } + + private static String getMembershipDescription(@NonNull Resources resources, int invitedCount, int fullMemberCount) { + return invitedCount > 0 ? resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, fullMemberCount, + fullMemberCount, invitedCount) + : resources.getQuantityString(R.plurals.MessageRequestProfileView_members, fullMemberCount, + fullMemberCount); + } + + private LiveData selfMemberLevel() { + return Transformations.map(groupRecord, g -> g.memberLevel(Recipient.self())); + } + + private static boolean applyAccessControl(@NonNull GroupDatabase.MemberLevel memberLevel, @NonNull GroupAccessControl rights) { + switch (rights) { + case ALL_MEMBERS: return memberLevel.isInGroup(); + case ONLY_ADMINS: return memberLevel == GroupDatabase.MemberLevel.ADMINISTRATOR; + case NO_ONE : return false; + default: throw new AssertionError(); + } + } + + public LiveData getGroupLink() { + return groupLink; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java b/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java new file mode 100644 index 00000000..3f5c727a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.groups; + +public final class MembershipNotSuitableForV2Exception extends Exception { + public MembershipNotSuitableForV2Exception(String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectionLimits.java b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectionLimits.java new file mode 100644 index 00000000..eb91de2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectionLimits.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.groups; + +import android.os.Parcel; +import android.os.Parcelable; + +public final class SelectionLimits implements Parcelable { + private static final int NO_LIMIT = Integer.MAX_VALUE; + + public static final SelectionLimits NO_LIMITS = new SelectionLimits(NO_LIMIT, NO_LIMIT); + + private final int recommendedLimit; + private final int hardLimit; + + public SelectionLimits(int recommendedLimit, int hardLimit) { + this.recommendedLimit = recommendedLimit; + this.hardLimit = hardLimit; + } + + public int getRecommendedLimit() { + return recommendedLimit; + } + + public int getHardLimit() { + return hardLimit; + } + + public boolean hasRecommendedLimit() { + return recommendedLimit != NO_LIMIT; + } + + public boolean hasHardLimit() { + return hardLimit != NO_LIMIT; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(recommendedLimit); + dest.writeInt(hardLimit); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SelectionLimits createFromParcel(Parcel in) { + return new SelectionLimits(in.readInt(), in.readInt()); + } + + @Override + public SelectionLimits[] newArray(int size) { + return new SelectionLimits[size]; + } + }; + + public SelectionLimits excludingSelf() { + return excluding(1); + } + + public SelectionLimits excluding(int count) { + return new SelectionLimits(recommendedLimit - count, hardLimit - count); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java new file mode 100644 index 00000000..2e3c29a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +public interface AdminActionsListener { + + void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember); + + void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers); + + void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember); + + void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeErrorCallback.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeErrorCallback.java new file mode 100644 index 00000000..e9dab3dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeErrorCallback.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +public interface GroupChangeErrorCallback { + void onError(@NonNull GroupChangeFailureReason failureReason); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java new file mode 100644 index 00000000..1cf6c919 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; + +import java.io.IOException; + +public enum GroupChangeFailureReason { + NO_RIGHTS, + NOT_CAPABLE, + NOT_A_MEMBER, + BUSY, + NETWORK, + OTHER; + + public static @NonNull GroupChangeFailureReason fromException(@NonNull Exception e) { + if (e instanceof MembershipNotSuitableForV2Exception) return GroupChangeFailureReason.NOT_CAPABLE; + if (e instanceof IOException) return GroupChangeFailureReason.NETWORK; + if (e instanceof GroupNotAMemberException) return GroupChangeFailureReason.NOT_A_MEMBER; + if (e instanceof GroupChangeBusyException) return GroupChangeFailureReason.BUSY; + if (e instanceof GroupInsufficientRightsException) return GroupChangeFailureReason.NO_RIGHTS; + return GroupChangeFailureReason.OTHER; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeResult.java new file mode 100644 index 00000000..7ab9cd97 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeResult.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class GroupChangeResult { + + public final static GroupChangeResult SUCCESS = new GroupChangeResult(null); + + private final @Nullable GroupChangeFailureReason failureReason; + + GroupChangeResult(@Nullable GroupChangeFailureReason failureReason) { + this.failureReason = failureReason; + } + + public static GroupChangeResult failure(@NonNull GroupChangeFailureReason failureReason) { + return new GroupChangeResult(failureReason); + } + + public boolean isSuccess() { + return failureReason == null; + } + + public @NonNull GroupChangeFailureReason getFailureReason() { + if (isSuccess()) { + throw new UnsupportedOperationException(); + } + + return failureReason; + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java new file mode 100644 index 00000000..2c373d10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +public final class GroupErrors { + private GroupErrors() { + } + + public static @StringRes int getUserDisplayMessage(@Nullable GroupChangeFailureReason failureReason) { + if (failureReason == null) { + return R.string.ManageGroupActivity_failed_to_update_the_group; + } + + switch (failureReason) { + case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this; + case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable; + case NOT_A_MEMBER: return R.string.ManageGroupActivity_youre_not_a_member_of_the_group; + case BUSY : return R.string.ManageGroupActivity_failed_to_update_the_group_please_retry_later; + case NETWORK : return R.string.ManageGroupActivity_failed_to_update_the_group_due_to_a_network_error_please_retry_later; + default : return R.string.ManageGroupActivity_failed_to_update_the_group; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupLimitDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupLimitDialog.java new file mode 100644 index 00000000..35f06b58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupLimitDialog.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.groups.ui; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.FeatureFlags; + +public final class GroupLimitDialog { + + public static void showHardLimitMessage(@NonNull Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.ContactSelectionListFragment_maximum_group_size_reached) + .setMessage(context.getString(R.string.ContactSelectionListFragment_signal_groups_can_have_a_maximum_of_d_members, FeatureFlags.groupLimits().getHardLimit())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + public static void showRecommendedLimitMessage(@NonNull Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.ContactSelectionListFragment_recommended_member_limit_reached) + .setMessage(context.getString(R.string.ContactSelectionListFragment_signal_groups_perform_best_with_d_members_or_fewer, FeatureFlags.groupLimits().getRecommendedLimit())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java new file mode 100644 index 00000000..338edfc7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -0,0 +1,266 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; + +import org.signal.zkgroup.groups.UuidCiphertext; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; + +import java.util.Collection; +import java.util.Objects; + +public abstract class GroupMemberEntry { + + private final DefaultValueLiveData busy = new DefaultValueLiveData<>(false); + + private GroupMemberEntry() { + } + + public LiveData getBusy() { + return busy; + } + + public void setBusy(boolean busy) { + this.busy.postValue(busy); + } + + @Override + public abstract boolean equals(@Nullable Object obj); + + @Override + public abstract int hashCode(); + + abstract boolean sameId(@NonNull GroupMemberEntry newItem); + + public final static class NewGroupCandidate extends GroupMemberEntry { + + private final Recipient member; + + public NewGroupCandidate(@NonNull Recipient member) { + this.member = member; + } + + public @NonNull Recipient getMember() { + return member; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return member.getId().equals(((NewGroupCandidate) newItem).member.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof NewGroupCandidate)) return false; + + NewGroupCandidate other = (NewGroupCandidate) obj; + return other.member.equals(member); + } + + @Override + public int hashCode() { + return member.hashCode(); + } + } + + public final static class FullMember extends GroupMemberEntry { + + private final Recipient member; + private final boolean isAdmin; + + public FullMember(@NonNull Recipient member, boolean isAdmin) { + this.member = member; + this.isAdmin = isAdmin; + } + + public Recipient getMember() { + return member; + } + + public boolean isAdmin() { + return isAdmin; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return member.getId().equals(((GroupMemberEntry.FullMember) newItem).member.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof FullMember)) return false; + + FullMember other = (FullMember) obj; + return other.member.equals(member) && + other.isAdmin == isAdmin; + } + + @Override + public int hashCode() { + return member.hashCode() * 31 + (isAdmin ? 1 : 0); + } + } + + public final static class PendingMember extends GroupMemberEntry { + private final Recipient invitee; + private final UuidCiphertext inviteeCipherText; + private final boolean cancellable; + + public PendingMember(@NonNull Recipient invitee, @Nullable UuidCiphertext inviteeCipherText, boolean cancellable) { + this.invitee = invitee; + this.inviteeCipherText = inviteeCipherText; + this.cancellable = cancellable; + if (cancellable && inviteeCipherText == null) { + throw new IllegalArgumentException("inviteeCipherText must be supplied to enable cancellation"); + } + } + + public PendingMember(@NonNull Recipient invitee) { + this(invitee, null, false); + } + + public Recipient getInvitee() { + return invitee; + } + + public UuidCiphertext getInviteeCipherText() { + if (!cancellable) { + throw new UnsupportedOperationException(); + } + return inviteeCipherText; + } + + public boolean isCancellable() { + return cancellable; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return invitee.getId().equals(((GroupMemberEntry.PendingMember) newItem).invitee.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof PendingMember)) return false; + + PendingMember other = (PendingMember) obj; + return other.invitee.equals(invitee) && + Objects.equals(other.inviteeCipherText, inviteeCipherText) && + other.cancellable == cancellable; + } + + @Override + public int hashCode() { + int hash = invitee.hashCode(); + hash *= 31; + hash += Objects.hashCode(inviteeCipherText); + hash *= 31; + return hash + (cancellable ? 1 : 0); + } + } + + public final static class UnknownPendingMemberCount extends GroupMemberEntry { + private final Recipient inviter; + private final Collection ciphertexts; + private final boolean cancellable; + + public UnknownPendingMemberCount(@NonNull Recipient inviter, + @NonNull Collection ciphertexts, + boolean cancellable) { + this.inviter = inviter; + this.ciphertexts = ciphertexts; + this.cancellable = cancellable; + } + + public Recipient getInviter() { + return inviter; + } + + public int getInviteCount() { + return ciphertexts.size(); + } + + public Collection getCiphertexts() { + return ciphertexts; + } + + public boolean isCancellable() { + return cancellable; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return inviter.getId().equals(((GroupMemberEntry.UnknownPendingMemberCount) newItem).inviter.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof UnknownPendingMemberCount)) return false; + + UnknownPendingMemberCount other = (UnknownPendingMemberCount) obj; + return other.inviter.equals(inviter) && + other.ciphertexts.equals(ciphertexts) && + other.cancellable == cancellable; + } + + @Override + public int hashCode() { + int hash = inviter.hashCode(); + hash *= 31; + hash += ciphertexts.hashCode(); + hash *= 31; + return hash + (cancellable ? 1 : 0); + } + } + + public final static class RequestingMember extends GroupMemberEntry { + private final Recipient requester; + private final boolean approvableDeniable; + + public RequestingMember(@NonNull Recipient requester, boolean approvableDeniable) { + this.requester = requester; + this.approvableDeniable = approvableDeniable; + } + + public Recipient getRequester() { + return requester; + } + + public boolean isApprovableDeniable() { + return approvableDeniable; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return requester.getId().equals(((RequestingMember) newItem).requester.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof RequestingMember)) return false; + + RequestingMember other = (RequestingMember) obj; + return other.requester.equals(requester) && + other.approvableDeniable == approvableDeniable; + } + + @Override + public int hashCode() { + int hash = requester.hashCode(); + hash *= 31; + return hash + (approvableDeniable ? 1 : 0); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java new file mode 100644 index 00000000..ad01981e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java @@ -0,0 +1,499 @@ +package org.thoughtcrime.securesms.groups.ui; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.LifecycleRecyclerAdapter; +import org.thoughtcrime.securesms.util.LifecycleViewHolder; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class GroupMemberListAdapter extends LifecycleRecyclerAdapter { + + private static final int FULL_MEMBER = 0; + private static final int OWN_INVITE_PENDING = 1; + private static final int OTHER_INVITE_PENDING_COUNT = 2; + private static final int NEW_GROUP_CANDIDATE = 3; + private static final int REQUESTING_MEMBER = 4; + + private final List data = new ArrayList<>(); + private final Set selection = new HashSet<>(); + private final SelectionChangeListener selectionChangeListener = new SelectionChangeListener(); + + private final boolean selectable; + + @Nullable private AdminActionsListener adminActionsListener; + @Nullable private RecipientClickListener recipientClickListener; + @Nullable private RecipientLongClickListener recipientLongClickListener; + @Nullable private RecipientSelectionChangeListener recipientSelectionChangeListener; + + GroupMemberListAdapter(boolean selectable) { + this.selectable = selectable; + } + + void updateData(@NonNull List recipients) { + if (data.isEmpty()) { + data.addAll(recipients); + notifyDataSetChanged(); + } else { + DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallback(data, recipients)); + data.clear(); + data.addAll(recipients); + + if (!selection.isEmpty()) { + Set newSelection = new HashSet<>(); + for (GroupMemberEntry entry : recipients) { + if (selection.contains(entry)) { + newSelection.add(entry); + } + } + selection.clear(); + selection.addAll(newSelection); + if (recipientSelectionChangeListener != null) { + recipientSelectionChangeListener.onSelectionChanged(selection); + } + } + + diffResult.dispatchUpdatesTo(this); + } + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case FULL_MEMBER: + return new FullMemberViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.group_recipient_list_item, parent, false), + recipientClickListener, + recipientLongClickListener, + adminActionsListener, + selectionChangeListener); + case OWN_INVITE_PENDING: + return new OwnInvitePendingMemberViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.group_recipient_list_item, parent, false), + recipientClickListener, + recipientLongClickListener, + adminActionsListener, + selectionChangeListener); + case OTHER_INVITE_PENDING_COUNT: + return new UnknownPendingMemberCountViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.group_recipient_list_item, parent, false), + adminActionsListener, + selectionChangeListener); + case NEW_GROUP_CANDIDATE: + return new NewGroupInviteeViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.group_new_candidate_recipient_list_item, parent, false), + recipientClickListener, + recipientLongClickListener, + selectionChangeListener); + case REQUESTING_MEMBER: + return new RequestingMemberViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.group_recipient_requesting_list_item, parent, false), + recipientClickListener, + recipientLongClickListener, + adminActionsListener, + selectionChangeListener); + + default: + throw new AssertionError(); + } + } + + void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) { + this.adminActionsListener = adminActionsListener; + } + + void setRecipientClickListener(@Nullable RecipientClickListener recipientClickListener) { + this.recipientClickListener = recipientClickListener; + } + + void setRecipientLongClickListener(@Nullable RecipientLongClickListener recipientLongClickListener) { + this.recipientLongClickListener = recipientLongClickListener; + } + + void setRecipientSelectionChangeListener(@Nullable RecipientSelectionChangeListener recipientSelectionChangeListener) { + this.recipientSelectionChangeListener = recipientSelectionChangeListener; + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + GroupMemberEntry entry = data.get(position); + holder.bind(entry, selection.contains(entry)); + } + + @Override + public int getItemViewType(int position) { + GroupMemberEntry groupMemberEntry = data.get(position); + + if (groupMemberEntry instanceof GroupMemberEntry.FullMember) { + return FULL_MEMBER; + } else if (groupMemberEntry instanceof GroupMemberEntry.PendingMember) { + return OWN_INVITE_PENDING; + } else if (groupMemberEntry instanceof GroupMemberEntry.UnknownPendingMemberCount) { + return OTHER_INVITE_PENDING_COUNT; + } else if (groupMemberEntry instanceof GroupMemberEntry.NewGroupCandidate) { + return NEW_GROUP_CANDIDATE; + } else if (groupMemberEntry instanceof GroupMemberEntry.RequestingMember) { + return REQUESTING_MEMBER; + } + + throw new AssertionError(); + } + + @Override + public int getItemCount() { + return data.size(); + } + + static abstract class ViewHolder extends LifecycleViewHolder { + + final Context context; + final AvatarImageView avatar; + final TextView recipient; + final EmojiTextView about; + final CheckBox selected; + final PopupMenuView popupMenu; + final View popupMenuContainer; + final ProgressBar busyProgress; + @Nullable final View admin; + final SelectionChangeListener selectionChangeListener; + @Nullable final RecipientClickListener recipientClickListener; + @Nullable final AdminActionsListener adminActionsListener; + @Nullable final RecipientLongClickListener recipientLongClickListener; + + ViewHolder(@NonNull View itemView, + @Nullable RecipientClickListener recipientClickListener, + @Nullable RecipientLongClickListener recipientLongClickListener, + @Nullable AdminActionsListener adminActionsListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView); + + this.context = itemView.getContext(); + this.avatar = itemView.findViewById(R.id.recipient_avatar); + this.recipient = itemView.findViewById(R.id.recipient_name); + this.about = itemView.findViewById(R.id.recipient_about); + this.selected = itemView.findViewById(R.id.recipient_selected); + this.popupMenu = itemView.findViewById(R.id.popupMenu); + this.popupMenuContainer = itemView.findViewById(R.id.popupMenuProgressContainer); + this.busyProgress = itemView.findViewById(R.id.menuBusyProgress); + this.admin = itemView.findViewById(R.id.admin); + this.recipientClickListener = recipientClickListener; + this.recipientLongClickListener = recipientLongClickListener; + this.adminActionsListener = adminActionsListener; + this.selectionChangeListener = selectionChangeListener; + } + + void bindRecipient(@NonNull Recipient recipient) { + String displayName = recipient.isSelf() ? context.getString(R.string.GroupMembersDialog_you) + : recipient.getDisplayName(itemView.getContext()); + bindImageAndText(recipient, displayName, recipient.getCombinedAboutAndEmoji()); + } + + void bindImageAndText(@NonNull Recipient recipient, @NonNull String displayText, @Nullable String about) { + this.recipient.setText(displayText); + this.avatar.setRecipient(recipient); + + if (this.about != null) { + this.about.setText(about); + this.about.setVisibility(Util.isEmpty(about) ? View.GONE : View.VISIBLE); + } + } + + void bindRecipientClick(@NonNull Recipient recipient) { + if (recipient.equals(Recipient.self())) { + this.itemView.setEnabled(false); + return; + } + + this.itemView.setEnabled(true); + this.itemView.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + if (recipientClickListener != null) { + recipientClickListener.onClick(recipient); + } + if (selected != null) { + selectionChangeListener.onSelectionChange(getAdapterPosition(), !selected.isChecked()); + } + } + }); + this.itemView.setOnLongClickListener(v -> { + if (recipientLongClickListener != null && getAdapterPosition() != RecyclerView.NO_POSITION) { + return recipientLongClickListener.onLongClick(recipient); + } + return false; + }); + } + + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + busyProgress.setVisibility(View.GONE); + if (admin != null) { + admin.setVisibility(View.GONE); + } + hideMenu(); + + itemView.setOnClickListener(null); + + memberEntry.getBusy().observe(this, busy -> { + busyProgress.setVisibility(busy ? View.VISIBLE : View.GONE); + popupMenu.setVisibility(busy ? View.GONE : View.VISIBLE); + }); + + selected.setChecked(isSelected); + } + + void hideMenu() { + popupMenuContainer.setVisibility(View.GONE); + popupMenu.setVisibility(View.GONE); + } + + void showMenu() { + popupMenuContainer.setVisibility(View.VISIBLE); + popupMenu.setVisibility(View.VISIBLE); + } + } + + final static class FullMemberViewHolder extends ViewHolder { + + FullMemberViewHolder(@NonNull View itemView, + @Nullable RecipientClickListener recipientClickListener, + @Nullable RecipientLongClickListener recipientLongClickListener, + @Nullable AdminActionsListener adminActionsListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener); + } + + @Override + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + super.bind(memberEntry, isSelected); + + GroupMemberEntry.FullMember fullMember = (GroupMemberEntry.FullMember) memberEntry; + + bindRecipient(fullMember.getMember()); + bindRecipientClick(fullMember.getMember()); + if (admin != null) { + admin.setVisibility(fullMember.isAdmin() ? View.VISIBLE : View.INVISIBLE); + } + } + } + final static class NewGroupInviteeViewHolder extends ViewHolder { + + private final View smsContact; + private final View smsWarning; + + NewGroupInviteeViewHolder(@NonNull View itemView, + @Nullable RecipientClickListener recipientClickListener, + @Nullable RecipientLongClickListener recipientLongClickListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView, recipientClickListener, recipientLongClickListener, null, selectionChangeListener); + + smsContact = itemView.findViewById(R.id.sms_contact); + smsWarning = itemView.findViewById(R.id.sms_warning); + } + + @Override + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + GroupMemberEntry.NewGroupCandidate newGroupCandidate = (GroupMemberEntry.NewGroupCandidate) memberEntry; + + bindRecipient(newGroupCandidate.getMember()); + bindRecipientClick(newGroupCandidate.getMember()); + + int smsWarningVisibility = newGroupCandidate.getMember().isRegistered() ? View.GONE : View.VISIBLE; + + smsContact.setVisibility(smsWarningVisibility); + smsWarning.setVisibility(smsWarningVisibility); + } + } + + final static class OwnInvitePendingMemberViewHolder extends ViewHolder { + + OwnInvitePendingMemberViewHolder(@NonNull View itemView, + @Nullable RecipientClickListener recipientClickListener, + @Nullable RecipientLongClickListener recipientLongClickListener, + @Nullable AdminActionsListener adminActionsListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener); + } + + @Override + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + super.bind(memberEntry, isSelected); + + GroupMemberEntry.PendingMember pendingMember = (GroupMemberEntry.PendingMember) memberEntry; + + bindImageAndText(pendingMember.getInvitee(), pendingMember.getInvitee().getDisplayNameOrUsername(context), pendingMember.getInvitee().getCombinedAboutAndEmoji()); + bindRecipientClick(pendingMember.getInvitee()); + + if (pendingMember.isCancellable() && adminActionsListener != null) { + popupMenu.setMenu(R.menu.own_invite_pending_menu, + item -> { + if (item == R.id.revoke_invite) { + adminActionsListener.onRevokeInvite(pendingMember); + return true; + } + return false; + }); + showMenu(); + } + } + } + + final static class UnknownPendingMemberCountViewHolder extends ViewHolder { + + UnknownPendingMemberCountViewHolder(@NonNull View itemView, + @Nullable AdminActionsListener adminActionsListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView, null, null, adminActionsListener, selectionChangeListener); + } + + @Override + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + super.bind(memberEntry, isSelected); + GroupMemberEntry.UnknownPendingMemberCount pendingMembers = (GroupMemberEntry.UnknownPendingMemberCount) memberEntry; + + Recipient inviter = pendingMembers.getInviter(); + String displayName = inviter.getDisplayName(itemView.getContext()); + String displayText = context.getResources().getQuantityString(R.plurals.GroupMemberList_invited, + pendingMembers.getInviteCount(), + displayName, pendingMembers.getInviteCount()); + + bindImageAndText(inviter, displayText, inviter.getAbout()); + + if (pendingMembers.isCancellable() && adminActionsListener != null) { + popupMenu.setMenu(R.menu.others_invite_pending_menu, + item -> { + if (item.getItemId() == R.id.revoke_invites) { + item.setTitle(context.getResources().getQuantityString(R.plurals.PendingMembersActivity_revoke_d_invites, pendingMembers.getInviteCount(), + pendingMembers.getInviteCount())); + return true; + } + return true; + }, + item -> { + if (item == R.id.revoke_invites) { + adminActionsListener.onRevokeAllInvites(pendingMembers); + return true; + } + return false; + }); + showMenu(); + } + } + } + + final static class RequestingMemberViewHolder extends ViewHolder { + + private final View approveRequest; + private final View denyRequest; + + RequestingMemberViewHolder(@NonNull View itemView, + @Nullable RecipientClickListener recipientClickListener, + @Nullable RecipientLongClickListener recipientLongClickListener, + @Nullable AdminActionsListener adminActionsListener, + @NonNull SelectionChangeListener selectionChangeListener) + { + super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener); + + approveRequest = itemView.findViewById(R.id.request_approve); + denyRequest = itemView.findViewById(R.id.request_deny); + } + + @Override + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { + super.bind(memberEntry, isSelected); + + GroupMemberEntry.RequestingMember requestingMember = (GroupMemberEntry.RequestingMember) memberEntry; + + if (adminActionsListener != null && requestingMember.isApprovableDeniable()) { + approveRequest.setVisibility(View.VISIBLE); + denyRequest .setVisibility(View.VISIBLE); + approveRequest.setOnClickListener(v -> adminActionsListener.onApproveRequest(requestingMember)); + denyRequest .setOnClickListener(v -> adminActionsListener.onDenyRequest (requestingMember)); + } else { + approveRequest.setVisibility(View.GONE); + denyRequest .setVisibility(View.GONE); + approveRequest.setOnClickListener(null); + denyRequest .setOnClickListener(null); + } + + bindRecipient(requestingMember.getRequester()); + bindRecipientClick(requestingMember.getRequester()); + } + } + + private final class SelectionChangeListener { + void onSelectionChange(int position, boolean isChecked) { + if (selectable) { + if (isChecked) { + selection.add(data.get(position)); + } else { + selection.remove(data.get(position)); + } + notifyItemChanged(position); + + if (recipientSelectionChangeListener != null) { + recipientSelectionChangeListener.onSelectionChanged(selection); + } + } + } + } + + private final static class DiffCallback extends DiffUtil.Callback { + private final List oldData; + private final List newData; + + DiffCallback(List oldData, List newData) { + this.oldData = oldData; + this.newData = newData; + } + + @Override + public int getOldListSize() { + return oldData.size(); + } + + @Override + public int getNewListSize() { + return newData.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + GroupMemberEntry oldItem = oldData.get(oldItemPosition); + GroupMemberEntry newItem = newData.get(newItemPosition); + + return oldItem.sameId(newItem); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + GroupMemberEntry oldItem = oldData.get(oldItemPosition); + GroupMemberEntry newItem = newData.get(newItemPosition); + + return oldItem.equals(newItem); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java new file mode 100644 index 00000000..0188c42f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.groups.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +public final class GroupMemberListView extends RecyclerView { + + private GroupMemberListAdapter membersAdapter; + private int maxHeight; + + public GroupMemberListView(@NonNull Context context) { + super(context); + initialize(context, null); + } + + public GroupMemberListView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs); + } + + public GroupMemberListView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs); + } + + private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) { + if (maxHeight > 0) { + setHasFixedSize(true); + } + + boolean selectable = false; + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.GroupMemberListView, 0, 0); + try { + maxHeight = typedArray.getDimensionPixelSize(R.styleable.GroupMemberListView_maxHeight, 0); + selectable = typedArray.getBoolean(R.styleable.GroupMemberListView_selectable, false); + } finally { + typedArray.recycle(); + } + } + + membersAdapter = new GroupMemberListAdapter(selectable); + setLayoutManager(new LinearLayoutManager(context)); + setAdapter(membersAdapter); + } + + public void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) { + membersAdapter.setAdminActionsListener(adminActionsListener); + } + + public void setRecipientClickListener(@Nullable RecipientClickListener listener) { + membersAdapter.setRecipientClickListener(listener); + } + + public void setRecipientLongClickListener(@Nullable RecipientLongClickListener listener) { + membersAdapter.setRecipientLongClickListener(listener); + } + + public void setRecipientSelectionChangeListener(@Nullable RecipientSelectionChangeListener listener) { + membersAdapter.setRecipientSelectionChangeListener(listener); + } + + public void setMembers(@NonNull List recipients) { + membersAdapter.updateData(recipients); + } + + public void setDisplayOnlyMembers(@NonNull List recipients) { + membersAdapter.updateData(Stream.of(recipients).map(r -> new GroupMemberEntry.FullMember(r, false)).toList()); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + if (maxHeight > 0) { + heightSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); + } + super.onMeasure(widthSpec, heightSpec); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java new file mode 100644 index 00000000..d27fe0f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.groups.ui; + +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.chooseadmin.ChooseNewAdminActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.io.IOException; +import java.util.List; + +public final class LeaveGroupDialog { + + private static final String TAG = Log.tag(LeaveGroupDialog.class); + + @NonNull private final FragmentActivity activity; + @NonNull private final GroupId.Push groupId; + @Nullable private final Runnable onSuccess; + + public static void handleLeavePushGroup(@NonNull FragmentActivity activity, + @NonNull GroupId.Push groupId, + @Nullable Runnable onSuccess) { + new LeaveGroupDialog(activity, groupId, onSuccess).show(); + } + + private LeaveGroupDialog(@NonNull FragmentActivity activity, + @NonNull GroupId.Push groupId, + @Nullable Runnable onSuccess) { + this.activity = activity; + this.groupId = groupId; + this.onSuccess = onSuccess; + } + + public void show() { + if (!groupId.isV2()) { + showLeaveDialog(); + return; + } + + SimpleTask.run(activity.getLifecycle(), () -> { + GroupDatabase.V2GroupProperties groupProperties = DatabaseFactory.getGroupDatabase(activity) + .getGroup(groupId) + .transform(GroupDatabase.GroupRecord::requireV2GroupProperties) + .orNull(); + + if (groupProperties != null && groupProperties.isAdmin(Recipient.self())) { + List otherMemberRecipients = groupProperties.getMemberRecipients(GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + long otherAdminsCount = Stream.of(otherMemberRecipients).filter(groupProperties::isAdmin).count(); + + return otherAdminsCount == 0 && !otherMemberRecipients.isEmpty(); + } + + return false; + }, mustSelectNewAdmin -> { + if (mustSelectNewAdmin) { + showSelectNewAdminDialog(); + } else { + showLeaveDialog(); + } + }); + } + + private void showSelectNewAdminDialog() { + new AlertDialog.Builder(activity) + .setTitle(R.string.LeaveGroupDialog_choose_new_admin) + .setMessage(R.string.LeaveGroupDialog_before_you_leave_you_must_choose_at_least_one_new_admin_for_this_group) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.LeaveGroupDialog_choose_admin, (d,w) -> activity.startActivity(ChooseNewAdminActivity.createIntent(activity, groupId.requireV2()))) + .show(); + } + + private void showLeaveDialog() { + new AlertDialog.Builder(activity) + .setTitle(R.string.LeaveGroupDialog_leave_group) + .setCancelable(true) + .setMessage(R.string.LeaveGroupDialog_you_will_no_longer_be_able_to_send_or_receive_messages_in_this_group) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.LeaveGroupDialog_leave, (dialog, which) -> { + AlertDialog progressDialog = SimpleProgressDialog.show(activity); + SimpleTask.run(activity.getLifecycle(), this::leaveGroup, result -> { + progressDialog.dismiss(); + handleLeaveGroupResult(result); + }); + }) + .show(); + } + + private @NonNull GroupChangeResult leaveGroup() { + try { + GroupManager.leaveGroup(activity, groupId); + return GroupChangeResult.SUCCESS; + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + return GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)); + } + } + + private void handleLeaveGroupResult(@NonNull GroupChangeResult result) { + if (result.isSuccess()) { + if (onSuccess != null) onSuccess.run(); + } else { + Toast.makeText(activity, GroupErrors.getUserDisplayMessage(result.getFailureReason()), Toast.LENGTH_LONG).show(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java new file mode 100644 index 00000000..2ceb6552 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.groups.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.IdRes; +import androidx.annotation.MenuRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.R; + +import java.util.Objects; + +public final class PopupMenuView extends View { + + @MenuRes private int menu; + @Nullable private PrepareOptionsMenuItem prepareOptionsMenuItemCallback; + @Nullable private ItemClick callback; + + public PopupMenuView(Context context) { + super(context); + init(null); + } + + public PopupMenuView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public PopupMenuView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + setBackgroundResource(R.drawable.ic_more_vert_24); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.PopupMenuView, 0, 0); + int tint = typedArray.getColor(R.styleable.PopupMenuView_background_tint, Color.BLACK); + Drawable drawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_more_vert_24); + + DrawableCompat.setTint(Objects.requireNonNull(drawable), tint); + + setBackground(drawable); + + typedArray.recycle(); + } + + setOnClickListener(v -> { + if (callback != null) { + PopupMenu popup = new PopupMenu(getContext(), v); + MenuInflater inflater = popup.getMenuInflater(); + + inflater.inflate(menu, popup.getMenu()); + + if (prepareOptionsMenuItemCallback != null) { + Menu menu = popup.getMenu(); + for (int i = menu.size() - 1; i >= 0; i--) { + MenuItem item = menu.getItem(i); + if (!prepareOptionsMenuItemCallback.onPrepareOptionsMenuItem(item)) { + menu.removeItem(item.getItemId()); + } + } + } + + popup.setOnMenuItemClickListener(item -> callback.onItemClick(item.getItemId())); + popup.show(); + } + }); + } + + public void setMenu(@MenuRes int menu, @NonNull ItemClick callback) { + this.menu = menu; + this.prepareOptionsMenuItemCallback = null; + this.callback = callback; + } + + public void setMenu(@MenuRes int menu, @NonNull PrepareOptionsMenuItem prepareOptionsMenuItem, @NonNull ItemClick callback) { + this.menu = menu; + this.prepareOptionsMenuItemCallback = prepareOptionsMenuItem; + this.callback = callback; + } + + public interface PrepareOptionsMenuItem { + + /** + * Chance to change the {@link MenuItem} after inflation. + * + * @return true to keep the {@link MenuItem}. false to remove the {@link MenuItem}. + */ + boolean onPrepareOptionsMenuItem(@NonNull MenuItem menuItem); + } + + public interface ItemClick { + boolean onItemClick(@IdRes int menuItemId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java new file mode 100644 index 00000000..d990d501 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public interface RecipientClickListener { + void onClick(@NonNull Recipient recipient); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java new file mode 100644 index 00000000..b0d2a73b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public interface RecipientLongClickListener { + boolean onLongClick(@NonNull Recipient recipient); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java new file mode 100644 index 00000000..300059ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import java.util.Set; + +public interface RecipientSelectionChangeListener { + void onSelectionChanged(@NonNull Set selection); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java new file mode 100644 index 00000000..82326993 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.PushContactSelectionActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +public class AddMembersActivity extends PushContactSelectionActivity { + + public static final String GROUP_ID = "group_id"; + + private View done; + private AddMembersViewModel viewModel; + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + getIntent().putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_members_activity); + super.onCreate(icicle, ready); + + AddMembersViewModel.Factory factory = new AddMembersViewModel.Factory(getGroupId()); + + done = findViewById(R.id.done); + viewModel = ViewModelProviders.of(this, factory) + .get(AddMembersViewModel.class); + + done.setOnClickListener(v -> + viewModel.getDialogStateForSelectedContacts(contactsFragment.getSelectedContacts(), this::displayAlertMessage) + ); + + disableDone(); + } + + @Override + protected void initializeToolbar() { + getToolbar().setNavigationIcon(R.drawable.ic_arrow_left_24); + getToolbar().setNavigationOnClickListener(v -> { + setResult(RESULT_CANCELED); + finish(); + }); + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).hasE164()) { + Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show(); + return false; + } + + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + enableDone(); + + return true; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + if (contactsFragment.getSelectedContactsCount() < 1) { + disableDone(); + } + } + + private void enableDone() { + done.setEnabled(true); + done.animate().alpha(1f); + } + + private void disableDone() { + done.setEnabled(false); + done.animate().alpha(0.5f); + } + + private GroupId getGroupId() { + return GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID)); + } + + private void displayAlertMessage(@NonNull AddMembersViewModel.AddMemberDialogMessageState state) { + Recipient recipient = Util.firstNonNull(state.getRecipient(), Recipient.UNKNOWN); + + String message = getResources().getQuantityString(R.plurals.AddMembersActivity__add_d_members_to_s, state.getSelectionCount(), + recipient.getDisplayName(this), state.getGroupTitle(), state.getSelectionCount()); + + new AlertDialog.Builder(this) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) + .setPositiveButton(R.string.AddMembersActivity__add, (dialog, which) -> { + dialog.dismiss(); + onFinishedSelection(); + }) + .setCancelable(true) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java new file mode 100644 index 00000000..55a3dd1b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.RecipientId; + +final class AddMembersRepository { + + private final Context context; + private final GroupId groupId; + + AddMembersRepository(@NonNull GroupId groupId) { + this.groupId = groupId; + this.context = ApplicationDependencies.getApplication(); + } + + @WorkerThread + RecipientId getOrCreateRecipientId(@NonNull SelectedContact selectedContact) { + return selectedContact.getOrCreateRecipientId(context); + } + + @WorkerThread + String getGroupTitle() { + return DatabaseFactory.getGroupDatabase(context).requireGroup(groupId).getTitle(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java new file mode 100644 index 00000000..7076386b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.List; +import java.util.Objects; + +public final class AddMembersViewModel extends ViewModel { + + private final AddMembersRepository repository; + + private AddMembersViewModel(@NonNull GroupId groupId) { + this.repository = new AddMembersRepository(groupId); + } + + void getDialogStateForSelectedContacts(@NonNull List selectedContacts, + @NonNull Consumer callback) + { + SimpleTask.run( + () -> { + AddMemberDialogMessageStatePartial partialState = selectedContacts.size() == 1 ? getDialogStateForSingleRecipient(selectedContacts.get(0)) + : getDialogStateForMultipleRecipients(selectedContacts.size()); + + return new AddMemberDialogMessageState(partialState.recipientId == null ? Recipient.UNKNOWN : Recipient.resolved(partialState.recipientId), + partialState.memberCount, titleOrDefault(repository.getGroupTitle())); + }, + callback::accept + ); + } + + @WorkerThread + private AddMemberDialogMessageStatePartial getDialogStateForSingleRecipient(@NonNull SelectedContact selectedContact) { + return new AddMemberDialogMessageStatePartial(repository.getOrCreateRecipientId(selectedContact)); + } + + private AddMemberDialogMessageStatePartial getDialogStateForMultipleRecipients(int recipientCount) { + return new AddMemberDialogMessageStatePartial(recipientCount); + } + + private static @NonNull String titleOrDefault(@Nullable String title) { + return TextUtils.isEmpty(title) ? ApplicationDependencies.getApplication().getString(R.string.Recipient_unknown) + : Objects.requireNonNull(title); + } + + private static final class AddMemberDialogMessageStatePartial { + private final RecipientId recipientId; + private final int memberCount; + + private AddMemberDialogMessageStatePartial(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + this.memberCount = 1; + } + + private AddMemberDialogMessageStatePartial(int memberCount) { + Preconditions.checkArgument(memberCount > 1); + this.memberCount = memberCount; + this.recipientId = null; + } + } + + public static final class AddMemberDialogMessageState { + private final Recipient recipient; + private final String groupTitle; + private final int selectionCount; + + private AddMemberDialogMessageState(@Nullable Recipient recipient, int selectionCount, @NonNull String groupTitle) { + this.recipient = recipient; + this.groupTitle = groupTitle; + this.selectionCount = selectionCount; + } + + public Recipient getRecipient() { + return recipient; + } + + public int getSelectionCount() { + return selectionCount; + } + + public @NonNull String getGroupTitle() { + return groupTitle; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final GroupId groupId; + + public Factory(@NonNull GroupId groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new AddMembersViewModel(groupId))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupRepository.java new file mode 100644 index 00000000..00a23b2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupRepository.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.groups.ui.addtogroup; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.Collections; + +final class AddToGroupRepository { + + private static final String TAG = Log.tag(AddToGroupRepository.class); + + private final Context context; + + AddToGroupRepository() { + this.context = ApplicationDependencies.getApplication(); + } + + public void add(@NonNull RecipientId recipientId, + @NonNull Recipient groupRecipient, + @NonNull GroupChangeErrorCallback error, + @NonNull Runnable success) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupId.Push pushGroupId = groupRecipient.requireGroupId().requirePush(); + + GroupManager.addMembers(context, pushGroupId, Collections.singletonList(recipientId)); + + success.run(); + } catch (GroupChangeException | MembershipNotSuitableForV2Exception | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupViewModel.java new file mode 100644 index 00000000..cf998a73 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupViewModel.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.groups.ui.addtogroup; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +import java.util.List; +import java.util.Objects; + +public final class AddToGroupViewModel extends ViewModel { + + private final Application context; + private final AddToGroupRepository repository; + private final RecipientId recipientId; + private final SingleLiveEvent events = new SingleLiveEvent<>(); + + private AddToGroupViewModel(@NonNull RecipientId recipientId) { + this.context = ApplicationDependencies.getApplication(); + this.recipientId = recipientId; + this.repository = new AddToGroupRepository(); + } + + public SingleLiveEvent getEvents() { + return events; + } + + void onContinueWithSelection(@NonNull List groupRecipientIds) { + if (groupRecipientIds.isEmpty()) { + events.postValue(new Event.CloseEvent()); + } else if (groupRecipientIds.size() == 1) { + SignalExecutors.BOUNDED.execute(() -> { + Recipient recipient = Recipient.resolved(recipientId); + Recipient groupRecipient = Recipient.resolved(groupRecipientIds.get(0)); + String recipientName = recipient.getDisplayName(context); + String groupName = groupRecipient.getDisplayName(context); + + if (groupRecipient.getGroupId().get().isV1() && !recipient.hasE164()) { + events.postValue(new Event.LegacyGroupDenialEvent()); + } else { + events.postValue(new Event.AddToSingleGroupConfirmationEvent(context.getResources().getString(R.string.AddToGroupActivity_add_member), + context.getResources().getString(R.string.AddToGroupActivity_add_s_to_s, recipientName, groupName), + groupRecipient, recipientName, groupName)); + } + }); + } else { + throw new AssertionError("Does not support multi-select"); + } + } + + void onAddToGroupsConfirmed(@NonNull Event.AddToSingleGroupConfirmationEvent event) { + repository.add(recipientId, + event.groupRecipient, + error -> events.postValue(new Event.ToastEvent(context.getResources().getString(GroupErrors.getUserDisplayMessage(error)))), + () -> { + events.postValue(new Event.ToastEvent(context.getResources().getString(R.string.AddToGroupActivity_s_added_to_s, event.recipientName, event.groupName))); + events.postValue(new Event.CloseEvent()); + }); + } + + static abstract class Event { + + static class CloseEvent extends Event { + } + + static class ToastEvent extends Event { + private final String message; + + ToastEvent(@NonNull String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + } + + static class AddToSingleGroupConfirmationEvent extends Event { + private final String title; + private final String message; + private final Recipient groupRecipient; + private final String recipientName; + private final String groupName; + + AddToSingleGroupConfirmationEvent(@NonNull String title, + @NonNull String message, + @NonNull Recipient groupRecipient, + @NonNull String recipientName, + @NonNull String groupName) + { + this.title = title; + this.message = message; + this.groupRecipient = groupRecipient; + this.recipientName = recipientName; + this.groupName = groupName; + } + + String getTitle() { + return title; + } + + String getMessage() { + return message; + } + } + + static class LegacyGroupDenialEvent extends Event { + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new AddToGroupViewModel(recipientId))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java new file mode 100644 index 00000000..7c899f03 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.groups.ui.addtogroup; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Group selection activity, will add a single member to selected groups. + */ +public final class AddToGroupsActivity extends ContactSelectionActivity { + + private static final int MINIMUM_GROUP_SELECT_SIZE = 1; + + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + + private View next; + private AddToGroupViewModel viewModel; + + public static Intent newIntent(@NonNull Context context, + @NonNull RecipientId recipientId, + @NonNull List currentGroupsMemberOf) + { + Intent intent = new Intent(context, AddToGroupsActivity.class); + + intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false); + intent.putExtra(ContactSelectionListFragment.RECENTS, true); + intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_to_group_activity); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS); + + intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(currentGroupsMemberOf)); + + return intent; + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + super.onCreate(bundle, ready); + + Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + + next = findViewById(R.id.next); + + getToolbar().setHint(contactsFragment.isMulti() ? R.string.AddToGroupActivity_add_to_groups : R.string.AddToGroupActivity_add_to_group); + + next.setVisibility(contactsFragment.isMulti() ? View.VISIBLE : View.GONE); + + disableNext(); + next.setOnClickListener(v -> handleNextPressed()); + + AddToGroupViewModel.Factory factory = new AddToGroupViewModel.Factory(getRecipientId()); + viewModel = ViewModelProviders.of(this, factory) + .get(AddToGroupViewModel.class); + + + viewModel.getEvents().observe(this, event -> { + if (event instanceof Event.CloseEvent) { + finish(); + } else if (event instanceof Event.ToastEvent) { + Toast.makeText(this, ((Event.ToastEvent) event).getMessage(), Toast.LENGTH_SHORT).show(); + } else if (event instanceof Event.AddToSingleGroupConfirmationEvent) { + Event.AddToSingleGroupConfirmationEvent addEvent = (Event.AddToSingleGroupConfirmationEvent) event; + new AlertDialog.Builder(this) + .setTitle(addEvent.getTitle()) + .setMessage(addEvent.getMessage()) + .setPositiveButton(R.string.AddToGroupActivity_add, (dialog, which) -> viewModel.onAddToGroupsConfirmed(addEvent)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else if (event instanceof Event.LegacyGroupDenialEvent) { + Toast.makeText(this, R.string.AddToGroupActivity_this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show(); + } else { + throw new AssertionError(); + } + }); + } + + private @NonNull RecipientId getRecipientId() { + return getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + if (contactsFragment.isMulti()) { + throw new UnsupportedOperationException("Not yet built to handle multi-select."); +// if (contactsFragment.hasQueryFilter()) { +// getToolbar().clear(); +// } +// +// if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SELECT_SIZE) { +// enableNext(); +// } + } else { + if (recipientId.isPresent()) { + viewModel.onContinueWithSelection(Collections.singletonList(recipientId.get())); + } + } + + return true; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + if (contactsFragment.getSelectedContactsCount() < MINIMUM_GROUP_SELECT_SIZE) { + disableNext(); + } + } + + private void enableNext() { + next.setEnabled(true); + next.animate().alpha(1f); + } + + private void disableNext() { + next.setEnabled(false); + next.animate().alpha(0.5f); + } + + private void handleNextPressed() { + List groupsRecipientIds = Stream.of(contactsFragment.getSelectedContacts()) + .map(selectedContact -> selectedContact.getOrCreateRecipientId(this)) + .toList(); + + viewModel.onContinueWithSelection(groupsRecipientIds); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java new file mode 100644 index 00000000..7820d734 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java @@ -0,0 +1,124 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +import java.util.Objects; + +public final class ChooseNewAdminActivity extends PassphraseRequiredActivity { + + private static final String EXTRA_GROUP_ID = "group_id"; + + private ChooseNewAdminViewModel viewModel; + private GroupMemberListView groupList; + private CircularProgressButton done; + private GroupId.V2 groupId; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent createIntent(@NonNull Context context, @NonNull GroupId.V2 groupId) { + Intent intent = new Intent(context, ChooseNewAdminActivity.class); + intent.putExtra(EXTRA_GROUP_ID, groupId.toString()); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.choose_new_admin_activity); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + //noinspection ConstantConditions + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + try { + groupId = GroupId.parse(Objects.requireNonNull(getIntent().getStringExtra(EXTRA_GROUP_ID))).requireV2(); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + + groupList = findViewById(R.id.choose_new_admin_group_list); + done = findViewById(R.id.choose_new_admin_done); + done.setIndeterminateProgressMode(true); + + initializeViewModel(); + + groupList.setRecipientSelectionChangeListener(selection -> viewModel.setSelection(Stream.of(selection) + .select(GroupMemberEntry.FullMember.class) + .collect(Collectors.toSet()))); + + done.setOnClickListener(v -> { + done.setClickable(false); + done.setProgress(50); + viewModel.updateAdminsAndLeave(this::handleUpdateAndLeaveResult); + }); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void initializeViewModel() { + viewModel = ViewModelProviders.of(this, new ChooseNewAdminViewModel.Factory(groupId)).get(ChooseNewAdminViewModel.class); + + viewModel.getNonAdminFullMembers().observe(this, groupList::setMembers); + viewModel.getSelection().observe(this, selection -> done.setVisibility(selection.isEmpty() ? View.GONE : View.VISIBLE)); + } + + private void handleUpdateAndLeaveResult(@NonNull GroupChangeResult updateResult) { + if (updateResult.isSuccess()) { + String title = Recipient.externalGroupExact(this, groupId).getDisplayName(this); + Toast.makeText(this, getString(R.string.ChooseNewAdminActivity_you_left, title), Toast.LENGTH_LONG).show(); + startActivity(MainActivity.clearTop(this)); + finish(); + } else { + done.setClickable(true); + done.setProgress(0); + //noinspection ConstantConditions + Toast.makeText(this, GroupErrors.getUserDisplayMessage(updateResult.getFailureReason()), Toast.LENGTH_LONG).show(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java new file mode 100644 index 00000000..4aa16590 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.List; + +public final class ChooseNewAdminRepository { + private final Application context; + + ChooseNewAdminRepository(@NonNull Application context) { + this.context = context; + } + + @WorkerThread + @NonNull GroupChangeResult updateAdminsAndLeave(@NonNull GroupId.V2 groupId, @NonNull List newAdminIds) { + try { + GroupManager.addMemberAdminsAndLeaveGroup(context, groupId, newAdminIds); + return GroupChangeResult.SUCCESS; + } catch (GroupChangeException | IOException e) { + return GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java new file mode 100644 index 00000000..21ddda7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +final class ChooseNewAdminViewModel extends ViewModel { + + private final GroupId.V2 groupId; + private final ChooseNewAdminRepository repository; + private final LiveGroup liveGroup; + private final MutableLiveData> selection; + + public ChooseNewAdminViewModel(@NonNull GroupId.V2 groupId, @NonNull ChooseNewAdminRepository repository) { + this.groupId = groupId; + this.repository = repository; + + liveGroup = new LiveGroup(groupId); + selection = new MutableLiveData<>(Collections.emptySet()); + } + + @NonNull LiveData> getNonAdminFullMembers() { + return liveGroup.getNonAdminFullMembers(); + } + + @NonNull LiveData> getSelection() { + return selection; + } + + void setSelection(@NonNull Set selection) { + this.selection.setValue(selection); + } + + void updateAdminsAndLeave(@NonNull Consumer consumer) { + //noinspection ConstantConditions + List recipientIds = Stream.of(selection.getValue()).map(entry -> entry.getMember().getId()).toList(); + SimpleTask.run(() -> repository.updateAdminsAndLeave(groupId, recipientIds), consumer::accept); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + + Factory(@NonNull GroupId.V2 groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ChooseNewAdminViewModel(groupId, new ChooseNewAdminRepository(ApplicationDependencies.getApplication()))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java new file mode 100644 index 00000000..c1433160 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Pair; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.annimon.stream.Stream; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.GroupsV2CapabilityChecker; +import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class CreateGroupActivity extends ContactSelectionActivity { + + private static final String TAG = Log.tag(CreateGroupActivity.class); + + private static final short REQUEST_CODE_ADD_DETAILS = 17275; + + private ExtendedFloatingActionButton next; + private ValueAnimator padStart; + private ValueAnimator padEnd; + + public static Intent newIntent(@NonNull Context context) { + Intent intent = new Intent(context, CreateGroupActivity.class); + + intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false); + intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.create_group_activity); + + int displayMode = TextSecurePreferences.isSmsEnabled(context) ? ContactsCursorLoader.DisplayMode.FLAG_SMS | ContactsCursorLoader.DisplayMode.FLAG_PUSH + : ContactsCursorLoader.DisplayMode.FLAG_PUSH; + + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.groupLimits().excludingSelf()); + + return intent; + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + super.onCreate(bundle, ready); + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + next = findViewById(R.id.next); + extendSkip(); + + next.setOnClickListener(v -> handleNextPressed()); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == REQUEST_CODE_ADD_DETAILS && resultCode == RESULT_OK) { + finish(); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + shrinkSkip(); + + return true; + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + if (contactsFragment.getSelectedContactsCount() == 0) { + extendSkip(); + } + } + + private void extendSkip() { + next.setIconGravity(MaterialButton.ICON_GRAVITY_END); + next.extend(); + animatePadding(24, 18); + } + + private void shrinkSkip() { + next.setIconGravity(MaterialButton.ICON_GRAVITY_START); + next.shrink(); + animatePadding(16, 16); + } + + private void animatePadding(int startDp, int endDp) { + if (padStart != null) padStart.cancel(); + + padStart = ValueAnimator.ofInt(next.getPaddingStart(), ViewUtil.dpToPx(startDp)).setDuration(200); + padStart.addUpdateListener(animation -> { + ViewUtil.setPaddingStart(next, (Integer) animation.getAnimatedValue()); + }); + padStart.start(); + + if (padEnd != null) padEnd.cancel(); + + padEnd = ValueAnimator.ofInt(next.getPaddingEnd(), ViewUtil.dpToPx(endDp)).setDuration(200); + padEnd.addUpdateListener(animation -> { + ViewUtil.setPaddingEnd(next, (Integer) animation.getAnimatedValue()); + }); + padEnd.start(); + } + + private void handleNextPressed() { + Stopwatch stopwatch = new Stopwatch("Recipient Refresh"); + SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(this); + + SimpleTask.run(getLifecycle(), () -> { + List ids = Stream.of(contactsFragment.getSelectedContacts()) + .map(selectedContact -> selectedContact.getOrCreateRecipientId(this)) + .toList(); + + List resolved = Recipient.resolvedList(ids); + + stopwatch.split("resolve"); + + List registeredChecks = Stream.of(resolved) + .filter(r -> r.getRegistered() == RecipientDatabase.RegisteredState.UNKNOWN) + .toList(); + + Log.i(TAG, "Need to do " + registeredChecks.size() + " registration checks."); + + for (Recipient recipient : registeredChecks) { + try { + DirectoryHelper.refreshDirectoryFor(this, recipient, false); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh registered status for " + recipient.getId(), e); + } + } + + stopwatch.split("registered"); + + List recipientsAndSelf = new ArrayList<>(resolved); + recipientsAndSelf.add(Recipient.self().resolve()); + + if (!SignalStore.internalValues().gv2DoNotCreateGv2Groups()) { + try { + GroupsV2CapabilityChecker.refreshCapabilitiesIfNecessary(recipientsAndSelf); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh all recipient capabilities.", e); + } + } + + stopwatch.split("capabilities"); + + resolved = Recipient.resolvedList(ids); + + Pair> result; + + boolean gv2 = Stream.of(recipientsAndSelf).allMatch(r -> r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED); + if (!gv2 && Stream.of(resolved).anyMatch(r -> !r.hasE164())) + { + Log.w(TAG, "Invalid GV1 group..."); + ids = Collections.emptyList(); + result = Pair.create(false, ids); + } else { + result = Pair.create(true, ids); + } + + stopwatch.split("gv1-check"); + + return result; + }, result -> { + dismissibleDialog.dismiss(); + + stopwatch.stop(TAG); + + if (result.first) { + startActivityForResult(AddGroupDetailsActivity.newIntent(this, result.second), REQUEST_CODE_ADD_DETAILS); + } else { + new AlertDialog.Builder(this) + .setMessage(R.string.CreateGroupActivity_some_contacts_cannot_be_in_legacy_groups) + .setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss()) + .show(); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java new file mode 100644 index 00000000..638323ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class AddGroupDetailsActivity extends PassphraseRequiredActivity implements AddGroupDetailsFragment.Callback { + + private static final String EXTRA_RECIPIENTS = "recipient_ids"; + + private final DynamicTheme theme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull Collection recipients) { + Intent intent = new Intent(context, AddGroupDetailsActivity.class); + + intent.putParcelableArrayListExtra(EXTRA_RECIPIENTS, new ArrayList<>(recipients)); + + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle bundle, boolean ready) { + theme.onCreate(this); + + setContentView(R.layout.add_group_details_activity); + + if (bundle == null) { + ArrayList recipientIds = getIntent().getParcelableArrayListExtra(EXTRA_RECIPIENTS); + AddGroupDetailsFragmentArgs arguments = new AddGroupDetailsFragmentArgs.Builder(recipientIds.toArray(new RecipientId[0])).build(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, arguments.toBundle()); + } + } + + @Override + protected void onResume() { + super.onResume(); + theme.onResume(this); + } + + @Override + public void onGroupCreated(@NonNull RecipientId recipientId, + long threadId, + @NonNull List invitedMembers) + { + Dialog dialog = GroupInviteSentDialog.showInvitesSent(this, invitedMembers); + if (dialog != null) { + dialog.setOnDismissListener((d) -> goToConversation(recipientId, threadId)); + } else { + goToConversation(recipientId, threadId); + } + } + + void goToConversation(@NonNull RecipientId recipientId, long threadId) { + Intent intent = ConversationIntents.createBuilder(this, recipientId, threadId) + .firstTimeInSelfCreatedGroup() + .build(); + + startActivity(intent); + setResult(RESULT_OK); + finish(); + } + + @Override + public void onNavigationButtonPressed() { + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java new file mode 100644 index 00000000..36761af2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java @@ -0,0 +1,263 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.dd.CircularProgressButton; + +import org.signal.core.util.EditTextUtil; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.groups.ui.creategroup.dialogs.NonGv2MemberDialog; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +public class AddGroupDetailsFragment extends LoggingFragment { + + private static final int AVATAR_PLACEHOLDER_INSET_DP = 18; + private static final short REQUEST_CODE_AVATAR = 27621; + + private CircularProgressButton create; + private Callback callback; + private AddGroupDetailsViewModel viewModel; + private Drawable avatarPlaceholder; + private EditText name; + private Toolbar toolbar; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof Callback) { + callback = (Callback) context; + } else { + throw new ClassCastException("Parent context should implement AddGroupDetailsFragment.Callback"); + } + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.add_group_details_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + create = view.findViewById(R.id.create); + name = view.findViewById(R.id.name); + toolbar = view.findViewById(R.id.toolbar); + + setCreateEnabled(false, false); + + GroupMemberListView members = view.findViewById(R.id.member_list); + ImageView avatar = view.findViewById(R.id.group_avatar); + View mmsWarning = view.findViewById(R.id.mms_warning); + LearnMoreTextView gv2Warning = view.findViewById(R.id.gv2_warning); + View addLater = view.findViewById(R.id.add_later); + + avatarPlaceholder = VectorDrawableCompat.create(getResources(), R.drawable.ic_camera_outline_32_ultramarine, requireActivity().getTheme()); + + if (savedInstanceState == null) { + avatar.setImageDrawable(new InsetDrawable(avatarPlaceholder, ViewUtil.dpToPx(AVATAR_PLACEHOLDER_INSET_DP))); + } + + initializeViewModel(); + + avatar.setOnClickListener(v -> showAvatarSelectionBottomSheet()); + members.setRecipientClickListener(this::handleRecipientClick); + EditTextUtil.addGraphemeClusterLimitFilter(name, FeatureFlags.getMaxGroupNameGraphemeLength()); + name.addTextChangedListener(new AfterTextChanged(editable -> viewModel.setName(editable.toString()))); + toolbar.setNavigationOnClickListener(unused -> callback.onNavigationButtonPressed()); + create.setOnClickListener(v -> handleCreateClicked()); + viewModel.getMembers().observe(getViewLifecycleOwner(), list -> { + addLater.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE); + members.setMembers(list); + }); + viewModel.getCanSubmitForm().observe(getViewLifecycleOwner(), isFormValid -> setCreateEnabled(isFormValid, true)); + viewModel.getIsMms().observe(getViewLifecycleOwner(), isMms -> { + mmsWarning.setVisibility(isMms ? View.VISIBLE : View.GONE); + name.setHint(isMms ? R.string.AddGroupDetailsFragment__group_name_optional : R.string.AddGroupDetailsFragment__group_name_required); + toolbar.setTitle(isMms ? R.string.AddGroupDetailsFragment__create_group : R.string.AddGroupDetailsFragment__name_this_group); + }); + viewModel.getNonGv2CapableMembers().observe(getViewLifecycleOwner(), nonGv2CapableMembers -> { + boolean forcedMigration = FeatureFlags.groupsV1ForcedMigration(); + + int stringRes = forcedMigration ? R.plurals.AddGroupDetailsFragment__d_members_do_not_support_new_groups_so_this_group_cannot_be_created + : R.plurals.AddGroupDetailsFragment__d_members_do_not_support_new_groups; + + gv2Warning.setVisibility(nonGv2CapableMembers.isEmpty() ? View.GONE : View.VISIBLE); + gv2Warning.setText(requireContext().getResources().getQuantityString(stringRes, nonGv2CapableMembers.size(), nonGv2CapableMembers.size())); + gv2Warning.setLearnMoreVisible(true); + gv2Warning.setOnLinkClickListener(v -> NonGv2MemberDialog.showNonGv2Members(requireContext(), nonGv2CapableMembers, forcedMigration)); + }); + viewModel.getAvatar().observe(getViewLifecycleOwner(), avatarBytes -> { + if (avatarBytes == null) { + avatar.setImageDrawable(new InsetDrawable(avatarPlaceholder, ViewUtil.dpToPx(AVATAR_PLACEHOLDER_INSET_DP))); + } else { + GlideApp.with(this) + .load(avatarBytes) + .circleCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatar); + } + }); + + name.requestFocus(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == REQUEST_CODE_AVATAR && resultCode == Activity.RESULT_OK && data != null) { + + if (data.getBooleanExtra("delete", false)) { + viewModel.setAvatar(null); + return; + } + + final Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); + + GlideApp.with(this) + .asBitmap() + .load(decryptableUri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + viewModel.setAvatar(Objects.requireNonNull(BitmapUtil.toByteArray(resource))); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void initializeViewModel() { + AddGroupDetailsFragmentArgs args = AddGroupDetailsFragmentArgs.fromBundle(requireArguments()); + AddGroupDetailsRepository repository = new AddGroupDetailsRepository(requireContext()); + AddGroupDetailsViewModel.Factory factory = new AddGroupDetailsViewModel.Factory(Arrays.asList(args.getRecipientIds()), repository); + + viewModel = ViewModelProviders.of(this, factory).get(AddGroupDetailsViewModel.class); + + viewModel.getGroupCreateResult().observe(getViewLifecycleOwner(), this::handleGroupCreateResult); + } + + private void handleCreateClicked() { + create.setClickable(false); + create.setIndeterminateProgressMode(true); + create.setProgress(50); + + viewModel.create(); + } + + private void handleRecipientClick(@NonNull Recipient recipient) { + new AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.AddGroupDetailsFragment__remove_s_from_this_group, recipient.getDisplayName(requireContext()))) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) + .setPositiveButton(R.string.AddGroupDetailsFragment__remove, (dialog, which) -> { + viewModel.delete(recipient.getId()); + dialog.dismiss(); + }) + .show(); + } + + private void handleGroupCreateResult(@NonNull GroupCreateResult groupCreateResult) { + groupCreateResult.consume(this::handleGroupCreateResultSuccess, this::handleGroupCreateResultError); + } + + private void handleGroupCreateResultSuccess(@NonNull GroupCreateResult.Success success) { + callback.onGroupCreated(success.getGroupRecipient().getId(), success.getThreadId(), success.getInvitedMembers()); + } + + private void handleGroupCreateResultError(@NonNull GroupCreateResult.Error error) { + switch (error.getErrorType()) { + case ERROR_IO: + case ERROR_BUSY: + toast(R.string.AddGroupDetailsFragment__try_again_later); + break; + case ERROR_FAILED: + toast(R.string.AddGroupDetailsFragment__group_creation_failed); + break; + case ERROR_INVALID_NAME: + name.setError(getString(R.string.AddGroupDetailsFragment__this_field_is_required)); + break; + default: + throw new IllegalStateException("Unexpected error: " + error.getErrorType().name()); + } + } + + private void toast(@StringRes int toastStringId) { + Toast.makeText(requireContext(), toastStringId, Toast.LENGTH_SHORT) + .show(); + } + + private void setCreateEnabled(boolean isEnabled, boolean animate) { + if (create.isEnabled() == isEnabled) { + return; + } + + create.setEnabled(isEnabled); + create.animate() + .setDuration(animate ? 300 : 0) + .alpha(isEnabled ? 1f : 0.5f); + } + + private void showAvatarSelectionBottomSheet() { + AvatarSelectionBottomSheetDialogFragment.create(viewModel.hasAvatar(), true, REQUEST_CODE_AVATAR, true) + .show(getChildFragmentManager(), "BOTTOM"); + } + + public interface Callback { + void onGroupCreated(@NonNull RecipientId recipientId, long threadId, @NonNull List invitedMembers); + void onNavigationButtonPressed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java new file mode 100644 index 00000000..e36d8977 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupsV2CapabilityChecker; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class AddGroupDetailsRepository { + + private static final String TAG = Log.tag(AddGroupDetailsRepository.class); + + private final Context context; + + AddGroupDetailsRepository(@NonNull Context context) { + this.context = context; + } + + void resolveMembers(@NonNull Collection recipientIds, Consumer> consumer) { + SignalExecutors.BOUNDED.execute(() -> { + List members = new ArrayList<>(recipientIds.size()); + + for (RecipientId id : recipientIds) { + members.add(new GroupMemberEntry.NewGroupCandidate(Recipient.resolved(id))); + } + + consumer.accept(members); + }); + } + + void createGroup(@NonNull Set members, + @Nullable byte[] avatar, + @Nullable String name, + boolean mms, + Consumer resultConsumer) + { + SignalExecutors.BOUNDED.execute(() -> { + Set recipients = new HashSet<>(Stream.of(members).map(Recipient::resolved).toList()); + + try { + GroupManager.GroupActionResult result = GroupManager.createGroup(context, recipients, avatar, name, mms); + + resultConsumer.accept(GroupCreateResult.success(result)); + } catch (GroupChangeBusyException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_BUSY)); + } catch (GroupChangeException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_FAILED)); + } catch (IOException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_IO)); + } + }); + } + + @WorkerThread + List checkCapabilities(@NonNull Collection newPotentialMemberList) { + try { + GroupsV2CapabilityChecker.refreshCapabilitiesIfNecessary(Recipient.resolvedList(newPotentialMemberList)); + } catch (IOException e) { + Log.w(TAG, "Could not get latest profiles for users, using known gv2 capability state", e); + } + + return Stream.of(Recipient.resolvedList(newPotentialMemberList)) + .filter(m -> m.getGroupsV2Capability() != Recipient.Capability.SUPPORTED) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java new file mode 100644 index 00000000..2dfaa7d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class AddGroupDetailsViewModel extends ViewModel { + + private final LiveData> members; + private final DefaultValueLiveData> deleted = new DefaultValueLiveData<>(new HashSet<>()); + private final MutableLiveData name = new MutableLiveData<>(""); + private final MutableLiveData avatar = new MutableLiveData<>(); + private final SingleLiveEvent groupCreateResult = new SingleLiveEvent<>(); + private final LiveData isMms; + private final LiveData canSubmitForm; + private final AddGroupDetailsRepository repository; + private final LiveData> nonGv2CapableMembers; + + private AddGroupDetailsViewModel(@NonNull Collection recipientIds, + @NonNull AddGroupDetailsRepository repository) + { + this.repository = repository; + + MutableLiveData> initialMembers = new MutableLiveData<>(); + + LiveData isValidName = Transformations.map(name, name -> !TextUtils.isEmpty(name)); + + members = LiveDataUtil.combineLatest(initialMembers, deleted, AddGroupDetailsViewModel::filterDeletedMembers); + + isMms = Transformations.map(members, AddGroupDetailsViewModel::isAnyForcedSms); + + LiveData> membersToCheckGv2CapabilityOf = LiveDataUtil.combineLatest(isMms, members, (forcedMms, memberList) -> { + if (SignalStore.internalValues().gv2DoNotCreateGv2Groups() || forcedMms) { + return Collections.emptyList(); + } else { + return memberList; + } + }); + + nonGv2CapableMembers = LiveDataUtil.mapAsync(membersToCheckGv2CapabilityOf, memberList -> repository.checkCapabilities(Stream.of(memberList).map(newGroupCandidate -> newGroupCandidate.getMember().getId()).toList())); + canSubmitForm = FeatureFlags.groupsV1ForcedMigration() ? LiveDataUtil.just(false) + : LiveDataUtil.combineLatest(isMms, isValidName, (mms, validName) -> mms || validName); + + repository.resolveMembers(recipientIds, initialMembers::postValue); + } + + @NonNull LiveData> getMembers() { + return members; + } + + @NonNull LiveData getCanSubmitForm() { + return canSubmitForm; + } + + @NonNull LiveData getGroupCreateResult() { + return groupCreateResult; + } + + @NonNull LiveData getAvatar() { + return avatar; + } + + @NonNull LiveData getIsMms() { + return isMms; + } + + @NonNull LiveData> getNonGv2CapableMembers() { + return nonGv2CapableMembers; + } + + void setAvatar(@Nullable byte[] avatar) { + this.avatar.setValue(avatar); + } + + boolean hasAvatar() { + return avatar.getValue() != null; + } + + void setName(@NonNull String name) { + this.name.setValue(name); + } + + void delete(@NonNull RecipientId recipientId) { + Set deleted = this.deleted.getValue(); + + deleted.add(recipientId); + this.deleted.setValue(deleted); + } + + void create() { + List members = Objects.requireNonNull(this.members.getValue()); + Set memberIds = Stream.of(members).map(member -> member.getMember().getId()).collect(Collectors.toSet()); + byte[] avatarBytes = avatar.getValue(); + boolean isGroupMms = isMms.getValue() == Boolean.TRUE; + String groupName = name.getValue(); + + if (!isGroupMms && TextUtils.isEmpty(groupName)) { + groupCreateResult.postValue(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_INVALID_NAME)); + return; + } + + repository.createGroup(memberIds, + avatarBytes, + groupName, + isGroupMms, + groupCreateResult::postValue); + } + + private static @NonNull List filterDeletedMembers(@NonNull List members, @NonNull Set deleted) { + return Stream.of(members) + .filterNot(member -> deleted.contains(member.getMember().getId())) + .toList(); + } + + private static boolean isAnyForcedSms(@NonNull List members) { + return Stream.of(members) + .anyMatch(member -> !member.getMember().isRegistered()); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final Collection recipientIds; + private final AddGroupDetailsRepository repository; + + Factory(@NonNull Collection recipientIds, @NonNull AddGroupDetailsRepository repository) { + this.recipientIds = recipientIds; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new AddGroupDetailsViewModel(recipientIds, repository))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java new file mode 100644 index 00000000..2c4eb480 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +abstract class GroupCreateResult { + + @WorkerThread + static GroupCreateResult success(@NonNull GroupManager.GroupActionResult result) { + return new GroupCreateResult.Success(result.getThreadId(), result.getGroupRecipient(), result.getAddedMemberCount(), Recipient.resolvedList(result.getInvitedMembers())); + } + + static GroupCreateResult error(@NonNull GroupCreateResult.Error.Type errorType) { + return new GroupCreateResult.Error(errorType); + } + + private GroupCreateResult() { + } + + static final class Success extends GroupCreateResult { + private final long threadId; + private final Recipient groupRecipient; + private final int addedMemberCount; + private final List invitedMembers; + + private Success(long threadId, + @NonNull Recipient groupRecipient, + int addedMemberCount, + @NonNull List invitedMembers) + { + this.threadId = threadId; + this.groupRecipient = groupRecipient; + this.addedMemberCount = addedMemberCount; + this.invitedMembers = invitedMembers; + } + + long getThreadId() { + return threadId; + } + + @NonNull Recipient getGroupRecipient() { + return groupRecipient; + } + + int getAddedMemberCount() { + return addedMemberCount; + } + + List getInvitedMembers() { + return invitedMembers; + } + + @Override + void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer) + { + successConsumer.accept(this); + } + } + + static final class Error extends GroupCreateResult { + private final Error.Type errorType; + + private Error(Error.Type errorType) { + this.errorType = errorType; + } + + @Override + void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer) + { + errorConsumer.accept(this); + } + + public Type getErrorType() { + return errorType; + } + + enum Type { + ERROR_IO, + ERROR_BUSY, + ERROR_FAILED, + ERROR_INVALID_NAME + } + } + + abstract void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/dialogs/NonGv2MemberDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/dialogs/NonGv2MemberDialog.java new file mode 100644 index 00000000..5bc018b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/dialogs/NonGv2MemberDialog.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.dialogs; + +import android.app.Dialog; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.ArrayList; +import java.util.List; + +public final class NonGv2MemberDialog { + + private NonGv2MemberDialog() { + } + + public static @Nullable Dialog showNonGv2Members(@NonNull Context context, @NonNull List recipients, boolean forcedMigration) { + int size = recipients.size(); + if (size == 0) { + return null; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context) + // TODO: GV2 Need a URL for learn more + // .setNegativeButton(R.string.NonGv2MemberDialog_learn_more, (dialog, which) -> { + // }) + .setPositiveButton(android.R.string.ok, null); + if (size == 1) { + int stringRes = forcedMigration ? R.string.NonGv2MemberDialog_single_users_are_non_gv2_capable_forced_migration + : R.string.NonGv2MemberDialog_single_users_are_non_gv2_capable; + builder.setMessage(context.getString(stringRes, recipients.get(0).getDisplayName(context))); + } else { + int pluralRes = forcedMigration ? R.plurals.NonGv2MemberDialog_d_users_are_non_gv2_capable_forced_migration + : R.plurals.NonGv2MemberDialog_d_users_are_non_gv2_capable; + builder.setMessage(context.getResources().getQuantityString(pluralRes, size, size)) + .setView(R.layout.dialog_multiple_members_non_gv2_capable); + } + + Dialog dialog = builder.show(); + if (size > 1) { + GroupMemberListView nonGv2CapableMembers = dialog.findViewById(R.id.list_non_gv2_members); + + List pendingMembers = new ArrayList<>(recipients.size()); + for (Recipient r : recipients) { + pendingMembers.add(new GroupMemberEntry.NewGroupCandidate(r)); + } + + //noinspection ConstantConditions + nonGv2CapableMembers.setMembers(pendingMembers); + } + + return dialog; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java new file mode 100644 index 00000000..c701f494 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited.PendingMemberInvitesFragment; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting.RequestingMembersFragment; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class ManagePendingAndRequestingMembersActivity extends PassphraseRequiredActivity { + + private static final String GROUP_ID = "GROUP_ID"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull GroupId.V2 groupId) { + Intent intent = new Intent(context, ManagePendingAndRequestingMembersActivity.class); + intent.putExtra(GROUP_ID, groupId.toString()); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.group_pending_and_requesting_member_activity); + + if (savedInstanceState == null) { + GroupId.V2 groupId = GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID)).requireV2(); + + ViewPager2 viewPager = findViewById(R.id.pending_and_requesting_pager); + TabLayout tabLayout = findViewById(R.id.pending_and_requesting_tabs); + + viewPager.setAdapter(new ViewPagerAdapter(this, groupId)); + + new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + switch (position) { + case 0 : tab.setText(R.string.PendingMembersActivity_requests); break; + case 1 : tab.setText(R.string.PendingMembersActivity_invites); break; + default: throw new AssertionError(); + } + } + ).attach(); + } + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + requireSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + private static class ViewPagerAdapter extends FragmentStateAdapter { + + private final GroupId.V2 groupId; + + public ViewPagerAdapter(@NonNull FragmentActivity fragmentActivity, + @NonNull GroupId.V2 groupId) + { + super(fragmentActivity); + this.groupId = groupId; + } + + @Override + public @NonNull Fragment createFragment(int position) { + switch (position) { + case 0 : return RequestingMembersFragment.newInstance(groupId); + case 1 : return PendingMemberInvitesFragment.newInstance(groupId); + default: throw new AssertionError(); + } + } + + @Override + public int getItemCount() { + return 2; + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/EnableInviteLinkError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/EnableInviteLinkError.java new file mode 100644 index 00000000..4589efaf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/EnableInviteLinkError.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite; + +enum EnableInviteLinkError { + BUSY, + FAILED, + NETWORK_ERROR, + INSUFFICIENT_RIGHTS, + NOT_IN_GROUP, +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsBottomSheetDialogFragment.java new file mode 100644 index 00000000..b6a72f77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsBottomSheetDialogFragment.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.GroupLinkBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.util.Objects; + +public final class GroupLinkInviteFriendsBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String TAG = Log.tag(GroupLinkInviteFriendsBottomSheetDialogFragment.class); + + private static final String ARG_GROUP_ID = "group_id"; + + private Button groupLinkEnableAndShareButton; + private Button groupLinkShareButton; + private View memberApprovalRow; + private View memberApprovalRow2; + private SwitchCompat memberApprovalSwitch; + + private SimpleProgressDialog.DismissibleDialog busyDialog; + + public static void show(@NonNull FragmentManager manager, + @NonNull GroupId.V2 groupId) + { + GroupLinkInviteFriendsBottomSheetDialogFragment fragment = new GroupLinkInviteFriendsBottomSheetDialogFragment(); + + Bundle args = new Bundle(); + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_invite_link_enable_and_share_bottom_sheet, container, false); + + groupLinkEnableAndShareButton = view.findViewById(R.id.group_link_enable_and_share_button); + groupLinkShareButton = view.findViewById(R.id.group_link_share_button); + memberApprovalRow = view.findViewById(R.id.group_link_enable_and_share_approve_new_members_row); + memberApprovalRow2 = view.findViewById(R.id.group_link_enable_and_share_approve_new_members_row2); + memberApprovalSwitch = view.findViewById(R.id.group_link_enable_and_share_approve_new_members_switch); + + view.findViewById(R.id.group_link_enable_and_share_cancel_button).setOnClickListener(v -> dismiss()); + + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + GroupId.V2 groupId = getGroupId(); + + GroupLinkInviteFriendsViewModel.Factory factory = new GroupLinkInviteFriendsViewModel.Factory(requireContext().getApplicationContext(), groupId); + GroupLinkInviteFriendsViewModel viewModel = ViewModelProviders.of(this, factory).get(GroupLinkInviteFriendsViewModel.class); + + viewModel.getGroupInviteLinkAndStatus() + .observe(getViewLifecycleOwner(), groupLinkUrlAndStatus -> { + if (groupLinkUrlAndStatus.isEnabled()) { + groupLinkShareButton.setVisibility(View.VISIBLE); + groupLinkEnableAndShareButton.setVisibility(View.INVISIBLE); + memberApprovalRow.setVisibility(View.GONE); + memberApprovalRow2.setVisibility(View.GONE); + + groupLinkShareButton.setOnClickListener(v -> shareGroupLinkAndDismiss(groupId)); + } else { + memberApprovalRow.setVisibility(View.VISIBLE); + memberApprovalRow2.setVisibility(View.VISIBLE); + + groupLinkEnableAndShareButton.setVisibility(View.VISIBLE); + groupLinkShareButton.setVisibility(View.INVISIBLE); + } + }); + + memberApprovalRow.setOnClickListener(v -> viewModel.toggleMemberApproval()); + + viewModel.getMemberApproval() + .observe(getViewLifecycleOwner(), enabled -> memberApprovalSwitch.setChecked(enabled)); + + viewModel.isBusy() + .observe(getViewLifecycleOwner(), this::setBusy); + + viewModel.getEnableErrors() + .observe(getViewLifecycleOwner(), error -> { + Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show(); + + if (error == EnableInviteLinkError.NOT_IN_GROUP || error == EnableInviteLinkError.INSUFFICIENT_RIGHTS) { + dismiss(); + } + }); + + groupLinkEnableAndShareButton.setOnClickListener(v -> viewModel.enable()); + + viewModel.getEnableSuccess() + .observe(getViewLifecycleOwner(), joinGroupSuccess -> { + Log.i(TAG, "Group link enabled, sharing"); + shareGroupLinkAndDismiss(groupId); + } + ); + } + + protected void shareGroupLinkAndDismiss(@NonNull GroupId.V2 groupId) { + dismiss(); + + GroupLinkBottomSheetDialogFragment.show(requireFragmentManager(), groupId); + } + + protected GroupId.V2 getGroupId() { + try { + return GroupId.parse(Objects.requireNonNull(requireArguments().getString(ARG_GROUP_ID))) + .requireV2(); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + private void setBusy(boolean isBusy) { + if (isBusy) { + if (busyDialog == null) { + busyDialog = SimpleProgressDialog.showDelayed(requireContext()); + } + } else { + if (busyDialog != null) { + busyDialog.dismiss(); + busyDialog = null; + } + } + } + + private @NonNull String errorToMessage(@NonNull EnableInviteLinkError error) { + switch (error) { + case NETWORK_ERROR : return getString(R.string.GroupInviteLinkEnableAndShareBottomSheetDialogFragment_encountered_a_network_error); + case INSUFFICIENT_RIGHTS : return getString(R.string.GroupInviteLinkEnableAndShareBottomSheetDialogFragment_you_dont_have_the_right_to_enable_group_link); + case NOT_IN_GROUP : return getString(R.string.GroupInviteLinkEnableAndShareBottomSheetDialogFragment_you_are_not_currently_a_member_of_the_group); + default : return getString(R.string.GroupInviteLinkEnableAndShareBottomSheetDialogFragment_unable_to_enable_group_link_please_try_again_later); + } + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsViewModel.java new file mode 100644 index 00000000..06e23d2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteFriendsViewModel.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public class GroupLinkInviteFriendsViewModel extends ViewModel { + + private static final boolean INITIAL_MEMBER_APPROVAL_STATE = false; + + private final GroupLinkInviteRepository repository; + private final MutableLiveData enableErrors = new SingleLiveEvent<>(); + private final MutableLiveData busy = new MediatorLiveData<>(); + private final MutableLiveData enableSuccess = new SingleLiveEvent<>(); + private final LiveData groupLink; + private final MutableLiveData memberApproval = new MutableLiveData<>(INITIAL_MEMBER_APPROVAL_STATE); + + private GroupLinkInviteFriendsViewModel(GroupId.V2 groupId, @NonNull GroupLinkInviteRepository repository) { + this.repository = repository; + + LiveGroup liveGroup = new LiveGroup(groupId); + + this.groupLink = liveGroup.getGroupLink(); + } + + LiveData getGroupInviteLinkAndStatus() { + return groupLink; + } + + void enable() { + busy.setValue(true); + repository.enableGroupInviteLink(getCurrentMemberApproval(), new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable GroupInviteLinkUrl groupInviteLinkUrl) { + busy.postValue(false); + enableSuccess.postValue(groupInviteLinkUrl); + } + + @Override + public void onError(@Nullable EnableInviteLinkError error) { + busy.postValue(false); + enableErrors.postValue(error); + } + }); + } + + LiveData isBusy() { + return busy; + } + + LiveData getEnableSuccess() { + return enableSuccess; + } + + LiveData getEnableErrors() { + return enableErrors; + } + + LiveData getMemberApproval() { + return memberApproval; + } + + private boolean getCurrentMemberApproval() { + Boolean value = memberApproval.getValue(); + if (value == null) { + return INITIAL_MEMBER_APPROVAL_STATE; + } + return value; + } + + void toggleMemberApproval() { + memberApproval.postValue(!getCurrentMemberApproval()); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupId.V2 groupId; + + public Factory(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new GroupLinkInviteFriendsViewModel(groupId, new GroupLinkInviteRepository(context.getApplicationContext(), groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteRepository.java new file mode 100644 index 00000000..f87f6124 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invite/GroupLinkInviteRepository.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.util.AsynchronousCallback; + +import java.io.IOException; + +final class GroupLinkInviteRepository { + + private final Context context; + private final GroupId.V2 groupId; + + GroupLinkInviteRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + void enableGroupInviteLink(boolean requireMemberApproval, @NonNull AsynchronousCallback.WorkerThread callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupInviteLinkUrl groupInviteLinkUrl = GroupManager.setGroupLinkEnabledState(context, + groupId, + requireMemberApproval ? GroupManager.GroupLinkState.ENABLED_WITH_APPROVAL + : GroupManager.GroupLinkState.ENABLED); + + if (groupInviteLinkUrl == null) { + throw new AssertionError(); + } + + callback.onComplete(groupInviteLinkUrl); + } catch (IOException e) { + callback.onError(EnableInviteLinkError.NETWORK_ERROR); + } catch (GroupChangeBusyException e) { + callback.onError(EnableInviteLinkError.BUSY); + } catch (GroupChangeFailedException e) { + callback.onError(EnableInviteLinkError.FAILED); + } catch (GroupInsufficientRightsException e) { + callback.onError(EnableInviteLinkError.INSUFFICIENT_RIGHTS); + } catch (GroupNotAMemberException e) { + callback.onError(EnableInviteLinkError.NOT_IN_GROUP); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java new file mode 100644 index 00000000..c219af07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +final class InviteRevokeConfirmationDialog { + + private InviteRevokeConfirmationDialog() { + } + + /** + * Confirms that you want to revoke an invite that you sent. + */ + static AlertDialog showOwnInviteRevokeConfirmationDialog(@NonNull Context context, + @NonNull Recipient invitee, + @NonNull Runnable onRevoke) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.InviteRevokeConfirmationDialog_revoke_own_single_invite, + invitee.getDisplayName(context))) + .setPositiveButton(R.string.yes, (dialog, which) -> onRevoke.run()) + .setNegativeButton(R.string.no, null) + .show(); + } + + /** + * Confirms that you want to revoke a number of invites that another member sent. + */ + static AlertDialog showOthersInviteRevokeConfirmationDialog(@NonNull Context context, + @NonNull Recipient inviter, + int numberOfInvitations, + @NonNull Runnable onRevoke) + { + return new AlertDialog.Builder(context) + .setMessage(context.getResources().getQuantityString(R.plurals.InviteRevokeConfirmationDialog_revoke_others_invites, + numberOfInvitations, + inviter.getDisplayName(context), + numberOfInvitations)) + .setPositiveButton(R.string.yes, (dialog, which) -> onRevoke.run()) + .setNegativeButton(R.string.no, null) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java new file mode 100644 index 00000000..ee38bfed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.AdminActionsListener; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.BottomSheetUtil; + +import java.util.Objects; + +public class PendingMemberInvitesFragment extends Fragment { + + private static final String GROUP_ID = "GROUP_ID"; + + private PendingMemberInvitesViewModel viewModel; + private GroupMemberListView youInvited; + private GroupMemberListView othersInvited; + private View youInvitedEmptyState; + private View othersInvitedEmptyState; + + public static PendingMemberInvitesFragment newInstance(@NonNull GroupId.V2 groupId) { + PendingMemberInvitesFragment fragment = new PendingMemberInvitesFragment(); + Bundle args = new Bundle(); + + args.putString(GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_pending_member_invites_fragment, container, false); + + youInvited = view.findViewById(R.id.members_you_invited); + othersInvited = view.findViewById(R.id.members_others_invited); + youInvitedEmptyState = view.findViewById(R.id.no_pending_from_you); + othersInvitedEmptyState = view.findViewById(R.id.no_pending_from_others); + + youInvited.setRecipientClickListener(recipient -> + RecipientBottomSheetDialogFragment.create(recipient.getId(), null) + .show(requireActivity().getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)); + + youInvited.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + viewModel.revokeInviteFor(pendingMember); + } + + @Override + public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + throw new UnsupportedOperationException(); + } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + }); + + othersInvited.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + viewModel.revokeInvitesFor(pendingMembers); + } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + }); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))).requireV2(); + + PendingMemberInvitesViewModel.Factory factory = new PendingMemberInvitesViewModel.Factory(requireContext(), groupId); + + viewModel = ViewModelProviders.of(requireActivity(), factory).get(PendingMemberInvitesViewModel.class); + + viewModel.getWhoYouInvited().observe(getViewLifecycleOwner(), invitees -> { + youInvited.setMembers(invitees); + youInvitedEmptyState.setVisibility(invitees.isEmpty() ? View.VISIBLE : View.GONE); + }); + + viewModel.getWhoOthersInvited().observe(getViewLifecycleOwner(), invitees -> { + othersInvited.setMembers(invitees); + othersInvitedEmptyState.setVisibility(invitees.isEmpty() ? View.VISIBLE : View.GONE); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java new file mode 100644 index 00000000..95aa9ade --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.UuidCiphertext; +import org.signal.zkgroup.util.UUIDUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * Repository for modifying the pending members on a single group. + */ +final class PendingMemberInvitesRepository { + + private static final String TAG = Log.tag(PendingMemberInvitesRepository.class); + + private final Context context; + private final GroupId.V2 groupId; + private final Executor executor; + + PendingMemberInvitesRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context.getApplicationContext(); + this.executor = SignalExecutors.BOUNDED; + this.groupId = groupId; + } + + public void getInvitees(@NonNull Consumer onInviteesLoaded) { + executor.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + GroupDatabase.V2GroupProperties v2GroupProperties = groupDatabase.getGroup(groupId).get().requireV2GroupProperties(); + DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup(); + List pendingMembersList = decryptedGroup.getPendingMembersList(); + List byMe = new ArrayList<>(pendingMembersList.size()); + List byOthers = new ArrayList<>(pendingMembersList.size()); + ByteString self = ByteString.copyFrom(UUIDUtil.serialize(Recipient.self().getUuid().get())); + boolean selfIsAdmin = v2GroupProperties.isAdmin(Recipient.self()); + + Stream.of(pendingMembersList) + .groupBy(DecryptedPendingMember::getAddedByUuid) + .forEach(g -> + { + ByteString inviterUuid = g.getKey(); + List invitedMembers = g.getValue(); + + if (self.equals(inviterUuid)) { + for (DecryptedPendingMember pendingMember : invitedMembers) { + try { + Recipient invitee = GroupProtoUtil.pendingMemberToRecipient(context, pendingMember); + UuidCiphertext uuidCipherText = new UuidCiphertext(pendingMember.getUuidCipherText().toByteArray()); + + byMe.add(new SinglePendingMemberInvitedByYou(invitee, uuidCipherText)); + } catch (InvalidInputException e) { + Log.w(TAG, e); + } + } + } else { + Recipient inviter = GroupProtoUtil.uuidByteStringToRecipient(context, inviterUuid); + ArrayList uuidCipherTexts = new ArrayList<>(invitedMembers.size()); + + for (DecryptedPendingMember pendingMember : invitedMembers) { + try { + uuidCipherTexts.add(new UuidCiphertext(pendingMember.getUuidCipherText().toByteArray())); + } catch (InvalidInputException e) { + Log.w(TAG, e); + } + } + + byOthers.add(new MultiplePendingMembersInvitedByAnother(inviter, uuidCipherTexts)); + } + } + ); + + onInviteesLoaded.accept(new InviteeResult(byMe, byOthers, selfIsAdmin)); + }); + } + + @WorkerThread + boolean revokeInvites(@NonNull Collection uuidCipherTexts) { + try { + GroupManager.revokeInvites(context, groupId, uuidCipherTexts); + return true; + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + return false; + } + } + + public static final class InviteeResult { + private final List byMe; + private final List byOthers; + private final boolean canRevokeInvites; + + private InviteeResult(List byMe, + List byOthers, + boolean canRevokeInvites) + { + this.byMe = byMe; + this.byOthers = byOthers; + this.canRevokeInvites = canRevokeInvites; + } + + public List getByMe() { + return byMe; + } + + public List getByOthers() { + return byOthers; + } + + public boolean isCanRevokeInvites() { + return canRevokeInvites; + } + } + + public final static class SinglePendingMemberInvitedByYou { + private final Recipient invitee; + private final UuidCiphertext inviteeCipherText; + + private SinglePendingMemberInvitedByYou(@NonNull Recipient invitee, @NonNull UuidCiphertext inviteeCipherText) { + this.invitee = invitee; + this.inviteeCipherText = inviteeCipherText; + } + + public Recipient getInvitee() { + return invitee; + } + + public UuidCiphertext getInviteeCipherText() { + return inviteeCipherText; + } + } + + public final static class MultiplePendingMembersInvitedByAnother { + private final Recipient inviter; + private final Collection uuidCipherTexts; + + private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull Collection uuidCipherTexts) { + this.inviter = inviter; + this.uuidCipherTexts = uuidCipherTexts; + } + + public Recipient getInviter() { + return inviter; + } + + public Collection getUuidCipherTexts() { + return uuidCipherTexts; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java new file mode 100644 index 00000000..2ce02946 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.zkgroup.groups.UuidCiphertext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +public class PendingMemberInvitesViewModel extends ViewModel { + + private final Context context; + private final PendingMemberInvitesRepository pendingMemberRepository; + private final DefaultValueLiveData> whoYouInvited = new DefaultValueLiveData<>(Collections.emptyList()); + private final DefaultValueLiveData> whoOthersInvited = new DefaultValueLiveData<>(Collections.emptyList()); + + private PendingMemberInvitesViewModel(@NonNull Context context, + @NonNull PendingMemberInvitesRepository pendingMemberRepository) + { + this.context = context; + this.pendingMemberRepository = pendingMemberRepository; + + pendingMemberRepository.getInvitees(this::setMembers); + } + + public LiveData> getWhoYouInvited() { + return whoYouInvited; + } + + public LiveData> getWhoOthersInvited() { + return whoOthersInvited; + } + + private void setInvitees(List byYou, List byOthers) { + whoYouInvited.postValue(byYou); + whoOthersInvited.postValue(byOthers); + } + + private void setMembers(PendingMemberInvitesRepository.InviteeResult inviteeResult) { + List byMe = new ArrayList<>(inviteeResult.getByMe().size()); + List byOthers = new ArrayList<>(inviteeResult.getByOthers().size()); + + for (PendingMemberInvitesRepository.SinglePendingMemberInvitedByYou pendingMember : inviteeResult.getByMe()) { + byMe.add(new GroupMemberEntry.PendingMember(pendingMember.getInvitee(), + pendingMember.getInviteeCipherText(), + inviteeResult.isCanRevokeInvites())); + } + + for (PendingMemberInvitesRepository.MultiplePendingMembersInvitedByAnother pendingMembers : inviteeResult.getByOthers()) { + byOthers.add(new GroupMemberEntry.UnknownPendingMemberCount(pendingMembers.getInviter(), + pendingMembers.getUuidCipherTexts(), + inviteeResult.isCanRevokeInvites())); + } + + setInvitees(byMe, byOthers); + } + + void revokeInviteFor(@NonNull GroupMemberEntry.PendingMember pendingMember) { + UuidCiphertext inviteeCipherText = pendingMember.getInviteeCipherText(); + + InviteRevokeConfirmationDialog.showOwnInviteRevokeConfirmationDialog(context, pendingMember.getInvitee(), () -> + SimpleTask.run( + () -> { + pendingMember.setBusy(true); + try { + return pendingMemberRepository.revokeInvites(Collections.singleton(inviteeCipherText)); + } finally { + pendingMember.setBusy(false); + } + }, + result -> { + if (result) { + ArrayList newList = new ArrayList<>(whoYouInvited.getValue()); + Iterator iterator = newList.iterator(); + + while (iterator.hasNext()) { + if (iterator.next().getInviteeCipherText().equals(inviteeCipherText)) { + iterator.remove(); + } + } + + whoYouInvited.setValue(newList); + } else { + toastErrorCanceling(1); + } + } + )); + } + + void revokeInvitesFor(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + InviteRevokeConfirmationDialog.showOthersInviteRevokeConfirmationDialog(context, pendingMembers.getInviter(), pendingMembers.getInviteCount(), + () -> SimpleTask.run( + () -> { + pendingMembers.setBusy(true); + try { + return pendingMemberRepository.revokeInvites(pendingMembers.getCiphertexts()); + } finally { + pendingMembers.setBusy(false); + } + }, + result -> { + if (result) { + ArrayList newList = new ArrayList<>(whoOthersInvited.getValue()); + Iterator iterator = newList.iterator(); + + while (iterator.hasNext()) { + if (iterator.next().getInviter().equals(pendingMembers.getInviter())) { + iterator.remove(); + } + } + + whoOthersInvited.setValue(newList); + } else { + toastErrorCanceling(pendingMembers.getInviteCount()); + } + } + )); + } + + private void toastErrorCanceling(int quantity) { + Toast.makeText(context, context.getResources().getQuantityText(R.plurals.PendingMembersActivity_error_revoking_invite, quantity), Toast.LENGTH_SHORT) + .show(); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupId.V2 groupId; + + public Factory(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new PendingMemberInvitesViewModel(context, new PendingMemberInvitesRepository(context.getApplicationContext(), groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java new file mode 100644 index 00000000..9ee2281f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +enum FetchGroupDetailsError { + GroupLinkNotActive, + NetworkError +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java new file mode 100644 index 00000000..37db0fa5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; + +public final class GroupDetails { + private final DecryptedGroupJoinInfo joinInfo; + private final byte[] avatarBytes; + + public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo, + @Nullable byte[] avatarBytes) + { + this.joinInfo = joinInfo; + this.avatarBytes = avatarBytes; + } + + public @NonNull String getGroupName() { + return joinInfo.getTitle(); + } + + public @Nullable byte[] getAvatarBytes() { + return avatarBytes; + } + + public @NonNull DecryptedGroupJoinInfo getJoinInfo() { + return joinInfo; + } + + public int getGroupMembershipCount() { + return joinInfo.getMemberCount(); + } + + public boolean joinRequiresAdminApproval() { + return joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java new file mode 100644 index 00000000..98dbf395 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String TAG = Log.tag(GroupJoinBottomSheetDialogFragment.class); + + private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url"; + + private ProgressBar busy; + private AvatarImageView avatar; + private TextView groupName; + private TextView groupDetails; + private TextView groupJoinExplain; + private Button groupJoinButton; + private Button groupCancelButton; + + public static void show(@NonNull FragmentManager manager, + @NonNull GroupInviteLinkUrl groupInviteLinkUrl) + { + GroupJoinBottomSheetDialogFragment fragment = new GroupJoinBottomSheetDialogFragment(); + + Bundle args = new Bundle(); + args.putString(ARG_GROUP_INVITE_LINK_URL, groupInviteLinkUrl.getUrl()); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_join_bottom_sheet, container, false); + + groupCancelButton = view.findViewById(R.id.group_join_cancel_button); + groupJoinButton = view.findViewById(R.id.group_join_button); + busy = view.findViewById(R.id.group_join_busy); + avatar = view.findViewById(R.id.group_join_recipient_avatar); + groupName = view.findViewById(R.id.group_join_group_name); + groupDetails = view.findViewById(R.id.group_join_group_details); + groupJoinExplain = view.findViewById(R.id.group_join_explain); + + groupCancelButton.setOnClickListener(v -> dismiss()); + + avatar.setImageBytesForGroup(null, new FallbackPhotoProvider(), MaterialColor.STEEL); + + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + GroupJoinViewModel.Factory factory = new GroupJoinViewModel.Factory(requireContext().getApplicationContext(), getGroupInviteLinkUrl()); + + GroupJoinViewModel viewModel = ViewModelProviders.of(this, factory).get(GroupJoinViewModel.class); + + viewModel.getGroupDetails().observe(getViewLifecycleOwner(), details -> { + groupName.setText(details.getGroupName()); + groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount())); + + switch (getGroupJoinStatus()) { + case UPDATE_LINKED_DEVICE_TO_JOIN: + groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_linked_device_message); + groupCancelButton.setText(android.R.string.ok); + groupJoinButton.setVisibility(View.GONE); + ApplicationDependencies.getJobManager() + .add(RetrieveProfileJob.forRecipient(Recipient.self().getId())); + break; + case LOCAL_CAN_JOIN: + groupJoinExplain.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_admin_approval_needed + : R.string.GroupJoinBottomSheetDialogFragment_direct_join); + groupJoinButton.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_request_to_join + : R.string.GroupJoinBottomSheetDialogFragment_join); + groupJoinButton.setOnClickListener(v -> { + Log.i(TAG, details.joinRequiresAdminApproval() ? "Attempting to direct join group" : "Attempting to request to join group"); + viewModel.join(details); + }); + groupJoinButton.setVisibility(View.VISIBLE); + break; + } + + avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL); + + groupCancelButton.setVisibility(View.VISIBLE); + }); + + viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE)); + + viewModel.getErrors().observe(getViewLifecycleOwner(), error -> { + Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show(); + dismiss(); + }); + + viewModel.getJoinErrors().observe(getViewLifecycleOwner(), error -> Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show()); + + viewModel.getJoinSuccess().observe(getViewLifecycleOwner(), joinGroupSuccess -> { + Log.i(TAG, "Group joined, navigating to group"); + + Intent intent = ConversationIntents.createBuilder(requireContext(), joinGroupSuccess.getGroupRecipient().getId(), joinGroupSuccess.getGroupThreadId()) + .build(); + requireActivity().startActivity(intent); + + dismiss(); + } + ); + } + + private static ExtendedGroupJoinStatus getGroupJoinStatus() { + if (Recipient.self().getGroupsV2Capability() != Recipient.Capability.SUPPORTED) { + return ExtendedGroupJoinStatus.UPDATE_LINKED_DEVICE_TO_JOIN; + } else { + return ExtendedGroupJoinStatus.LOCAL_CAN_JOIN; + } + } + + private @NonNull String errorToMessage(@NonNull FetchGroupDetailsError error) { + if (error == FetchGroupDetailsError.GroupLinkNotActive) { + return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active); + } + return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later); + } + + private @NonNull String errorToMessage(@NonNull JoinGroupError error) { + switch (error) { + case GROUP_LINK_NOT_ACTIVE: return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active); + case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error); + default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later); + } + } + + private GroupInviteLinkUrl getGroupInviteLinkUrl() { + try { + //noinspection ConstantConditions + return GroupInviteLinkUrl.fromUri(requireArguments().getString(ARG_GROUP_INVITE_LINK_URL)); + } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { + throw new AssertionError(); + } + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new ResourceContactPhoto(R.drawable.ic_group_outline_48); + } + } + + public enum ExtendedGroupJoinStatus { + /** Locally we're using a version that can use group links, but one or more linked devices needs updating for GV2. */ + UPDATE_LINKED_DEVICE_TO_JOIN, + + /** This version of the client allows joining via GV2 group links. */ + LOCAL_CAN_JOIN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java new file mode 100644 index 00000000..99257e15 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; +import org.signal.zkgroup.VerificationFailedException; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; + +import java.io.IOException; + +final class GroupJoinRepository { + + private static final String TAG = Log.tag(GroupJoinRepository.class); + + private final Context context; + private final GroupInviteLinkUrl groupInviteLinkUrl; + + GroupJoinRepository(@NonNull Context context, @NonNull GroupInviteLinkUrl groupInviteLinkUrl) { + this.context = context; + this.groupInviteLinkUrl = groupInviteLinkUrl; + } + + void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + callback.onComplete(getGroupDetails()); + } catch (IOException e) { + callback.onError(FetchGroupDetailsError.NetworkError); + } catch (VerificationFailedException | GroupLinkNotActiveException e) { + callback.onError(FetchGroupDetailsError.GroupLinkNotActive); + } + }); + } + + void joinGroup(@NonNull GroupDetails groupDetails, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.GroupActionResult groupActionResult = GroupManager.joinGroup(context, + groupInviteLinkUrl.getGroupMasterKey(), + groupInviteLinkUrl.getPassword(), + groupDetails.getJoinInfo(), + groupDetails.getAvatarBytes()); + + callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId())); + } catch (IOException e) { + callback.onError(JoinGroupError.NETWORK_ERROR); + } catch (GroupChangeBusyException e) { + callback.onError(JoinGroupError.BUSY); + } catch (GroupLinkNotActiveException e) { + callback.onError(JoinGroupError.GROUP_LINK_NOT_ACTIVE); + } catch (GroupChangeFailedException | MembershipNotSuitableForV2Exception e) { + callback.onError(JoinGroupError.FAILED); + } + }); + } + + @WorkerThread + private @NonNull GroupDetails getGroupDetails() + throws VerificationFailedException, IOException, GroupLinkNotActiveException + { + DecryptedGroupJoinInfo joinInfo = GroupManager.getGroupJoinInfoFromServer(context, + groupInviteLinkUrl.getGroupMasterKey(), + groupInviteLinkUrl.getPassword()); + + byte[] avatarBytes = tryGetAvatarBytes(joinInfo); + + return new GroupDetails(joinInfo, avatarBytes); + } + + private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) { + try { + return AvatarGroupsV2DownloadJob.downloadGroupAvatarBytes(context, groupInviteLinkUrl.getGroupMasterKey(), joinInfo.getAvatar()); + } catch (IOException e) { + Log.w(TAG, "Failed to get group avatar", e); + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinUpdateRequiredBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinUpdateRequiredBottomSheetDialogFragment.java new file mode 100644 index 00000000..e1689c83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinUpdateRequiredBottomSheetDialogFragment.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public final class GroupJoinUpdateRequiredBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private TextView groupJoinTitle; + private TextView groupJoinExplain; + private Button groupJoinButton; + + public static void show(@NonNull FragmentManager manager) { + new GroupJoinUpdateRequiredBottomSheetDialogFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_join_update_needed_bottom_sheet, container, false); + + groupJoinTitle = view.findViewById(R.id.group_join_update_title); + groupJoinButton = view.findViewById(R.id.group_join_update_button); + groupJoinExplain = view.findViewById(R.id.group_join_update_explain); + + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + groupJoinTitle.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links); + groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message); + groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal); + groupJoinButton.setOnClickListener(v -> { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()); + dismiss(); + }); + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java new file mode 100644 index 00000000..767d9b1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public class GroupJoinViewModel extends ViewModel { + + private final GroupJoinRepository repository; + private final MutableLiveData groupDetails = new MutableLiveData<>(); + private final MutableLiveData errors = new SingleLiveEvent<>(); + private final MutableLiveData joinErrors = new SingleLiveEvent<>(); + private final MutableLiveData busy = new MediatorLiveData<>(); + private final MutableLiveData joinSuccess = new SingleLiveEvent<>(); + + private GroupJoinViewModel(@NonNull GroupJoinRepository repository) { + this.repository = repository; + + busy.setValue(true); + repository.getGroupDetails(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable GroupDetails details) { + busy.postValue(false); + groupDetails.postValue(details); + } + + @Override + public void onError(@Nullable FetchGroupDetailsError error) { + busy.postValue(false); + errors.postValue(error); + } + }); + } + + void join(@NonNull GroupDetails groupDetails) { + busy.setValue(true); + repository.joinGroup(groupDetails, new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable JoinGroupSuccess result) { + busy.postValue(false); + joinSuccess.postValue(result); + } + + @Override + public void onError(@Nullable JoinGroupError error) { + busy.postValue(false); + joinErrors.postValue(error); + } + }); + } + + LiveData getGroupDetails() { + return groupDetails; + } + + LiveData getJoinSuccess() { + return joinSuccess; + } + + LiveData isBusy() { + return busy; + } + + LiveData getErrors() { + return errors; + } + + LiveData getJoinErrors() { + return joinErrors; + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupInviteLinkUrl groupInviteLinkUrl; + + public Factory(@NonNull Context context, @NonNull GroupInviteLinkUrl groupInviteLinkUrl) { + this.context = context; + this.groupInviteLinkUrl = groupInviteLinkUrl; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new GroupJoinViewModel(new GroupJoinRepository(context.getApplicationContext(), groupInviteLinkUrl)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java new file mode 100644 index 00000000..059b2ce2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +enum JoinGroupError { + BUSY, + GROUP_LINK_NOT_ACTIVE, + FAILED, + NETWORK_ERROR, +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java new file mode 100644 index 00000000..59655cc0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +import org.thoughtcrime.securesms.recipients.Recipient; + +final class JoinGroupSuccess { + private final Recipient groupRecipient; + private final long groupThreadId; + + JoinGroupSuccess(Recipient groupRecipient, long groupThreadId) { + this.groupRecipient = groupRecipient; + this.groupThreadId = groupThreadId; + } + + Recipient getGroupRecipient() { + return groupRecipient; + } + + long getGroupThreadId() { + return groupThreadId; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java new file mode 100644 index 00000000..d6329faf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +final class RequestConfirmationDialog { + + private RequestConfirmationDialog() { + } + + /** + * Confirms that you want to approve or deny a request to join the group depending on + * {@param approve}. + */ + static AlertDialog show(@NonNull Context context, + @NonNull Recipient requester, + boolean approve, + @NonNull Runnable onApproveOrDeny) + { + if (approve) { + return showRequestApproveConfirmationDialog(context, requester, onApproveOrDeny); + } else { + return showRequestDenyConfirmationDialog(context, requester, onApproveOrDeny); + } + } + + /** + * Confirms that you want to approve a request to join the group. + */ + private static AlertDialog showRequestApproveConfirmationDialog(@NonNull Context context, + @NonNull Recipient requester, + @NonNull Runnable onApprove) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.RequestConfirmationDialog_add_s_to_the_group, + requester.getDisplayName(context))) + .setPositiveButton(R.string.RequestConfirmationDialog_add, (dialog, which) -> onApprove.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Confirms that you want to deny a request to join the group. + */ + private static AlertDialog showRequestDenyConfirmationDialog(@NonNull Context context, + @NonNull Recipient requester, + @NonNull Runnable onDeny) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.RequestConfirmationDialog_deny_request_from_s, + requester.getDisplayName(context))) + .setPositiveButton(R.string.RequestConfirmationDialog_deny, (dialog, which) -> onDeny.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java new file mode 100644 index 00000000..0bcd6283 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +public class RequestingMemberInvitesViewModel extends ViewModel { + + private final Context context; + private final RequestingMemberRepository requestingMemberRepository; + private final MutableLiveData toasts; + private final LiveData> requesting; + + private RequestingMemberInvitesViewModel(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull RequestingMemberRepository requestingMemberRepository) + { + this.context = context; + this.requestingMemberRepository = requestingMemberRepository; + this.requesting = new LiveGroup(groupId).getRequestingMembers(); + this.toasts = new SingleLiveEvent<>(); + } + + LiveData> getRequesting() { + return requesting; + } + + LiveData getToasts() { + return toasts; + } + + void approveRequestFor(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + approveOrDeny(requestingMember, true); + } + + void denyRequestFor(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + approveOrDeny(requestingMember, false); + } + + private void approveOrDeny(@NonNull GroupMemberEntry.RequestingMember requestingMember, boolean approve) { + RequestConfirmationDialog.show(context, requestingMember.getRequester(), approve, () -> { + Set memberAsSet = Collections.singleton(requestingMember.getRequester().getId()); + + if (approve) { + requestingMember.setBusy(true); + requestingMemberRepository.approveRequests(memberAsSet, new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(R.string.RequestingMembersFragment_added_s, requestingMember.getRequester().getDisplayName(context))); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } else { + requestingMember.setBusy(true); + requestingMemberRepository.denyRequests(memberAsSet, new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(R.string.RequestingMembersFragment_denied_s, requestingMember.getRequester().getDisplayName(context))); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } + }); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupId.V2 groupId; + + public Factory(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new RequestingMemberInvitesViewModel(context, groupId, new RequestingMemberRepository(context.getApplicationContext(), groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java new file mode 100644 index 00000000..ae2d811e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; + +import java.io.IOException; +import java.util.Collection; + +/** + * Repository for modifying the requesting members on a single group. + */ +final class RequestingMemberRepository { + + private static final String TAG = Log.tag(RequestingMemberRepository.class); + + private final Context context; + private final GroupId.V2 groupId; + + RequestingMemberRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context.getApplicationContext(); + this.groupId = groupId; + } + + void approveRequests(@NonNull Collection recipientIds, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.approveRequests(context, groupId, recipientIds); + callback.onComplete(null); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void denyRequests(@NonNull Collection recipientIds, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.denyRequests(context, groupId, recipientIds); + callback.onComplete(null); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java new file mode 100644 index 00000000..04c2ab22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.AdminActionsListener; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.BottomSheetUtil; + +import java.util.Objects; + +/** + * Lists and allows approval/denial of people requesting access to the group. + */ +public class RequestingMembersFragment extends Fragment { + + private static final String GROUP_ID = "GROUP_ID"; + + private RequestingMemberInvitesViewModel viewModel; + private GroupMemberListView requestingMembers; + private View noRequestingMessage; + private View requestingExplanation; + + public static RequestingMembersFragment newInstance(@NonNull GroupId.V2 groupId) { + RequestingMembersFragment fragment = new RequestingMembersFragment(); + Bundle args = new Bundle(); + + args.putString(GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_requesting_member_fragment, container, false); + + requestingMembers = view.findViewById(R.id.requesting_members); + noRequestingMessage = view.findViewById(R.id.no_requesting); + requestingExplanation = view.findViewById(R.id.requesting_members_explain); + + requestingMembers.setRecipientClickListener(recipient -> + RecipientBottomSheetDialogFragment.create(recipient.getId(), null) + .show(requireActivity().getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)); + + requestingMembers.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + throw new UnsupportedOperationException(); + } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + viewModel.approveRequestFor(requestingMember); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + viewModel.denyRequestFor(requestingMember); + } + }); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))).requireV2(); + + RequestingMemberInvitesViewModel.Factory factory = new RequestingMemberInvitesViewModel.Factory(requireContext(), groupId); + + viewModel = ViewModelProviders.of(requireActivity(), factory).get(RequestingMemberInvitesViewModel.class); + + viewModel.getRequesting().observe(getViewLifecycleOwner(), requesting -> { + requestingMembers.setMembers(requesting); + noRequestingMessage.setVisibility(requesting.isEmpty() ? View.VISIBLE: View.GONE); + requestingExplanation.setVisibility(requesting.isEmpty() ? View.GONE : View.VISIBLE); + }); + + viewModel.getToasts().observe(getViewLifecycleOwner(), toast -> Toast.makeText(requireContext(), toast, Toast.LENGTH_SHORT).show()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java new file mode 100644 index 00000000..52609d71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityOptionsCompat; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class ManageGroupActivity extends PassphraseRequiredActivity { + + private static final String GROUP_ID = "GROUP_ID"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull GroupId groupId) { + Intent intent = new Intent(context, ManageGroupActivity.class); + intent.putExtra(GROUP_ID, groupId.toString()); + return intent; + } + + public static @Nullable Bundle createTransitionBundle(@NonNull Context activityContext, @NonNull View from) { + if (activityContext instanceof Activity) { + return ActivityOptionsCompat.makeSceneTransitionAnimation((Activity) activityContext, from, "avatar").toBundle(); + } else { + return null; + } + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + getWindow().getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + setContentView(R.layout.group_manage_activity); + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, ManageGroupFragment.newInstance(getIntent().getStringExtra(GROUP_ID))) + .commitNow(); + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java new file mode 100644 index 00000000..7631539f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -0,0 +1,465 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.snackbar.Snackbar; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.AvatarPreviewActivity; +import org.thoughtcrime.securesms.InviteActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.MuteDialog; +import org.thoughtcrime.securesms.PushContactSelectionActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.ThreadPhotoRailView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public class ManageGroupFragment extends LoggingFragment { + private static final String GROUP_ID = "GROUP_ID"; + + private static final String TAG = Log.tag(ManageGroupFragment.class); + + private static final int RETURN_FROM_MEDIA = 33114; + private static final int PICK_CONTACT = 61341; + public static final String DIALOG_TAG = "DIALOG"; + + private ManageGroupViewModel viewModel; + private GroupMemberListView groupMemberList; + private View pendingAndRequestingRow; + private TextView pendingAndRequestingCount; + private Toolbar toolbar; + private TextView groupName; + private LearnMoreTextView groupInfoText; + private TextView memberCountUnderAvatar; + private TextView memberCountAboveList; + private AvatarImageView avatar; + private ThreadPhotoRailView threadPhotoRailView; + private View groupMediaCard; + private View accessControlCard; + private View groupLinkCard; + private ManageGroupViewModel.CursorFactory cursorFactory; + private View sharedMediaRow; + private View editGroupAccessRow; + private TextView editGroupAccessValue; + private View editGroupMembershipRow; + private TextView editGroupMembershipValue; + private View disappearingMessagesCard; + private View disappearingMessagesRow; + private TextView disappearingMessages; + private View blockAndLeaveCard; + private TextView blockGroup; + private TextView unblockGroup; + private TextView leaveGroup; + private TextView addMembers; + private SwitchCompat muteNotificationsSwitch; + private View muteNotificationsRow; + private TextView muteNotificationsUntilLabel; + private TextView customNotificationsButton; + private View customNotificationsRow; + private View mentionsRow; + private TextView mentionsValue; + private View toggleAllMembers; + private View groupLinkRow; + private TextView groupLinkButton; + private View wallpaperButton; + + private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() { + @Override + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new FallbackPhoto80dp(R.drawable.ic_group_80, MaterialColor.ULTRAMARINE.toAvatarColor(requireContext())); + } + }; + + static ManageGroupFragment newInstance(@NonNull String groupId) { + ManageGroupFragment fragment = new ManageGroupFragment(); + Bundle args = new Bundle(); + + args.putString(GROUP_ID, groupId); + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + View view = inflater.inflate(R.layout.group_manage_fragment, container, false); + + avatar = view.findViewById(R.id.group_avatar); + toolbar = view.findViewById(R.id.toolbar); + groupName = view.findViewById(R.id.name); + groupInfoText = view.findViewById(R.id.manage_group_info_text); + memberCountUnderAvatar = view.findViewById(R.id.member_count); + memberCountAboveList = view.findViewById(R.id.member_count_2); + groupMemberList = view.findViewById(R.id.group_members); + pendingAndRequestingRow = view.findViewById(R.id.pending_and_requesting_members_row); + pendingAndRequestingCount = view.findViewById(R.id.pending_and_requesting_members_count); + threadPhotoRailView = view.findViewById(R.id.recent_photos); + groupMediaCard = view.findViewById(R.id.group_media_card); + accessControlCard = view.findViewById(R.id.group_access_control_card); + groupLinkCard = view.findViewById(R.id.group_link_card); + sharedMediaRow = view.findViewById(R.id.shared_media_row); + editGroupAccessRow = view.findViewById(R.id.edit_group_access_row); + editGroupAccessValue = view.findViewById(R.id.edit_group_access_value); + editGroupMembershipRow = view.findViewById(R.id.edit_group_membership_row); + editGroupMembershipValue = view.findViewById(R.id.edit_group_membership_value); + disappearingMessagesCard = view.findViewById(R.id.group_disappearing_messages_card); + disappearingMessagesRow = view.findViewById(R.id.disappearing_messages_row); + disappearingMessages = view.findViewById(R.id.disappearing_messages); + blockAndLeaveCard = view.findViewById(R.id.group_block_and_leave_card); + blockGroup = view.findViewById(R.id.blockGroup); + unblockGroup = view.findViewById(R.id.unblockGroup); + leaveGroup = view.findViewById(R.id.leaveGroup); + addMembers = view.findViewById(R.id.add_members); + muteNotificationsUntilLabel = view.findViewById(R.id.group_mute_notifications_until); + muteNotificationsSwitch = view.findViewById(R.id.group_mute_notifications_switch); + muteNotificationsRow = view.findViewById(R.id.group_mute_notifications_row); + customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button); + customNotificationsRow = view.findViewById(R.id.group_custom_notifications_row); + mentionsRow = view.findViewById(R.id.group_mentions_row); + mentionsValue = view.findViewById(R.id.group_mentions_value); + toggleAllMembers = view.findViewById(R.id.toggle_all_members); + groupLinkRow = view.findViewById(R.id.group_link_row); + groupLinkButton = view.findViewById(R.id.group_link_button); + wallpaperButton = view.findViewById(R.id.chat_wallpaper); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + Context context = requireContext(); + GroupId groupId = getGroupId(); + ManageGroupViewModel.Factory factory = new ManageGroupViewModel.Factory(context, groupId); + + disappearingMessagesCard.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); + blockAndLeaveCard.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); + + viewModel = ViewModelProviders.of(requireActivity(), factory).get(ManageGroupViewModel.class); + + viewModel.getMembers().observe(getViewLifecycleOwner(), members -> groupMemberList.setMembers(members)); + + viewModel.getCanCollapseMemberList().observe(getViewLifecycleOwner(), canCollapseMemberList -> { + if (canCollapseMemberList) { + toggleAllMembers.setVisibility(View.VISIBLE); + toggleAllMembers.setOnClickListener(v -> viewModel.revealCollapsedMembers()); + } else { + toggleAllMembers.setVisibility(View.GONE); + } + }); + + viewModel.getPendingAndRequestingCount().observe(getViewLifecycleOwner(), pendingAndRequestingCount -> { + pendingAndRequestingRow.setOnClickListener(v -> { + FragmentActivity activity = requireActivity(); + activity.startActivity(ManagePendingAndRequestingMembersActivity.newIntent(activity, groupId.requireV2())); + }); + if (pendingAndRequestingCount == 0) { + this.pendingAndRequestingCount.setVisibility(View.GONE); + } else { + this.pendingAndRequestingCount.setText(String.format(Locale.getDefault(), "%d", pendingAndRequestingCount)); + this.pendingAndRequestingCount.setVisibility(View.VISIBLE); + } + }); + + avatar.setFallbackPhotoProvider(fallbackPhotoProvider); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); + toolbar.inflateMenu(R.menu.manage_group_fragment); + + viewModel.getCanEditGroupAttributes().observe(getViewLifecycleOwner(), canEdit -> { + toolbar.getMenu().findItem(R.id.action_edit).setVisible(canEdit); + disappearingMessages.setEnabled(canEdit); + disappearingMessagesRow.setEnabled(canEdit); + }); + + viewModel.getTitle().observe(getViewLifecycleOwner(), groupName::setText); + viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCountUnderAvatar::setText); + viewModel.getFullMemberCountSummary().observe(getViewLifecycleOwner(), memberCountAboveList::setText); + viewModel.getGroupRecipient().observe(getViewLifecycleOwner(), groupRecipient -> { + avatar.setRecipient(groupRecipient); + avatar.setOnClickListener(v -> { + FragmentActivity activity = requireActivity(); + activity.startActivity(AvatarPreviewActivity.intentFromRecipientId(activity, groupRecipient.getId()), + AvatarPreviewActivity.createTransitionBundle(activity, avatar)); + }); + customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(groupRecipient.getId()) + .show(requireFragmentManager(), DIALOG_TAG)); + wallpaperButton.setOnClickListener(v -> startActivity(ChatWallpaperActivity.createIntent(requireContext(), groupRecipient.getId()))); + }); + + if (groupId.isV2()) { + groupLinkRow.setOnClickListener(v -> ShareableGroupLinkDialogFragment.create(groupId.requireV2()) + .show(requireFragmentManager(), DIALOG_TAG)); + viewModel.getGroupLinkOn().observe(getViewLifecycleOwner(), linkEnabled -> groupLinkButton.setText(booleanToOnOff(linkEnabled))); + } + + viewModel.getGroupViewState().observe(getViewLifecycleOwner(), vs -> { + if (vs == null) return; + sharedMediaRow.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(context, vs.getThreadId()))); + + setMediaCursorFactory(vs.getMediaCursorFactory()); + + threadPhotoRailView.setListener(mediaRecord -> + startActivityForResult(MediaPreviewActivity.intentFromMediaRecord(context, + mediaRecord, + ViewUtil.isLtr(threadPhotoRailView)), + RETURN_FROM_MEDIA)); + + groupLinkCard.setVisibility(vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE); + }); + + leaveGroup.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); + leaveGroup.setOnClickListener(v -> LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupId.requirePush(), () -> startActivity(MainActivity.clearTop(context)))); + + viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); + + disappearingMessagesRow.setOnClickListener(v -> viewModel.handleExpirationSelection()); + blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity())); + unblockGroup.setOnClickListener(v -> viewModel.unblock(requireActivity())); + + addMembers.setOnClickListener(v -> viewModel.onAddMembersClick(this, PICK_CONTACT)); + + viewModel.getMembershipRights().observe(getViewLifecycleOwner(), r -> { + if (r != null) { + editGroupMembershipValue.setText(r.getString()); + editGroupMembershipRow.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.MEMBERSHIP, r, (from, to) -> viewModel.applyMembershipRightsChange(to)).show()); + } + } + ); + + viewModel.getEditGroupAttributesRights().observe(getViewLifecycleOwner(), r -> { + if (r != null) { + editGroupAccessValue.setText(r.getString()); + editGroupAccessRow.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.ATTRIBUTES, r, (from, to) -> viewModel.applyAttributesRightsChange(to)).show()); + } + } + ); + + viewModel.getIsAdmin().observe(getViewLifecycleOwner(), admin -> { + accessControlCard.setVisibility(admin ? View.VISIBLE : View.GONE); + editGroupMembershipRow.setEnabled(admin); + editGroupMembershipValue.setEnabled(admin); + editGroupAccessRow.setEnabled(admin); + editGroupAccessValue.setEnabled(admin); + }); + + viewModel.getCanAddMembers().observe(getViewLifecycleOwner(), canEdit -> addMembers.setVisibility(canEdit ? View.VISIBLE : View.GONE)); + + groupMemberList.setRecipientClickListener(recipient -> RecipientBottomSheetDialogFragment.create(recipient.getId(), groupId).show(requireFragmentManager(), "BOTTOM")); + groupMemberList.setOverScrollMode(View.OVER_SCROLL_NEVER); + + final CompoundButton.OnCheckedChangeListener muteSwitchListener = (buttonView, isChecked) -> { + if (isChecked) { + MuteDialog.show(context, viewModel::setMuteUntil, () -> muteNotificationsSwitch.setChecked(false)); + } else { + viewModel.clearMuteUntil(); + } + }; + + muteNotificationsRow.setOnClickListener(v -> { + if (muteNotificationsSwitch.isEnabled()) { + muteNotificationsSwitch.toggle(); + } + }); + + viewModel.getMuteState().observe(getViewLifecycleOwner(), muteState -> { + if (muteNotificationsSwitch.isChecked() != muteState.isMuted()) { + muteNotificationsSwitch.setOnCheckedChangeListener(null); + muteNotificationsSwitch.setChecked(muteState.isMuted()); + } + + muteNotificationsSwitch.setEnabled(true); + muteNotificationsSwitch.setOnCheckedChangeListener(muteSwitchListener); + muteNotificationsUntilLabel.setVisibility(muteState.isMuted() ? View.VISIBLE : View.GONE); + + if (muteState.isMuted()) { + muteNotificationsUntilLabel.setText(getString(R.string.ManageGroupActivity_until_s, + DateUtils.getTimeString(requireContext(), + Locale.getDefault(), + muteState.getMutedUntil()))); + } + }); + + customNotificationsRow.setVisibility(View.VISIBLE); + + if (NotificationChannels.supported()) { + viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { + customNotificationsButton.setText(booleanToOnOff(hasCustomNotifications)); + }); + } + + mentionsRow.setVisibility(groupId.isV2() ? View.VISIBLE : View.GONE); + mentionsRow.setOnClickListener(v -> viewModel.handleMentionNotificationSelection()); + viewModel.getMentionSetting().observe(getViewLifecycleOwner(), value -> mentionsValue.setText(value)); + + viewModel.getCanLeaveGroup().observe(getViewLifecycleOwner(), canLeave -> leaveGroup.setVisibility(canLeave ? View.VISIBLE : View.GONE)); + viewModel.getCanBlockGroup().observe(getViewLifecycleOwner(), canBlock -> blockGroup.setVisibility(canBlock ? View.VISIBLE : View.GONE)); + viewModel.getCanUnblockGroup().observe(getViewLifecycleOwner(), canUnblock -> unblockGroup.setVisibility(canUnblock ? View.VISIBLE : View.GONE)); + + viewModel.getGroupInfoMessage().observe(getViewLifecycleOwner(), message -> { + switch (message) { + case LEGACY_GROUP_LEARN_MORE: + groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more); + groupInfoText.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager())); + groupInfoText.setLearnMoreVisible(true); + groupInfoText.setVisibility(View.VISIBLE); + break; + case LEGACY_GROUP_UPGRADE: + groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade); + groupInfoText.setOnLinkClickListener(v -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(requireFragmentManager(), Recipient.externalPossiblyMigratedGroup(requireContext(), groupId).getId())); + groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group); + groupInfoText.setVisibility(View.VISIBLE); + break; + case LEGACY_GROUP_TOO_LARGE: + groupInfoText.setText(context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().getHardLimit() - 1)); + groupInfoText.setLearnMoreVisible(false); + groupInfoText.setVisibility(View.VISIBLE); + break; + case MMS_WARNING: + groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group); + groupInfoText.setOnLinkClickListener(v -> startActivity(new Intent(requireContext(), InviteActivity.class))); + groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now); + groupInfoText.setVisibility(View.VISIBLE); + break; + default: + groupInfoText.setVisibility(View.GONE); + break; + } + }); + } + + private static int booleanToOnOff(boolean isOn) { + return isOn ? R.string.ManageGroupActivity_on + : R.string.ManageGroupActivity_off; + } + + public boolean onMenuItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.action_edit) { + startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), getGroupId())); + return true; + } + + return false; + } + + private GroupId getGroupId() { + return GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))); + } + + private void setMediaCursorFactory(@Nullable ManageGroupViewModel.CursorFactory cursorFactory) { + if (this.cursorFactory != cursorFactory) { + this.cursorFactory = cursorFactory; + applyMediaCursorFactory(); + } + } + + private void applyMediaCursorFactory() { + Context context = getContext(); + if (context == null) return; + if (this.cursorFactory != null) { + Cursor cursor = this.cursorFactory.create(); + getViewLifecycleOwner().getLifecycle().addObserver(new LifecycleCursorWrapper(cursor)); + + threadPhotoRailView.setCursor(GlideApp.with(context), cursor); + groupMediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE); + } else { + threadPhotoRailView.setCursor(GlideApp.with(context), null); + groupMediaCard.setVisibility(View.GONE); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == RETURN_FROM_MEDIA) { + applyMediaCursorFactory(); + } else if (requestCode == PICK_CONTACT && data != null) { + List selected = data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS); + SimpleProgressDialog.DismissibleDialog progress = SimpleProgressDialog.showDelayed(requireContext()); + + viewModel.onAddMembers(selected, new AsynchronousCallback.MainThread() { + @Override + public void onComplete(ManageGroupViewModel.AddMembersResult result) { + progress.dismiss(); + if (!result.getNewInvitedMembers().isEmpty()) { + GroupInviteSentDialog.showInvitesSent(requireContext(), result.getNewInvitedMembers()); + } + + if (result.getNumberOfMembersAdded() > 0) { + String string = getResources().getQuantityString(R.plurals.ManageGroupActivity_added, + result.getNumberOfMembersAdded(), + result.getNumberOfMembersAdded()); + Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show(); + } + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + progress.dismiss(); + Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_LONG).show(); + } + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java new file mode 100644 index 00000000..d0fd5e03 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.groups.GroupAccessControl; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +final class ManageGroupRepository { + + private static final String TAG = Log.tag(ManageGroupRepository.class); + + private final Context context; + + ManageGroupRepository(@NonNull Context context) { + this.context = context; + } + + void getGroupState(@NonNull GroupId groupId, @NonNull Consumer onGroupStateLoaded) { + SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState(groupId))); + } + + void getGroupCapacity(@NonNull GroupId groupId, @NonNull Consumer onGroupCapacityLoaded) { + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get(); + if (groupRecord.isV2Group()) { + DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); + List pendingMembers = Stream.of(decryptedGroup.getPendingMembersList()) + .map(member -> GroupProtoUtil.uuidByteStringToRecipientId(member.getUuid())) + .toList(); + List members = new LinkedList<>(groupRecord.getMembers()); + + members.addAll(pendingMembers); + + return new GroupCapacityResult(members, FeatureFlags.groupLimits()); + } else { + return new GroupCapacityResult(groupRecord.getMembers(), FeatureFlags.groupLimits()); + } + }, onGroupCapacityLoaded::accept); + } + + @WorkerThread + private GroupStateResult getGroupState(@NonNull GroupId groupId) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + long threadId = threadDatabase.getThreadIdFor(groupRecipient); + + return new GroupStateResult(threadId, groupRecipient); + } + + void setExpiration(@NonNull GroupId groupId, int newExpirationTime, @NonNull GroupChangeErrorCallback error) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.updateGroupTimer(context, groupId.requirePush(), newExpirationTime); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void applyMembershipRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void applyAttributesRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + public void getRecipient(@NonNull GroupId groupId, @NonNull Consumer recipientCallback) { + SimpleTask.run(SignalExecutors.BOUNDED, + () -> Recipient.externalGroupExact(context, groupId), + recipientCallback::accept); + } + + void setMuteUntil(@NonNull GroupId groupId, long until) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); + DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until); + }); + } + + void addMembers(@NonNull GroupId groupId, + @NonNull List selected, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.GroupActionResult groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected); + callback.onComplete(new ManageGroupViewModel.AddMembersResult(groupActionResult.getAddedMemberCount(), Recipient.resolvedList(groupActionResult.getInvitedMembers()))); + } catch (GroupChangeException | MembershipNotSuitableForV2Exception | IOException e) { + Log.w(TAG, e); + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void blockAndLeaveGroup(@NonNull GroupId groupId, @NonNull GroupChangeErrorCallback error, @NonNull Runnable onSuccess) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + RecipientUtil.block(context, Recipient.externalGroupExact(context, groupId)); + onSuccess.run(); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void setMentionSetting(@NonNull GroupId groupId, RecipientDatabase.MentionSetting mentionSetting) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); + DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting); + }); + } + + static final class GroupStateResult { + + private final long threadId; + private final Recipient recipient; + + private GroupStateResult(long threadId, + Recipient recipient) + { + this.threadId = threadId; + this.recipient = recipient; + } + + long getThreadId() { + return threadId; + } + + Recipient getRecipient() { + return recipient; + } + } + + static final class GroupCapacityResult { + private final List members; + private final SelectionLimits selectionLimits; + + GroupCapacityResult(@NonNull List members, @NonNull SelectionLimits selectionLimits) { + this.members = members; + this.selectionLimits = selectionLimits; + } + + public @NonNull List getMembers() { + return members; + } + + public int getSelectionLimit() { + if (!selectionLimits.hasHardLimit()) { + return ContactSelectionListFragment.NO_LIMIT; + } + + boolean containsSelf = members.indexOf(Recipient.self().getId()) != -1; + + return selectionLimits.getHardLimit() - (containsSelf ? 1 : 0); + } + + public int getSelectionWarning() { + if (!selectionLimits.hasRecommendedLimit()) { + return ContactSelectionListFragment.NO_LIMIT; + } + + boolean containsSelf = members.indexOf(Recipient.self().getId()) != -1; + + return selectionLimits.getRecommendedLimit() - (containsSelf ? 1 : 0); + } + + public int getRemainingCapacity() { + return selectionLimits.getHardLimit() - members.size(); + } + + public @NonNull List getMembersWithoutSelf() { + ArrayList recipientIds = new ArrayList<>(members.size()); + RecipientId selfId = Recipient.self().getId(); + + for (RecipientId recipientId : members) { + if (!recipientId.equals(selfId)) { + recipientIds.add(recipientId); + } + } + + return recipientIds; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java new file mode 100644 index 00000000..665ea852 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -0,0 +1,437 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.text.TextUtils; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.ExpirationDialog; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.loaders.MediaLoader; +import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; +import org.thoughtcrime.securesms.groups.GroupAccessControl; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupMentionSettingDialog; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.util.ArrayList; +import java.util.List; + +public class ManageGroupViewModel extends ViewModel { + + private static final int MAX_UNCOLLAPSED_MEMBERS = 6; + private static final int SHOW_COLLAPSED_MEMBERS = 5; + + private final Context context; + private final ManageGroupRepository manageGroupRepository; + private final LiveData title; + private final LiveData isAdmin; + private final LiveData canEditGroupAttributes; + private final LiveData canAddMembers; + private final LiveData> members; + private final LiveData pendingMemberCount; + private final LiveData pendingAndRequestingCount; + private final LiveData disappearingMessageTimer; + private final LiveData memberCountSummary; + private final LiveData fullMemberCountSummary; + private final LiveData editMembershipRights; + private final LiveData editGroupAttributesRights; + private final LiveData groupRecipient; + private final MutableLiveData groupViewState = new MutableLiveData<>(null); + private final LiveData muteState; + private final LiveData hasCustomNotifications; + private final LiveData canCollapseMemberList; + private final DefaultValueLiveData memberListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); + private final LiveData canLeaveGroup; + private final LiveData canBlockGroup; + private final LiveData canUnblockGroup; + private final LiveData showLegacyIndicator; + private final LiveData mentionSetting; + private final LiveData groupLinkOn; + private final LiveData groupInfoMessage; + + private ManageGroupViewModel(@NonNull Context context, @NonNull GroupId groupId, @NonNull ManageGroupRepository manageGroupRepository) { + this.context = context; + this.manageGroupRepository = manageGroupRepository; + + manageGroupRepository.getGroupState(groupId, this::groupStateLoaded); + + LiveGroup liveGroup = new LiveGroup(groupId); + + this.title = Transformations.map(liveGroup.getTitle(), + title -> TextUtils.isEmpty(title) ? context.getString(R.string.Recipient_unknown) + : title); + this.groupRecipient = liveGroup.getGroupRecipient(); + this.isAdmin = liveGroup.isSelfAdmin(); + this.canCollapseMemberList = LiveDataUtil.combineLatest(memberListCollapseState, + Transformations.map(liveGroup.getFullMembers(), m -> m.size() > MAX_UNCOLLAPSED_MEMBERS), + (state, hasEnoughMembers) -> state != CollapseState.OPEN && hasEnoughMembers); + this.members = LiveDataUtil.combineLatest(liveGroup.getFullMembers(), + memberListCollapseState, + ManageGroupViewModel::filterMemberList); + this.pendingMemberCount = liveGroup.getPendingMemberCount(); + this.pendingAndRequestingCount = liveGroup.getPendingAndRequestingMemberCount(); + this.showLegacyIndicator = Transformations.map(groupRecipient, recipient -> recipient.requireGroupId().isV1()); + this.memberCountSummary = LiveDataUtil.combineLatest(liveGroup.getMembershipCountDescription(context.getResources()), + this.showLegacyIndicator, + (description, legacy) -> legacy ? String.format("%s · %s", description, context.getString(R.string.ManageGroupActivity_legacy_group)) + : description); + this.fullMemberCountSummary = liveGroup.getFullMembershipCountDescription(context.getResources()); + this.editMembershipRights = liveGroup.getMembershipAdditionAccessControl(); + this.editGroupAttributesRights = liveGroup.getAttributesAccessControl(); + this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration)); + this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes(); + this.canAddMembers = liveGroup.selfCanAddMembers(); + this.muteState = Transformations.map(this.groupRecipient, + recipient -> new MuteState(recipient.getMuteUntil(), recipient.isMuted())); + this.hasCustomNotifications = Transformations.map(this.groupRecipient, + recipient -> recipient.getNotificationChannel() != null || !NotificationChannels.supported()); + this.canLeaveGroup = liveGroup.isActive(); + this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> RecipientUtil.isBlockable(recipient) && !recipient.isBlocked()); + this.canUnblockGroup = Transformations.map(this.groupRecipient, Recipient::isBlocked); + this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient, + recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting()))); + this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled); + this.groupInfoMessage = Transformations.map(this.groupRecipient, + recipient -> { + boolean showLegacyInfo = recipient.requireGroupId().isV1(); + + if (showLegacyInfo && recipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + return GroupInfoMessage.LEGACY_GROUP_TOO_LARGE; + } else if (showLegacyInfo) { + return GroupInfoMessage.LEGACY_GROUP_UPGRADE; + } else if (groupId.isMms()) { + return GroupInfoMessage.MMS_WARNING; + } else { + return GroupInfoMessage.NONE; + } + }); + } + + @WorkerThread + private void groupStateLoaded(@NonNull ManageGroupRepository.GroupStateResult groupStateResult) { + groupViewState.postValue(new GroupViewState(groupStateResult.getThreadId(), + groupStateResult.getRecipient(), + () -> new ThreadMediaLoader(context, groupStateResult.getThreadId(), MediaLoader.MediaType.GALLERY, MediaDatabase.Sorting.Newest).getCursor())); + } + + LiveData> getMembers() { + return members; + } + + LiveData getPendingMemberCount() { + return pendingMemberCount; + } + + LiveData getPendingAndRequestingCount() { + return pendingAndRequestingCount; + } + + LiveData getMemberCountSummary() { + return memberCountSummary; + } + + LiveData getFullMemberCountSummary() { + return fullMemberCountSummary; + } + + LiveData getGroupRecipient() { + return groupRecipient; + } + + LiveData getGroupViewState() { + return groupViewState; + } + + LiveData getTitle() { + return title; + } + + LiveData getMuteState() { + return muteState; + } + + LiveData getMembershipRights() { + return editMembershipRights; + } + + LiveData getEditGroupAttributesRights() { + return editGroupAttributesRights; + } + + LiveData getIsAdmin() { + return isAdmin; + } + + LiveData getCanEditGroupAttributes() { + return canEditGroupAttributes; + } + + LiveData getCanAddMembers() { + return canAddMembers; + } + + LiveData getDisappearingMessageTimer() { + return disappearingMessageTimer; + } + + LiveData hasCustomNotifications() { + return hasCustomNotifications; + } + + LiveData getCanCollapseMemberList() { + return canCollapseMemberList; + } + + LiveData getCanBlockGroup() { + return canBlockGroup; + } + + LiveData getCanUnblockGroup() { + return canUnblockGroup; + } + + LiveData getCanLeaveGroup() { + return canLeaveGroup; + } + + LiveData getMentionSetting() { + return mentionSetting; + } + + LiveData getGroupLinkOn() { + return groupLinkOn; + } + + LiveData getGroupInfoMessage() { + return groupInfoMessage; + } + + void handleExpirationSelection() { + manageGroupRepository.getRecipient(getGroupId(), + groupRecipient -> + ExpirationDialog.show(context, + groupRecipient.getExpireMessages(), + expirationTime -> manageGroupRepository.setExpiration(getGroupId(), expirationTime, this::showErrorToast))); + } + + void applyMembershipRightsChange(@NonNull GroupAccessControl newRights) { + manageGroupRepository.applyMembershipRightsChange(getGroupId(), newRights, this::showErrorToast); + } + + void applyAttributesRightsChange(@NonNull GroupAccessControl newRights) { + manageGroupRepository.applyAttributesRightsChange(getGroupId(), newRights, this::showErrorToast); + } + + void blockAndLeave(@NonNull FragmentActivity activity) { + manageGroupRepository.getRecipient(getGroupId(), + recipient -> BlockUnblockDialog.showBlockFor(activity, + activity.getLifecycle(), + recipient, + this::onBlockAndLeaveConfirmed)); + } + + void unblock(@NonNull FragmentActivity activity) { + manageGroupRepository.getRecipient(getGroupId(), + recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, + () -> RecipientUtil.unblock(context, recipient))); + } + + void onAddMembers(@NonNull List selected, + @NonNull AsynchronousCallback.MainThread callback) + { + manageGroupRepository.addMembers(getGroupId(), selected, callback.toWorkerCallback()); + } + + void setMuteUntil(long muteUntil) { + manageGroupRepository.setMuteUntil(getGroupId(), muteUntil); + } + + void clearMuteUntil() { + manageGroupRepository.setMuteUntil(getGroupId(), 0); + } + + void revealCollapsedMembers() { + memberListCollapseState.setValue(CollapseState.OPEN); + } + + void handleMentionNotificationSelection() { + manageGroupRepository.getRecipient(getGroupId(), r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), setting -> manageGroupRepository.setMentionSetting(getGroupId(), setting))); + } + + private void onBlockAndLeaveConfirmed() { + SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context); + + manageGroupRepository.blockAndLeaveGroup(getGroupId(), + e -> { + dismissibleDialog.dismiss(); + showErrorToast(e); + }, + dismissibleDialog::dismiss); + } + + private @NonNull GroupId getGroupId() { + return groupRecipient.getValue().requireGroupId(); + } + + private static @NonNull List filterMemberList(@NonNull List members, + @NonNull CollapseState collapseState) + { + if (collapseState == CollapseState.COLLAPSED && members.size() > MAX_UNCOLLAPSED_MEMBERS) { + return members.subList(0, SHOW_COLLAPSED_MEMBERS); + } else { + return members; + } + } + + @WorkerThread + private void showErrorToast(@NonNull GroupChangeFailureReason e) { + Util.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); + } + + public void onAddMembersClick(@NonNull Fragment fragment, int resultCode) { + manageGroupRepository.getGroupCapacity(getGroupId(), capacity -> { + int remainingCapacity = capacity.getRemainingCapacity(); + if (remainingCapacity <= 0) { + GroupLimitDialog.showHardLimitMessage(fragment.requireContext()); + } else { + Intent intent = new Intent(fragment.requireActivity(), AddMembersActivity.class); + intent.putExtra(AddMembersActivity.GROUP_ID, getGroupId().toString()); + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, new SelectionLimits(capacity.getSelectionWarning(), capacity.getSelectionLimit())); + intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(capacity.getMembersWithoutSelf())); + fragment.startActivityForResult(intent, resultCode); + } + }); + } + + static final class AddMembersResult { + private final int numberOfMembersAdded; + private final List newInvitedMembers; + + AddMembersResult(int numberOfMembersAdded, @NonNull List newInvitedMembers) { + this.numberOfMembersAdded = numberOfMembersAdded; + this.newInvitedMembers = newInvitedMembers; + } + + int getNumberOfMembersAdded() { + return numberOfMembersAdded; + } + + List getNewInvitedMembers() { + return newInvitedMembers; + } + } + + static final class GroupViewState { + private final long threadId; + @NonNull private final Recipient groupRecipient; + @NonNull private final CursorFactory mediaCursorFactory; + + private GroupViewState(long threadId, + @NonNull Recipient groupRecipient, + @NonNull CursorFactory mediaCursorFactory) + { + this.threadId = threadId; + this.groupRecipient = groupRecipient; + this.mediaCursorFactory = mediaCursorFactory; + } + + long getThreadId() { + return threadId; + } + + @NonNull Recipient getGroupRecipient() { + return groupRecipient; + } + + @NonNull CursorFactory getMediaCursorFactory() { + return mediaCursorFactory; + } + } + + static final class MuteState { + private final long mutedUntil; + private final boolean isMuted; + + MuteState(long mutedUntil, boolean isMuted) { + this.mutedUntil = mutedUntil; + this.isMuted = isMuted; + } + + public long getMutedUntil() { + return mutedUntil; + } + + public boolean isMuted() { + return isMuted; + } + } + + enum GroupInfoMessage { + NONE, + LEGACY_GROUP_LEARN_MORE, + LEGACY_GROUP_UPGRADE, + LEGACY_GROUP_TOO_LARGE, + MMS_WARNING + } + + private enum CollapseState { + OPEN, + COLLAPSED + } + + interface CursorFactory { + Cursor create(); + } + + public static class Factory implements ViewModelProvider.Factory { + private final Context context; + private final GroupId groupId; + + public Factory(@NonNull Context context, @NonNull GroupId groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new ManageGroupViewModel(context, groupId, new ManageGroupRepository(context.getApplicationContext())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupInviteSentDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupInviteSentDialog.java new file mode 100644 index 00000000..b118f079 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupInviteSentDialog.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.app.Dialog; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.ArrayList; +import java.util.List; + +public final class GroupInviteSentDialog { + + private GroupInviteSentDialog() { + } + + public static @Nullable Dialog showInvitesSent(@NonNull Context context, @NonNull List recipients) { + int size = recipients.size(); + if (size == 0) { + return null; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context) + .setTitle(context.getResources().getQuantityString(R.plurals.GroupManagement_invitation_sent, size, size)) + // TODO: GV2 Need a URL for learn more + // .setNegativeButton(R.string.GroupManagement_learn_more, (dialog, which) -> { + // }) + .setPositiveButton(android.R.string.ok, null); + if (size == 1) { + builder.setMessage(context.getString(R.string.GroupManagement_invite_single_user, recipients.get(0).getDisplayName(context))); + } else { + builder.setMessage(R.string.GroupManagement_invite_multiple_users) + .setView(R.layout.dialog_multiple_group_invites_sent); + } + + Dialog dialog = builder.show(); + if (size > 1) { + GroupMemberListView invitees = dialog.findViewById(R.id.list_invitees); + + List pendingMembers = new ArrayList<>(recipients.size()); + for (Recipient r : recipients) { + pendingMembers.add(new GroupMemberEntry.PendingMember(r)); + } + + //noinspection ConstantConditions + invitees.setMembers(pendingMembers); + } + + return dialog; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java new file mode 100644 index 00000000..fd22004b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckedTextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; + +public final class GroupMentionSettingDialog { + + public static void show(@NonNull Context context, @NonNull MentionSetting mentionSetting, @Nullable Consumer callback) { + SelectionCallback selectionCallback = new SelectionCallback(mentionSetting, callback); + + new AlertDialog.Builder(context) + .setTitle(R.string.GroupMentionSettingDialog_notify_me_for_mentions) + .setView(getView(context, mentionSetting, selectionCallback)) + .setPositiveButton(android.R.string.ok, selectionCallback) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + @SuppressLint("InflateParams") + private static View getView(@NonNull Context context, @NonNull MentionSetting mentionSetting, @NonNull SelectionCallback selectionCallback) { + View root = LayoutInflater.from(context).inflate(R.layout.group_mention_setting_dialog, null, false); + CheckedTextView alwaysNotify = root.findViewById(R.id.group_mention_setting_always_notify); + CheckedTextView dontNotify = root.findViewById(R.id.group_mention_setting_dont_notify); + + View.OnClickListener listener = (v) -> { + alwaysNotify.setChecked(alwaysNotify == v); + dontNotify.setChecked(dontNotify == v); + + if (alwaysNotify.isChecked()) { + selectionCallback.selection = MentionSetting.ALWAYS_NOTIFY; + } else if (dontNotify.isChecked()) { + selectionCallback.selection = MentionSetting.DO_NOT_NOTIFY; + } + }; + + alwaysNotify.setOnClickListener(listener); + dontNotify.setOnClickListener(listener); + + switch (mentionSetting) { + case ALWAYS_NOTIFY: + listener.onClick(alwaysNotify); + break; + case DO_NOT_NOTIFY: + listener.onClick(dontNotify); + break; + } + + return root; + } + + private static class SelectionCallback implements DialogInterface.OnClickListener { + + @NonNull private final MentionSetting previousMentionSetting; + @NonNull private MentionSetting selection; + @Nullable private final Consumer callback; + + public SelectionCallback(@NonNull MentionSetting previousMentionSetting, @Nullable Consumer callback) { + this.previousMentionSetting = previousMentionSetting; + this.selection = previousMentionSetting; + this.callback = callback; + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (callback != null && selection != previousMentionSetting) { + callback.accept(selection); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java new file mode 100644 index 00000000..d59f3880 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.content.Context; + +import androidx.annotation.ArrayRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupAccessControl; + +public final class GroupRightsDialog { + + private final AlertDialog.Builder builder; + + @NonNull private GroupAccessControl rights; + + public GroupRightsDialog(@NonNull Context context, + @NonNull Type type, + @NonNull GroupAccessControl currentRights, + @NonNull GroupRightsDialog.OnChange onChange) + { + rights = currentRights; + + builder = new AlertDialog.Builder(context) + .setTitle(type.message) + .setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which]) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + GroupAccessControl newGroupAccessControl = rights; + + if (newGroupAccessControl != currentRights) { + onChange.changed(currentRights, newGroupAccessControl); + } + }); + } + + public void show() { + builder.show(); + } + + public interface OnChange { + void changed(@NonNull GroupAccessControl from, @NonNull GroupAccessControl to); + } + + public enum Type { + + MEMBERSHIP(R.string.ManageGroupActivity_who_can_add_new_members, + R.array.GroupManagement_edit_group_membership_choices), + + ATTRIBUTES(R.string.ManageGroupActivity_who_can_edit_this_groups_info, + R.array.GroupManagement_edit_group_info_choices); + + @StringRes private final int message; + @ArrayRes private final int choices; + + Type(@StringRes int message, @ArrayRes int choices) { + this.message = message; + this.choices = choices; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupsLearnMoreBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupsLearnMoreBottomSheetDialogFragment.java new file mode 100644 index 00000000..6dee7385 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupsLearnMoreBottomSheetDialogFragment.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public final class GroupsLearnMoreBottomSheetDialogFragment extends BottomSheetDialogFragment { + + public static void show(@NonNull FragmentManager manager) { + new GroupsLearnMoreBottomSheetDialogFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.groups_learn_more_bottom_sheet, container, false); + + view.findViewById(R.id.lbs_ok_button).setOnClickListener(v -> dismiss()); + + return view; + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java new file mode 100644 index 00000000..08097b93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.List; + +/** + * Shows more info about a GV1->GV2 migration event. Looks similar to + * {@link GroupsV1MigrationInitiationBottomSheetDialogFragment}, but only displays static data. + */ +public final class GroupsV1MigrationInfoBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String KEY_MEMBERSHIP_CHANGE = "membership_change"; + + private GroupsV1MigrationInfoViewModel viewModel; + private GroupMemberListView pendingList; + private TextView pendingTitle; + private View pendingContainer; + private GroupMemberListView droppedList; + private TextView droppedTitle; + private View droppedContainer; + + public static void show(@NonNull FragmentManager manager, @NonNull GroupMigrationMembershipChange membershipChange) { + Bundle args = new Bundle(); + args.putString(KEY_MEMBERSHIP_CHANGE, membershipChange.serialize()); + + GroupsV1MigrationInfoBottomSheetDialogFragment fragment = new GroupsV1MigrationInfoBottomSheetDialogFragment(); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.groupsv1_migration_learn_more_bottom_sheet, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.pendingContainer = view.findViewById(R.id.gv1_learn_more_pending_container); + this.pendingTitle = view.findViewById(R.id.gv1_learn_more_pending_title); + this.pendingList = view.findViewById(R.id.gv1_learn_more_pending_list); + this.droppedContainer = view.findViewById(R.id.gv1_learn_more_dropped_container); + this.droppedTitle = view.findViewById(R.id.gv1_learn_more_dropped_title); + this.droppedList = view.findViewById(R.id.gv1_learn_more_dropped_list); + + //noinspection ConstantConditions + GroupMigrationMembershipChange membershipChange = GroupMigrationMembershipChange.deserialize(getArguments().getString(KEY_MEMBERSHIP_CHANGE)); + + this.viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInfoViewModel.Factory(membershipChange)).get(GroupsV1MigrationInfoViewModel.class); + viewModel.getPendingMembers().observe(getViewLifecycleOwner(), this::onPendingMembersChanged); + viewModel.getDroppedMembers().observe(getViewLifecycleOwner(), this::onDroppedMembersChanged); + + view.findViewById(R.id.gv1_learn_more_ok_button).setOnClickListener(v -> dismiss()); + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + private void onPendingMembersChanged(@NonNull List pendingMembers) { + if (pendingMembers.size() == 1 && pendingMembers.get(0).isSelf()) { + pendingContainer.setVisibility(View.VISIBLE); + pendingTitle.setText(R.string.GroupsV1MigrationLearnMore_you_will_need_to_accept_an_invite_to_join_this_group_again); + } else if (pendingMembers.size() > 0) { + pendingContainer.setVisibility(View.VISIBLE); + pendingTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_will_need_to_accept_an_invite, pendingMembers.size())); + pendingList.setDisplayOnlyMembers(pendingMembers); + } else { + pendingContainer.setVisibility(View.GONE); + } + } + + private void onDroppedMembersChanged(@NonNull List droppedMembers) { + if (droppedMembers.size() > 0) { + droppedContainer.setVisibility(View.VISIBLE); + droppedTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_were_removed_from_the_group, droppedMembers.size())); + droppedList.setDisplayOnlyMembers(droppedMembers); + } else { + droppedContainer.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java new file mode 100644 index 00000000..bdd0bc24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +class GroupsV1MigrationInfoViewModel extends ViewModel { + + private final MutableLiveData> pendingMembers; + private final MutableLiveData> droppedMembers; + + private GroupsV1MigrationInfoViewModel(@NonNull GroupMigrationMembershipChange membershipChange) { + this.pendingMembers = new MutableLiveData<>(); + this.droppedMembers = new MutableLiveData<>(); + + SignalExecutors.BOUNDED.execute(() -> { + this.pendingMembers.postValue(Recipient.resolvedList(membershipChange.getPending())); + }); + + SignalExecutors.BOUNDED.execute(() -> { + this.droppedMembers.postValue(Recipient.resolvedList(membershipChange.getDropped())); + }); + } + + @NonNull LiveData> getPendingMembers() { + return pendingMembers; + } + + @NonNull LiveData> getDroppedMembers() { + return droppedMembers; + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final GroupMigrationMembershipChange membershipChange; + + Factory(@NonNull GroupMigrationMembershipChange membershipChange) { + this.membershipChange = membershipChange; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new GroupsV1MigrationInfoViewModel(membershipChange)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java new file mode 100644 index 00000000..7e0447cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +/** + * A bottom sheet that allows a user to initiation a manual GV1->GV2 migration. Will show the user + * the members that will be invited/left behind. + */ +public final class GroupsV1MigrationInitiationBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String KEY_GROUP_RECIPIENT_ID = "group_recipient_id"; + + private GroupsV1MigrationInitiationViewModel viewModel; + private GroupMemberListView inviteList; + private TextView inviteTitle; + private View inviteContainer; + private GroupMemberListView ineligibleList; + private TextView ineligibleTitle; + private View ineligibleContainer; + private View upgradeButton; + private View spinner; + + public static void showForInitiation(@NonNull FragmentManager manager, @NonNull RecipientId groupRecipientId) { + Bundle args = new Bundle(); + args.putParcelable(KEY_GROUP_RECIPIENT_ID, groupRecipientId); + + GroupsV1MigrationInitiationBottomSheetDialogFragment fragment = new GroupsV1MigrationInitiationBottomSheetDialogFragment(); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.groupsv1_migration_bottom_sheet, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.inviteContainer = view.findViewById(R.id.gv1_migrate_invite_container); + this.inviteTitle = view.findViewById(R.id.gv1_migrate_invite_title); + this.inviteList = view.findViewById(R.id.gv1_migrate_invite_list); + this.ineligibleContainer = view.findViewById(R.id.gv1_migrate_ineligible_container); + this.ineligibleTitle = view.findViewById(R.id.gv1_migrate_ineligible_title); + this.ineligibleList = view.findViewById(R.id.gv1_migrate_ineligible_list); + this.upgradeButton = view.findViewById(R.id.gv1_migrate_upgrade_button); + this.spinner = view.findViewById(R.id.gv1_migrate_spinner); + + inviteList.setNestedScrollingEnabled(false); + ineligibleList.setNestedScrollingEnabled(false); + + //noinspection ConstantConditions + RecipientId groupRecipientId = getArguments().getParcelable(KEY_GROUP_RECIPIENT_ID); + + //noinspection ConstantConditions + viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInitiationViewModel.Factory(groupRecipientId)).get(GroupsV1MigrationInitiationViewModel.class); + viewModel.getMigrationState().observe(getViewLifecycleOwner(), this::onMigrationStateChanged); + + upgradeButton.setEnabled(false); + upgradeButton.setOnClickListener(v -> onUpgradeClicked()); + view.findViewById(R.id.gv1_migrate_cancel_button).setOnClickListener(v -> dismiss()); + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + private void onMigrationStateChanged(@NonNull MigrationState migrationState) { + if (migrationState.getNeedsInvite().size() > 0) { + inviteContainer.setVisibility(View.VISIBLE); + inviteTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_will_need_to_accept_an_invite, migrationState.getNeedsInvite().size())); + inviteList.setDisplayOnlyMembers(migrationState.getNeedsInvite()); + } else { + inviteContainer.setVisibility(View.GONE); + } + + if (migrationState.getIneligible().size() > 0) { + ineligibleContainer.setVisibility(View.VISIBLE); + ineligibleTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_are_not_capable_of_joining_new_groups, migrationState.getIneligible().size())); + ineligibleList.setDisplayOnlyMembers(migrationState.getIneligible()); + } else { + ineligibleContainer.setVisibility(View.GONE); + } + + upgradeButton.setEnabled(true); + spinner.setVisibility(View.GONE); + } + + private void onUpgradeClicked() { + AlertDialog dialog = SimpleProgressDialog.show(requireContext()); + viewModel.onUpgradeClicked().observe(getViewLifecycleOwner(), result -> { + switch (result) { + case SUCCESS: + dismiss(); + break; + case FAILURE_GENERAL: + Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_failed_to_upgrade, Toast.LENGTH_SHORT).show(); + dismiss(); + break; + case FAILURE_NETWORK: + Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_encountered_a_network_error, Toast.LENGTH_SHORT).show(); + dismiss(); + break; + default: + throw new IllegalStateException(); + } + dialog.dismiss(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java new file mode 100644 index 00000000..72d25fb7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +class GroupsV1MigrationInitiationViewModel extends ViewModel { + + private final RecipientId groupRecipientId; + private final MutableLiveData migrationState; + private final GroupsV1MigrationRepository repository; + + private GroupsV1MigrationInitiationViewModel(@NonNull RecipientId groupRecipientId) { + this.groupRecipientId = groupRecipientId; + this.migrationState = new MutableLiveData<>(); + this.repository = new GroupsV1MigrationRepository(); + + repository.getMigrationState(groupRecipientId, migrationState::postValue); + } + + @NonNull LiveData getMigrationState() { + return migrationState; + } + + @NonNull LiveData onUpgradeClicked() { + MutableLiveData migrationResult = new MutableLiveData<>(); + + repository.upgradeGroup(groupRecipientId, migrationResult::postValue); + + return migrationResult; + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final RecipientId groupRecipientId; + + Factory(@NonNull RecipientId groupRecipientId) { + this.groupRecipientId = groupRecipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new GroupsV1MigrationInitiationViewModel(groupRecipientId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java new file mode 100644 index 00000000..998e93be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +final class GroupsV1MigrationRepository { + + private static final String TAG = Log.tag(GroupsV1MigrationRepository.class); + + void getMigrationState(@NonNull RecipientId groupRecipientId, @NonNull Consumer callback) { + SignalExecutors.BOUNDED.execute(() -> callback.accept(getMigrationState(groupRecipientId))); + } + + void upgradeGroup(@NonNull RecipientId recipientId, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + if (!NetworkConstraint.isMet(ApplicationDependencies.getApplication())) { + Log.w(TAG, "No network!"); + callback.accept(MigrationResult.FAILURE_NETWORK); + return; + } + + if (!Recipient.resolved(recipientId).isPushV1Group()) { + Log.w(TAG, "Not a V1 group!"); + callback.accept(MigrationResult.FAILURE_GENERAL); + return; + } + + try { + GroupsV1MigrationUtil.migrate(ApplicationDependencies.getApplication(), recipientId, true); + callback.accept(MigrationResult.SUCCESS); + } catch (IOException | RetryLaterException | GroupChangeBusyException e) { + callback.accept(MigrationResult.FAILURE_NETWORK); + } catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) { + callback.accept(MigrationResult.FAILURE_GENERAL); + } + }); + } + + @WorkerThread + private MigrationState getMigrationState(@NonNull RecipientId groupRecipientId) { + Recipient group = Recipient.resolved(groupRecipientId); + + if (!group.isPushV1Group()) { + return new MigrationState(Collections.emptyList(), Collections.emptyList()); + } + + Set needsRefresh = Stream.of(group.getParticipants()) + .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED) + .map(Recipient::getId) + .collect(Collectors.toSet()); + + List jobs = RetrieveProfileJob.forRecipients(needsRefresh); + + for (Job job : jobs) { + if (!ApplicationDependencies.getJobManager().runSynchronously(job, TimeUnit.SECONDS.toMillis(3)).isPresent()) { + Log.w(TAG, "Failed to refresh capabilities in time!"); + } + } + + try { + List registered = Stream.of(group.getParticipants()) + .filter(Recipient::isRegistered) + .toList(); + + RecipientUtil.ensureUuidsAreAvailable(ApplicationDependencies.getApplication(), registered); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh UUIDs!", e); + } + + group = group.fresh(); + + List ineligible = Stream.of(group.getParticipants()) + .filter(r -> !r.hasUuid() || + r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED || + r.getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) + .toList(); + + List invites = Stream.of(group.getParticipants()) + .filterNot(ineligible::contains) + .filterNot(Recipient::isSelf) + .filter(r -> r.getProfileKey() == null) + .toList(); + + return new MigrationState(invites, ineligible); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java new file mode 100644 index 00000000..2e99de1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import android.content.DialogInterface; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.io.IOException; +import java.util.List; + +/** + * Shows a list of members that got lost when migrating from a V1->V2 group, giving you the chance + * to add them back. + */ +public final class GroupsV1MigrationSuggestionsDialog { + + private static final String TAG = Log.tag(GroupsV1MigrationSuggestionsDialog.class); + + private final FragmentActivity fragmentActivity; + private final GroupId.V2 groupId; + private final List suggestions; + + public static void show(@NonNull FragmentActivity activity, + @NonNull GroupId.V2 groupId, + @NonNull List suggestions) + { + new GroupsV1MigrationSuggestionsDialog(activity, groupId, suggestions).display(); + } + + private GroupsV1MigrationSuggestionsDialog(@NonNull FragmentActivity activity, + @NonNull GroupId.V2 groupId, + @NonNull List suggestions) + { + this.fragmentActivity = activity; + this.groupId = groupId; + this.suggestions = suggestions; + } + + private void display() { + AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) + .setTitle(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_add_members_question, suggestions.size())) + .setMessage(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_these_members_couldnt_be_automatically_added, suggestions.size())) + .setView(R.layout.dialog_group_members) + .setPositiveButton(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_add_members, suggestions.size()), (d, i) -> onAddClicked(d)) + .setNegativeButton(android.R.string.cancel, (d, i) -> d.dismiss()) + .show(); + + GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); + + SimpleTask.run(() -> Recipient.resolvedList(suggestions), + memberListView::setDisplayOnlyMembers); + } + + private void onAddClicked(@NonNull DialogInterface rootDialog) { + SimpleProgressDialog.DismissibleDialog progressDialog = SimpleProgressDialog.showDelayed(fragmentActivity, 300, 0); + SimpleTask.run(SignalExecutors.UNBOUNDED, () -> { + try { + GroupManager.addMembers(fragmentActivity, groupId.requirePush(), suggestions); + Log.i(TAG, "Successfully added members! Removing these dropped members from the list."); + DatabaseFactory.getGroupDatabase(fragmentActivity).removeUnmigratedV1Members(groupId, suggestions); + return Result.SUCCESS; + } catch (IOException | GroupChangeBusyException e) { + Log.w(TAG, "Temporary failure.", e); + return Result.NETWORK_ERROR; + } catch (GroupNotAMemberException | GroupInsufficientRightsException | MembershipNotSuitableForV2Exception | GroupChangeFailedException e) { + Log.w(TAG, "Permanent failure! Removing these dropped members from the list.", e); + DatabaseFactory.getGroupDatabase(fragmentActivity).removeUnmigratedV1Members(groupId, suggestions); + return Result.IMPOSSIBLE; + } + }, result -> { + progressDialog.dismiss(); + rootDialog.dismiss(); + + switch (result) { + case NETWORK_ERROR: + Toast.makeText(fragmentActivity, fragmentActivity.getResources().getQuantityText(R.plurals.GroupsV1MigrationSuggestionsDialog_failed_to_add_members_try_again_later, suggestions.size()), Toast.LENGTH_SHORT).show(); + break; + case IMPOSSIBLE: + Toast.makeText(fragmentActivity, fragmentActivity.getResources().getQuantityText(R.plurals.GroupsV1MigrationSuggestionsDialog_cannot_add_members, suggestions.size()), Toast.LENGTH_SHORT).show(); + break; + } + }); + } + + private enum Result { + SUCCESS, NETWORK_ERROR, IMPOSSIBLE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java new file mode 100644 index 00000000..47efa2d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +enum MigrationResult { + SUCCESS, FAILURE_GENERAL, FAILURE_NETWORK +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java new file mode 100644 index 00000000..f9ffee85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +/** + * Represents the migration state of a group. Namely, which users will be invited or left behind. + */ +final class MigrationState { + private final List needsInvite; + private final List ineligible; + + MigrationState(@NonNull List needsInvite, + @NonNull List ineligible) + { + this.needsInvite = needsInvite; + this.ineligible = ineligible; + } + + public @NonNull List getNeedsInvite() { + return needsInvite; + } + + public @NonNull List getIneligible() { + return ineligible; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java new file mode 100644 index 00000000..f4f5d655 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.groups.v2; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.groupsv2.GroupCandidate; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public final class GroupCandidateHelper { + private final SignalServiceAccountManager signalServiceAccountManager; + private final RecipientDatabase recipientDatabase; + + public GroupCandidateHelper(@NonNull Context context) { + signalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager(); + recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + } + + private static final String TAG = Log.tag(GroupCandidateHelper.class); + + /** + * Given a recipient will create a {@link GroupCandidate} which may or may not have a profile key credential. + *

+ * It will try to find missing profile key credentials from the server and persist locally. + */ + @WorkerThread + public @NonNull GroupCandidate recipientIdToCandidate(@NonNull RecipientId recipientId) + throws IOException + { + final Recipient recipient = Recipient.resolved(recipientId); + + UUID uuid = recipient.getUuid().orNull(); + if (uuid == null) { + throw new AssertionError("Non UUID members should have need detected by now"); + } + + Optional profileKeyCredential = Optional.fromNullable(recipient.getProfileKeyCredential()); + GroupCandidate candidate = new GroupCandidate(uuid, profileKeyCredential); + + if (!candidate.hasProfileKeyCredential()) { + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + + if (profileKey != null) { + Log.i(TAG, String.format("No profile key credential on recipient %s, fetching", recipient.getId())); + + Optional profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey); + + if (profileKeyCredentialOptional.isPresent()) { + boolean updatedProfileKey = recipientDatabase.setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get()); + + if (!updatedProfileKey) { + Log.w(TAG, String.format("Failed to update the profile key credential on recipient %s", recipient.getId())); + } else { + Log.i(TAG, String.format("Got new profile key credential for recipient %s", recipient.getId())); + candidate = candidate.withProfileKeyCredential(profileKeyCredentialOptional.get()); + } + } + } + } + + return candidate; + } + + @WorkerThread + public @NonNull Set recipientIdsToCandidates(@NonNull Collection recipientIds) + throws IOException + { + Set result = new HashSet<>(recipientIds.size()); + + for (RecipientId recipientId : recipientIds) { + result.add(recipientIdToCandidate(recipientId)); + } + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java new file mode 100644 index 00000000..40ee561b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.GroupInviteLink; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.whispersystems.util.Base64UrlSafe; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +public final class GroupInviteLinkUrl { + + private static final String GROUP_URL_HOST = "signal.group"; + private static final String GROUP_URL_PREFIX = "https://" + GROUP_URL_HOST + "/#"; + + private final GroupMasterKey groupMasterKey; + private final GroupLinkPassword password; + private final String url; + + public static GroupInviteLinkUrl forGroup(@NonNull GroupMasterKey groupMasterKey, + @NonNull DecryptedGroup group) + { + return new GroupInviteLinkUrl(groupMasterKey, GroupLinkPassword.fromBytes(group.getInviteLinkPassword().toByteArray())); + } + + public static boolean isGroupLink(@NonNull String urlString) { + return getGroupUrl(urlString) != null; + } + + /** + * @return null iff not a group url. + * @throws InvalidGroupLinkException If group url, but cannot be parsed. + */ + public static @Nullable GroupInviteLinkUrl fromUri(@NonNull String urlString) + throws InvalidGroupLinkException, UnknownGroupLinkVersionException + { + URI uri = getGroupUrl(urlString); + + if (uri == null) { + return null; + } + + try { + if (!"/".equals(uri.getPath()) && uri.getPath().length() > 0) { + throw new InvalidGroupLinkException("No path was expected in uri"); + } + + String encoding = uri.getFragment(); + + if (encoding == null || encoding.length() == 0) { + throw new InvalidGroupLinkException("No reference was in the uri"); + } + + byte[] bytes = Base64UrlSafe.decodePaddingAgnostic(encoding); + GroupInviteLink groupInviteLink = GroupInviteLink.parseFrom(bytes); + + //noinspection SwitchStatementWithTooFewBranches + switch (groupInviteLink.getContentsCase()) { + case V1CONTENTS: { + GroupInviteLink.GroupInviteLinkContentsV1 groupInviteLinkContentsV1 = groupInviteLink.getV1Contents(); + GroupMasterKey groupMasterKey = new GroupMasterKey(groupInviteLinkContentsV1.getGroupMasterKey().toByteArray()); + GroupLinkPassword password = GroupLinkPassword.fromBytes(groupInviteLinkContentsV1.getInviteLinkPassword().toByteArray()); + + return new GroupInviteLinkUrl(groupMasterKey, password); + } + default: throw new UnknownGroupLinkVersionException("Url contains no known group link content"); + } + } catch (InvalidInputException | IOException e) { + throw new InvalidGroupLinkException(e); + } + } + + /** + * @return {@link URI} if the host name matches. + */ + private static URI getGroupUrl(@NonNull String urlString) { + try { + URI url = new URI(urlString); + + if (!"https".equalsIgnoreCase(url.getScheme()) && + !"sgnl".equalsIgnoreCase(url.getScheme())) + { + return null; + } + + return GROUP_URL_HOST.equalsIgnoreCase(url.getHost()) + ? url + : null; + + } catch (URISyntaxException e) { + return null; + } + } + + private GroupInviteLinkUrl(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) { + this.groupMasterKey = groupMasterKey; + this.password = password; + this.url = createUrl(groupMasterKey, password); + } + + protected static @NonNull String createUrl(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) { + GroupInviteLink groupInviteLink = GroupInviteLink.newBuilder() + .setV1Contents(GroupInviteLink.GroupInviteLinkContentsV1.newBuilder() + .setGroupMasterKey(ByteString.copyFrom(groupMasterKey.serialize())) + .setInviteLinkPassword(ByteString.copyFrom(password.serialize()))) + .build(); + + String encoding = Base64UrlSafe.encodeBytesWithoutPadding(groupInviteLink.toByteArray()); + + return GROUP_URL_PREFIX + encoding; + } + + public @NonNull String getUrl() { + return url; + } + + public @NonNull GroupMasterKey getGroupMasterKey() { + return groupMasterKey; + } + + public @NonNull GroupLinkPassword getPassword() { + return password; + } + + public final static class InvalidGroupLinkException extends Exception { + public InvalidGroupLinkException(String message) { + super(message); + } + + public InvalidGroupLinkException(Throwable cause) { + super(cause); + } + } + + public final static class UnknownGroupLinkVersionException extends Exception { + public UnknownGroupLinkVersionException(String message) { + super(message); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkPassword.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkPassword.java new file mode 100644 index 00000000..10e769c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkPassword.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.Util; + +import java.util.Arrays; + +public final class GroupLinkPassword { + + private static final int SIZE = 16; + + private final byte[] bytes; + + public static @NonNull GroupLinkPassword createNew() { + return new GroupLinkPassword(Util.getSecretBytes(SIZE)); + } + + public static @NonNull GroupLinkPassword fromBytes(@NonNull byte[] bytes) { + return new GroupLinkPassword(bytes); + } + + private GroupLinkPassword(@NonNull byte[] bytes) { + this.bytes = bytes; + } + + public @NonNull byte[] serialize() { + return bytes.clone(); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GroupLinkPassword)) { + return false; + } + + return Arrays.equals(bytes, ((GroupLinkPassword) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java new file mode 100644 index 00000000..1495736a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; + +public final class GroupLinkUrlAndStatus { + + public static final GroupLinkUrlAndStatus NONE = new GroupLinkUrlAndStatus(false, false, ""); + + private final boolean enabled; + private final boolean requiresApproval; + private final String url; + + public GroupLinkUrlAndStatus(boolean enabled, + boolean requiresApproval, + @NonNull String url) + { + this.enabled = enabled; + this.requiresApproval = requiresApproval; + this.url = url; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isRequiresApproval() { + return requiresApproval; + } + + public @NonNull String getUrl() { + return url; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java new file mode 100644 index 00000000..c6f33baf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.protobuf.ByteString; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Collects profile keys from group states. + *

+ * Separates out "authoritative" profile keys that came from a group update created by their owner. + *

+ * Authoritative profile keys can be used to overwrite local profile keys. + * Non-authoritative profile keys can be used to fill in missing knowledge. + */ +public final class ProfileKeySet { + + private static final String TAG = Log.tag(ProfileKeySet.class); + + private final Map profileKeys = new LinkedHashMap<>(); + private final Map authoritativeProfileKeys = new LinkedHashMap<>(); + + /** + * Add new profile keys from a group change. + *

+ * If the change came from the member whose profile key is changing then it is regarded as + * authoritative. + */ + public void addKeysFromGroupChange(@NonNull DecryptedGroupChange change) { + UUID editor = UuidUtil.fromByteStringOrNull(change.getEditor()); + + for (DecryptedMember member : change.getNewMembersList()) { + addMemberKey(member, editor); + } + + for (DecryptedMember member : change.getPromotePendingMembersList()) { + addMemberKey(member, editor); + } + + for (DecryptedMember member : change.getModifiedProfileKeysList()) { + addMemberKey(member, editor); + } + + for (DecryptedRequestingMember member : change.getNewRequestingMembersList()) { + addMemberKey(editor, member.getUuid(), member.getProfileKey()); + } + } + + /** + * Add new profile keys from the group state. + *

+ * Profile keys found in group state are never authoritative as the change cannot be easily + * attributed to a member and it's possible that the group is out of date. So profile keys + * gathered from a group state can only be used to fill in gaps in knowledge. + */ + public void addKeysFromGroupState(@NonNull DecryptedGroup group) { + for (DecryptedMember member : group.getMembersList()) { + addMemberKey(member, null); + } + } + + private void addMemberKey(@NonNull DecryptedMember member, @Nullable UUID changeSource) { + addMemberKey(changeSource, member.getUuid(), member.getProfileKey()); + } + + private void addMemberKey(@Nullable UUID changeSource, + @NonNull ByteString memberUuidBytes, + @NonNull ByteString profileKeyBytes) + { + UUID memberUuid = UuidUtil.fromByteString(memberUuidBytes); + + if (UuidUtil.UNKNOWN_UUID.equals(memberUuid)) { + Log.w(TAG, "Seen unknown member UUID"); + return; + } + + ProfileKey profileKey; + try { + profileKey = new ProfileKey(profileKeyBytes.toByteArray()); + } catch (InvalidInputException e) { + Log.w(TAG, "Bad profile key in group"); + return; + } + + if (memberUuid.equals(changeSource)) { + authoritativeProfileKeys.put(memberUuid, profileKey); + profileKeys.remove(memberUuid); + } else { + if (!authoritativeProfileKeys.containsKey(memberUuid)) { + profileKeys.put(memberUuid, profileKey); + } + } + } + + public Map getProfileKeys() { + return profileKeys; + } + + public Map getAuthoritativeProfileKeys() { + return authoritativeProfileKeys; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java new file mode 100644 index 00000000..d0a9c450 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; + +import java.util.Collection; + +/** + * Pair of log entries applied and a new {@link GlobalGroupState}. + */ +final class AdvanceGroupStateResult { + + @NonNull private final Collection processedLogEntries; + @NonNull private final GlobalGroupState newGlobalGroupState; + + AdvanceGroupStateResult(@NonNull Collection processedLogEntries, + @NonNull GlobalGroupState newGlobalGroupState) + { + this.processedLogEntries = processedLogEntries; + this.newGlobalGroupState = newGlobalGroupState; + } + + @NonNull Collection getProcessedLogEntries() { + return processedLogEntries; + } + + @NonNull GlobalGroupState getNewGlobalGroupState() { + return newGlobalGroupState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java new file mode 100644 index 00000000..ad534fe4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; + +import java.util.Collection; +import java.util.List; + +/** + * Combination of Local and Server group state. + */ +final class GlobalGroupState { + + @Nullable private final DecryptedGroup localState; + @NonNull private final List serverHistory; + + GlobalGroupState(@Nullable DecryptedGroup localState, + @NonNull List serverHistory) + { + this.localState = localState; + this.serverHistory = serverHistory; + } + + @Nullable DecryptedGroup getLocalState() { + return localState; + } + + @NonNull Collection getServerHistory() { + return serverHistory; + } + + int getEarliestRevisionNumber() { + if (localState != null) { + return localState.getRevision(); + } else { + if (serverHistory.isEmpty()) { + throw new AssertionError(); + } + return serverHistory.get(0).getRevision(); + } + } + + int getLatestRevisionNumber() { + if (serverHistory.isEmpty()) { + if (localState == null) { + throw new AssertionError(); + } + return localState.getRevision(); + } + return serverHistory.get(serverHistory.size() - 1).getRevision(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java new file mode 100644 index 00000000..ac086fb8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil; +import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; + +final class GroupStateMapper { + + private static final String TAG = Log.tag(GroupStateMapper.class); + + static final int LATEST = Integer.MAX_VALUE; + static final int PLACEHOLDER_REVISION = -1; + static final int RESTORE_PLACEHOLDER_REVISION = -2; + + private static final Comparator BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision()); + + private GroupStateMapper() { + } + + /** + * Given an input {@link GlobalGroupState} and a {@param maximumRevisionToApply}, returns a result + * containing what the new local group state should be, and any remaining revision history to apply. + *

+ * Function is pure. + * @param maximumRevisionToApply Use {@link #LATEST} to apply the very latest. + */ + static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState, + int maximumRevisionToApply) + { + AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply); + + return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState()); + } + + private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState, + int maximumRevisionToApply) + { + HashMap statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); + ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); + DecryptedGroup current = inputState.getLocalState(); + StateChain stateChain = createNewMapper(); + + if (inputState.getServerHistory().isEmpty()) { + return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList())); + } + + for (ServerGroupLogEntry entry : inputState.getServerHistory()) { + if (entry.getRevision() > maximumRevisionToApply) { + statesToApplyLater.add(entry); + } else { + statesToApplyNow.put(entry.getRevision(), entry); + } + } + + Collections.sort(statesToApplyLater, BY_REVISION); + + final int from = Math.max(0, inputState.getEarliestRevisionNumber()); + final int to = Math.min(inputState.getLatestRevisionNumber(), maximumRevisionToApply); + + if (current != null && current.getRevision() == PLACEHOLDER_REVISION) { + Log.i(TAG, "Ignoring place holder group state"); + } else { + stateChain.push(current, null); + } + + for (int revision = from; revision >= 0 && revision <= to; revision++) { + ServerGroupLogEntry entry = statesToApplyNow.get(revision); + if (entry == null) { + Log.w(TAG, "Could not find group log on server V" + revision); + continue; + } + + if (stateChain.getLatestState() == null && entry.getGroup() != null && current != null && current.getRevision() == PLACEHOLDER_REVISION) { + DecryptedGroup previousState = DecryptedGroup.newBuilder(entry.getGroup()) + .setTitle(current.getTitle()) + .setAvatar(current.getAvatar()) + .build(); + + stateChain.push(previousState, null); + } + + stateChain.push(entry.getGroup(), entry.getChange()); + } + + List> mapperList = stateChain.getList(); + List appliedChanges = new ArrayList<>(mapperList.size()); + + for (StateChain.Pair entry : mapperList) { + if (current == null || entry.getDelta() != null) { + appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta())); + } + } + + return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater)); + } + + private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult, + @Nullable DecryptedGroup previousGroupState) + { + if (previousGroupState == null) return groupStateResult; + + ArrayList appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size()); + + for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) { + DecryptedGroupChange change = entry.getChange(); + + if (change != null) { + change = GroupChangeUtil.resolveConflict(previousGroupState, change).build(); + } + + appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change)); + + previousGroupState = entry.getGroup(); + } + + return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState()); + } + + private static StateChain createNewMapper() { + return new StateChain<>( + (group, change) -> { + try { + return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change); + } catch (NotAbleToApplyGroupV2ChangeException e) { + Log.w(TAG, "Unable to apply V" + change.getRevision(), e); + return null; + } + }, + (groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB), + (groupA, groupB) -> groupA.getRevision() == groupB.getRevision() && DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB)) + ); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java new file mode 100644 index 00000000..6525f093 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -0,0 +1,535 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupDoesNotExistException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupMutation; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.groups.GroupsV2Authorization; +import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; +import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException; +import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * Advances a groups state to a specified revision. + */ +public final class GroupsV2StateProcessor { + + private static final String TAG = Log.tag(GroupsV2StateProcessor.class); + + public static final int LATEST = GroupStateMapper.LATEST; + + /** + * Used to mark a group state as a placeholder when there is partial knowledge (title and avater) + * gathered from a group join link. + */ + public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION; + + /** + * Used to mark a group state as a placeholder when you have no knowledge at all of the group + * e.g. from a group master key from a storage service restore. + */ + public static final int RESTORE_PLACEHOLDER_REVISION = GroupStateMapper.RESTORE_PLACEHOLDER_REVISION; + + private final Context context; + private final JobManager jobManager; + private final RecipientDatabase recipientDatabase; + private final GroupDatabase groupDatabase; + private final GroupsV2Authorization groupsV2Authorization; + private final GroupsV2Api groupsV2Api; + + public GroupsV2StateProcessor(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.jobManager = ApplicationDependencies.getJobManager(); + this.groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization(); + this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + } + + public StateProcessorForGroup forGroup(@NonNull GroupMasterKey groupMasterKey) { + return new StateProcessorForGroup(groupMasterKey); + } + + public enum GroupState { + /** + * The message revision was inconsistent with server revision, should ignore + */ + INCONSISTENT, + + /** + * The local group was successfully updated to be consistent with the message revision + */ + GROUP_UPDATED, + + /** + * The local group is already consistent with the message revision or is ahead of the message revision + */ + GROUP_CONSISTENT_OR_AHEAD + } + + public static class GroupUpdateResult { + private final GroupState groupState; + @Nullable private final DecryptedGroup latestServer; + + GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) { + this.groupState = groupState; + this.latestServer = latestServer; + } + + public GroupState getGroupState() { + return groupState; + } + + public @Nullable DecryptedGroup getLatestServer() { + return latestServer; + } + } + + public final class StateProcessorForGroup { + private final GroupMasterKey masterKey; + private final GroupId.V2 groupId; + private final GroupSecretParams groupSecretParams; + + private StateProcessorForGroup(@NonNull GroupMasterKey groupMasterKey) { + this.masterKey = groupMasterKey; + this.groupId = GroupId.v2(masterKey); + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + } + + /** + * Using network where required, will attempt to bring the local copy of the group up to the revision specified. + * + * @param revision use {@link #LATEST} to get latest. + */ + @WorkerThread + public GroupUpdateResult updateLocalGroupToRevision(final int revision, + final long timestamp, + @Nullable DecryptedGroupChange signedGroupChange) + throws IOException, GroupNotAMemberException + { + if (localIsAtLeast(revision)) { + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); + } + + GlobalGroupState inputGroupState = null; + + DecryptedGroup localState = groupDatabase.getGroup(groupId) + .transform(g -> g.requireV2GroupProperties().getDecryptedGroup()) + .orNull(); + + if (signedGroupChange != null && + localState != null && + localState.getRevision() + 1 == signedGroupChange.getRevision() && + revision == signedGroupChange.getRevision()) + { + if (SignalStore.internalValues().gv2IgnoreP2PChanges()) { + Log.w(TAG, "Ignoring P2P group change by setting"); + } else { + try { + Log.i(TAG, "Applying P2P group change"); + DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange); + + inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange))); + } catch (NotAbleToApplyGroupV2ChangeException e) { + Log.w(TAG, "Unable to apply P2P group change", e); + } + } + } + + if (inputGroupState == null) { + try { + boolean latestRevisionOnly = revision == LATEST && (localState == null || localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION); + inputGroupState = queryServer(localState, latestRevisionOnly); + } catch (GroupNotAMemberException e) { + if (localState != null && signedGroupChange != null) { + try { + Log.i(TAG, "Applying P2P group change when not a member"); + DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange); + + inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange))); + } catch (NotAbleToApplyGroupV2ChangeException failed) { + Log.w(TAG, "Unable to apply P2P group change when not a member", failed); + } + } + + if (inputGroupState == null) { + if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, Recipient.self().getUuid().get())) { + Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member"); + } else { + Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message"); + insertGroupLeave(); + } + throw e; + } + } + } else { + Log.i(TAG, "Saved server query for group change"); + } + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision); + DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState(); + + if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) { + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); + } + + updateLocalDatabaseGroupState(inputGroupState, newLocalState); + determineProfileSharing(inputGroupState, newLocalState); + if (localState != null && localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { + Log.i(TAG, "Inserting single update message for restore placeholder"); + insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); + } else { + insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + } + persistLearnedProfileKeys(inputGroupState); + + GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState(); + if (remainingWork.getServerHistory().size() > 0) { + Log.i(TAG, String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", newLocalState.getRevision() + 1, remainingWork.getLatestRevisionNumber())); + ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId, remainingWork.getLatestRevisionNumber())); + } + + return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState); + } + + @WorkerThread + public @NonNull DecryptedGroup getCurrentGroupStateFromServer() + throws IOException, GroupNotAMemberException, GroupDoesNotExistException + { + try { + return groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + } catch (GroupNotFoundException e) { + throw new GroupDoesNotExistException(e); + } catch (NotInGroupException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + } + + @WorkerThread + public @Nullable DecryptedGroup getSpecificVersionFromServer(int revision) + throws IOException, GroupNotAMemberException, GroupDoesNotExistException + { + try { + return groupsV2Api.getGroupHistory(groupSecretParams, revision, groupsV2Authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)) + .get(0) + .getGroup() + .orNull(); + } catch (GroupNotFoundException e) { + throw new GroupDoesNotExistException(e); + } catch (NotInGroupException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + } + + private void insertGroupLeave() { + if (!groupDatabase.isActive(groupId)) { + Log.w(TAG, "Group has already been left."); + return; + } + + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + UUID selfUuid = Recipient.self().getUuid().get(); + DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId) + .requireV2GroupProperties() + .getDecryptedGroup(); + + DecryptedGroup simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, selfUuid, decryptedGroup.getRevision() + 1); + DecryptedGroupChange simulatedGroupChange = DecryptedGroupChange.newBuilder() + .setEditor(UuidUtil.toByteString(UuidUtil.UNKNOWN_UUID)) + .setRevision(simulatedGroupState.getRevision()) + .addDeleteMembers(UuidUtil.toByteString(selfUuid)) + .build(); + + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null); + OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage(groupRecipient, + decryptedGroupV2Context, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + + try { + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + long id = mmsDatabase.insertMessageOutbox(leaveMessage, threadId, false, null); + mmsDatabase.markAsSent(id, true); + } catch (MmsException e) { + Log.w(TAG, "Failed to insert leave message.", e); + } + + groupDatabase.setActive(groupId, false); + groupDatabase.remove(groupId, Recipient.self().getId()); + } + + /** + * @return true iff group exists locally and is at least the specified revision. + */ + private boolean localIsAtLeast(int revision) { + if (groupDatabase.isUnknownGroup(groupId) || revision == LATEST) { + return false; + } + int dbRevision = groupDatabase.getGroup(groupId).get().requireV2GroupProperties().getGroupRevision(); + return revision <= dbRevision; + } + + private void updateLocalDatabaseGroupState(@NonNull GlobalGroupState inputGroupState, + @NonNull DecryptedGroup newLocalState) + { + boolean needsAvatarFetch; + + if (inputGroupState.getLocalState() == null) { + groupDatabase.create(masterKey, newLocalState); + needsAvatarFetch = !TextUtils.isEmpty(newLocalState.getAvatar()); + } else { + groupDatabase.update(masterKey, newLocalState); + needsAvatarFetch = !newLocalState.getAvatar().equals(inputGroupState.getLocalState().getAvatar()); + } + + if (needsAvatarFetch) { + jobManager.add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.getAvatar())); + } + + determineProfileSharing(inputGroupState, newLocalState); + } + + private void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, + @NonNull DecryptedGroup newLocalState) + { + if (inputGroupState.getLocalState() != null) { + boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByUuid(inputGroupState.getLocalState().getMembersList(), Recipient.self().getUuid().get()).isPresent(); + + if (wasAMemberAlready) { + Log.i(TAG, "Skipping profile sharing detection as was already a full member before update"); + return; + } + } + + Optional selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), Recipient.self().getUuid().get()); + + if (selfAsMemberOptional.isPresent()) { + DecryptedMember selfAsMember = selfAsMemberOptional.get(); + int revisionJoinedAt = selfAsMember.getJoinedAtRevision(); + + Optional addedByOptional = Stream.of(inputGroupState.getServerHistory()) + .map(ServerGroupLogEntry::getChange) + .filter(c -> c != null && c.getRevision() == revisionJoinedAt) + .findFirst() + .map(c -> Optional.fromNullable(UuidUtil.fromByteStringOrNull(c.getEditor())) + .transform(a -> Recipient.externalPush(context, UuidUtil.fromByteStringOrNull(c.getEditor()), null, false))) + .orElse(Optional.absent()); + + if (addedByOptional.isPresent()) { + Recipient addedBy = addedByOptional.get(); + + Log.i(TAG, String.format("Added as a full member of %s by %s", groupId, addedBy.getId())); + + if (addedBy.isSystemContact() || addedBy.isProfileSharing()) { + Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact() + ", profileSharing: " + addedBy.isProfileSharing()); + Log.i(TAG, "Added to a group and auto-enabling profile sharing"); + recipientDatabase.setProfileSharing(Recipient.externalGroupExact(context, groupId).getId(), true); + } else { + Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted"); + } + } else { + Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing."); + } + } else { + Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as not a fullMember.", groupId)); + } + } + + private void insertUpdateMessages(long timestamp, + @Nullable DecryptedGroup previousGroupState, + Collection processedLogEntries) + { + for (LocalGroupLogEntry entry : processedLogEntries) { + if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { + Log.d(TAG, "Skipping profile key changes only update message"); + } else { + storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp); + timestamp++; + } + previousGroupState = entry.getGroup(); + } + } + + private void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) { + final ProfileKeySet profileKeys = new ProfileKeySet(); + + for (ServerGroupLogEntry entry : globalGroupState.getServerHistory()) { + if (entry.getGroup() != null) { + profileKeys.addKeysFromGroupState(entry.getGroup()); + } + if (entry.getChange() != null) { + profileKeys.addKeysFromGroupChange(entry.getChange()); + } + } + + Set updated = recipientDatabase.persistProfileKeySet(profileKeys); + + if (!updated.isEmpty()) { + Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, fetching profiles", updated.size())); + + for (Job job : RetrieveProfileJob.forRecipients(updated)) { + jobManager.runSynchronously(job, 5000); + } + } + } + + private @NonNull GlobalGroupState queryServer(@Nullable DecryptedGroup localState, boolean latestOnly) + throws IOException, GroupNotAMemberException + { + UUID selfUuid = Recipient.self().getUuid().get(); + DecryptedGroup latestServerGroup; + List history; + + try { + latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); + } catch (NotInGroupException | GroupNotFoundException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + + if (latestOnly || !GroupProtoUtil.isMember(selfUuid, latestServerGroup.getMembersList())) { + history = Collections.singletonList(new ServerGroupLogEntry(latestServerGroup, null)); + } else { + int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, selfUuid); + int logsNeededFrom = localState != null ? Math.max(localState.getRevision(), revisionWeWereAdded) : revisionWeWereAdded; + + history = getFullMemberHistory(selfUuid, logsNeededFrom); + } + + return new GlobalGroupState(localState, history); + } + + private List getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFromRevision) throws IOException { + try { + Collection groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); + ArrayList history = new ArrayList<>(groupStatesFromRevision.size()); + boolean ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(); + + if (ignoreServerChanges) { + Log.w(TAG, "Server change logs are ignored by setting"); + } + + for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) { + DecryptedGroup group = entry.getGroup().orNull(); + DecryptedGroupChange change = ignoreServerChanges ? null : entry.getChange().orNull(); + + if (group != null || change != null) { + history.add(new ServerGroupLogEntry(group, change)); + } + } + + return history; + } catch (InvalidGroupStateException | VerificationFailedException e) { + throw new IOException(e); + } + } + + private void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { + Optional editor = getEditor(decryptedGroupV2Context); + + boolean outgoing = !editor.isPresent() || Recipient.self().requireUuid().equals(editor.get()); + + if (outgoing) { + try { + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + Recipient recipient = Recipient.resolved(recipientId); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, decryptedGroupV2Context, null, timestamp, 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); + + mmsDatabase.markAsSent(messageId, true); + } catch (MmsException e) { + Log.w(TAG, e); + } + } else { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + RecipientId sender = RecipientId.from(editor.get(), null); + IncomingTextMessage incoming = new IncomingTextMessage(sender, -1, timestamp, timestamp, "", Optional.of(groupId), 0, false); + IncomingGroupUpdateMessage groupMessage = new IncomingGroupUpdateMessage(incoming, decryptedGroupV2Context); + + if (!smsDatabase.insertMessageInbox(groupMessage).isPresent()) { + Log.w(TAG, "Could not insert update message"); + } + } + } + + private Optional getEditor(@NonNull DecryptedGroupV2Context decryptedGroupV2Context) { + DecryptedGroupChange change = decryptedGroupV2Context.getChange(); + Optional changeEditor = DecryptedGroupUtil.editorUuid(change); + if (changeEditor.isPresent()) { + return changeEditor; + } else { + Optional pendingByUuid = DecryptedGroupUtil.findPendingByUuid(decryptedGroupV2Context.getGroupState().getPendingMembersList(), Recipient.self().requireUuid()); + if (pendingByUuid.isPresent()) { + return Optional.fromNullable(UuidUtil.fromByteStringOrNull(pendingByUuid.get().getAddedByUuid())); + } + } + return Optional.absent(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java new file mode 100644 index 00000000..5692340e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; + +import java.util.Objects; + +/** + * Pair of a group state and optionally the corresponding change. + *

+ * Similar to {@link ServerGroupLogEntry} but guaranteed to have a group state. + *

+ * Changes are typically not available for pending members. + */ +final class LocalGroupLogEntry { + + @NonNull private final DecryptedGroup group; + @Nullable private final DecryptedGroupChange change; + + LocalGroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) { + if (change != null && group.getRevision() != change.getRevision()) { + throw new AssertionError(); + } + + this.group = group; + this.change = change; + } + + @NonNull DecryptedGroup getGroup() { + return group; + } + + @Nullable DecryptedGroupChange getChange() { + return change; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof LocalGroupLogEntry)) return false; + + LocalGroupLogEntry other = (LocalGroupLogEntry) o; + + return group.equals(other.group) && Objects.equals(change, other.change); + } + + @Override + public int hashCode() { + int result = group.hashCode(); + result = 31 * result + (change != null ? change.hashCode() : 0); + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java new file mode 100644 index 00000000..9f68f1e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; + +/** + * Pair of a group state and optionally the corresponding change from the server. + *

+ * Either the group or change may be empty. + *

+ * Changes are typically not available for pending members. + */ +final class ServerGroupLogEntry { + + private static final String TAG = Log.tag(ServerGroupLogEntry.class); + + @Nullable private final DecryptedGroup group; + @Nullable private final DecryptedGroupChange change; + + ServerGroupLogEntry(@Nullable DecryptedGroup group, @Nullable DecryptedGroupChange change) { + if (change != null && group != null && group.getRevision() != change.getRevision()) { + Log.w(TAG, "Ignoring change with revision number not matching group"); + change = null; + } + + if (change == null && group == null) { + throw new AssertionError(); + } + + this.group = group; + this.change = change; + } + + @Nullable DecryptedGroup getGroup() { + return group; + } + + @Nullable DecryptedGroupChange getChange() { + return change; + } + + int getRevision() { + if (group != null) return group.getRevision(); + else if (change != null) return change.getRevision(); + else throw new AssertionError(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java new file mode 100644 index 00000000..25cd7ecf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java @@ -0,0 +1,172 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +/** + * Maintains a chain of state pairs: + *

+ * {@code
+ *  (S1, Delta1),
+ *  (S2, Delta2),
+ *  (S3, Delta3)
+ * }
+ * 
+ * Such that the states always include all deltas. + *
+ * {@code
+ *  (S1, _),
+ *  (S1 + Delta2, Delta2),
+ *  (S1 + Delta2 + Delta3, Delta3),
+ * }
+ * 
+ *

+ * If a pushed delta does not correct create the new state (tested by {@link StateEquality}), a new + * delta and state is inserted like so: + *

+ * {@code
+ * (PreviousState, PreviousDelta),
+ * (PreviousState + NewDelta, NewDelta),
+ * (NewState, PreviousState + NewDelta - NewState),
+ * }
+ * 
+ * That is it keeps both the newly supplied delta and state, but creates an interim state and delta. + * + * The + function is supplied by {@link AddDelta} and the - function is supplied by {@link SubtractStates}. + */ +public final class StateChain { + + private final AddDelta add; + private final SubtractStates subtract; + private final StateEquality stateEquality; + + private final List> pairs = new LinkedList<>(); + + public StateChain(@NonNull AddDelta add, + @NonNull SubtractStates subtract, + @NonNull StateEquality stateEquality) + { + this.add = add; + this.subtract = subtract; + this.stateEquality = stateEquality; + } + + public void push(@Nullable State state, @Nullable Delta delta) { + if (delta == null && state == null) return; + + boolean bothSupplied = state != null && delta != null; + State latestState = getLatestState(); + + if (latestState == null && state == null) return; + + if (latestState != null) { + if (delta == null) { + + delta = subtract.subtract(state, latestState); + } + + if (state == null) { + state = add.add(latestState, delta); + + if (state == null) return; + } + + if (bothSupplied) { + State calculatedState = add.add(latestState, delta); + + if (calculatedState == null) { + push(state, null); + return; + } else if (!stateEquality.equals(state, calculatedState)) { + push(null, delta); + push(state, null); + return; + } + } + } + + if (latestState == null || !stateEquality.equals(latestState, state)) { + pairs.add(new Pair<>(state, delta)); + } + } + + public @Nullable State getLatestState() { + int size = pairs.size(); + + return size == 0 ? null : pairs.get(size - 1).getState(); + } + + public List> getList() { + return new ArrayList<>(pairs); + } + + public static final class Pair { + @NonNull private final State state; + @Nullable private final Delta delta; + + Pair(@NonNull State state, @Nullable Delta delta) { + this.state = state; + this.delta = delta; + } + + public @NonNull State getState() { + return state; + } + + public @Nullable Delta getDelta() { + return delta; + } + + @Override + public String toString() { + return String.format("(%s, %s)", state, delta); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Pair other = (Pair) o; + + return state.equals(other.state) && + Objects.equals(delta, other.delta); + } + + @Override + public int hashCode() { + int result = state.hashCode(); + result = 31 * result + (delta != null ? delta.hashCode() : 0); + return result; + } + } + + interface AddDelta { + + /** + * Add {@param delta} to {@param state} and return the new {@link State}. + *

+ * If this returns null, then the delta could not be applied and will be ignored. + */ + @Nullable State add(@NonNull State state, @NonNull Delta delta); + } + + interface SubtractStates { + + /** + * Finds a delta = {@param stateB} - {@param stateA} + * such that {@param stateA} + {@link Delta} = {@param stateB}. + */ + @NonNull Delta subtract(@NonNull State stateB, @NonNull State stateA); + } + + interface StateEquality { + + boolean equals(@NonNull State stateA, @NonNull State stateB); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java new file mode 100644 index 00000000..1ab74980 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.help; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.Stream; +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SupportEmailUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +import java.util.ArrayList; +import java.util.List; + +public class HelpFragment extends LoggingFragment { + + private EditText problem; + private CheckBox includeDebugLogs; + private View debugLogInfo; + private View faq; + private CircularProgressButton next; + private View toaster; + private List emoji; + private HelpViewModel helpViewModel; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.help_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModels(); + initializeViews(view); + initializeListeners(); + initializeObservers(); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__help); + + cancelSpinning(next); + problem.setEnabled(true); + } + + private void initializeViewModels() { + helpViewModel = ViewModelProviders.of(this).get(HelpViewModel.class); + } + + private void initializeViews(@NonNull View view) { + problem = view.findViewById(R.id.help_fragment_problem); + includeDebugLogs = view.findViewById(R.id.help_fragment_debug); + debugLogInfo = view.findViewById(R.id.help_fragment_debug_info); + faq = view.findViewById(R.id.help_fragment_faq); + next = view.findViewById(R.id.help_fragment_next); + toaster = view.findViewById(R.id.help_fragment_next_toaster); + emoji = new ArrayList<>(Feeling.values().length); + + for (Feeling feeling : Feeling.values()) { + EmojiImageView emojiView = view.findViewById(feeling.getViewId()); + emojiView.setImageEmoji(feeling.getEmojiCode()); + emoji.add(view.findViewById(feeling.getViewId())); + } + } + + private void initializeListeners() { + problem.addTextChangedListener(new AfterTextChanged(e -> helpViewModel.onProblemChanged(e.toString()))); + Stream.of(emoji).forEach(view -> view.setOnClickListener(this::handleEmojiClicked)); + faq.setOnClickListener(v -> launchFaq()); + debugLogInfo.setOnClickListener(v -> launchDebugLogInfo()); + next.setOnClickListener(v -> submitForm()); + toaster.setOnClickListener(v -> Toast.makeText(requireContext(), R.string.HelpFragment__please_be_as_descriptive_as_possible, Toast.LENGTH_LONG).show()); + } + + private void initializeObservers() { + //noinspection CodeBlock2Expr + helpViewModel.isFormValid().observe(getViewLifecycleOwner(), isValid -> { + next.setEnabled(isValid); + toaster.setVisibility(isValid ? View.GONE : View.VISIBLE); + }); + } + + private void handleEmojiClicked(@NonNull View clicked) { + if (clicked.isSelected()) { + clicked.setSelected(false); + } else { + Stream.of(emoji).forEach(view -> view.setSelected(false)); + clicked.setSelected(true); + } + } + + private void launchFaq() { + Uri data = Uri.parse(getString(R.string.HelpFragment__link__faq)); + Intent intent = new Intent(Intent.ACTION_VIEW, data); + + startActivity(intent); + } + + private void launchDebugLogInfo() { + Uri data = Uri.parse(getString(R.string.HelpFragment__link__debug_info)); + Intent intent = new Intent(Intent.ACTION_VIEW, data); + + startActivity(intent); + } + + private void submitForm() { + setSpinning(next); + problem.setEnabled(false); + + helpViewModel.onSubmitClicked(includeDebugLogs.isChecked()).observe(getViewLifecycleOwner(), result -> { + if (result.getDebugLogUrl().isPresent()) { + submitFormWithDebugLog(result.getDebugLogUrl().get()); + } else if (result.isError()) { + submitFormWithDebugLog(getString(R.string.HelpFragment__could_not_upload_logs)); + } else { + submitFormWithDebugLog(null); + } + }); + } + + private void submitFormWithDebugLog(@Nullable String debugLog) { + Feeling feeling = Stream.of(emoji) + .filter(View::isSelected) + .map(view -> Feeling.getByViewId(view.getId())) + .findFirst().orElse(null); + + CommunicationActions.openEmail(requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getEmailSubject(), + getEmailBody(debugLog, feeling)); + } + + private String getEmailSubject() { + return getString(R.string.HelpFragment__signal_android_support_request); + } + + private String getEmailBody(@Nullable String debugLog, @Nullable Feeling feeling) { + StringBuilder suffix = new StringBuilder(); + + if (debugLog != null) { + suffix.append("\n"); + suffix.append(getString(R.string.HelpFragment__debug_log)); + suffix.append(" "); + suffix.append(debugLog); + } + + if (feeling != null) { + suffix.append("\n\n"); + suffix.append(feeling.getEmojiCode()); + suffix.append("\n"); + suffix.append(getString(feeling.getStringId())); + } + + return SupportEmailUtil.generateSupportEmailBody(requireContext(), + R.string.HelpFragment__signal_android_support_request, + problem.getText().toString() + "\n\n", + suffix.toString()); + } + + private static void setSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setClickable(false); + button.setIndeterminateProgressMode(true); + button.setProgress(50); + } + } + + private static void cancelSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setProgress(0); + button.setIndeterminateProgressMode(false); + button.setClickable(true); + } + } + + private enum Feeling { + ECSTATIC(R.id.help_fragment_emoji_5, R.string.HelpFragment__emoji_5, "\ud83d\ude00"), + HAPPY(R.id.help_fragment_emoji_4, R.string.HelpFragment__emoji_4, "\ud83d\ude42"), + AMBIVALENT(R.id.help_fragment_emoji_3, R.string.HelpFragment__emoji_3, "\ud83d\ude10"), + UNHAPPY(R.id.help_fragment_emoji_2, R.string.HelpFragment__emoji_2, "\ud83d\ude41"), + ANGRY(R.id.help_fragment_emoji_1, R.string.HelpFragment__emoji_1, "\ud83d\ude20"); + + private final @IdRes int viewId; + private final @StringRes int stringId; + private final CharSequence emojiCode; + + Feeling(@IdRes int viewId, @StringRes int stringId, @NonNull CharSequence emojiCode) { + this.viewId = viewId; + this.stringId = stringId; + this.emojiCode = emojiCode; + } + + public @IdRes int getViewId() { + return viewId; + } + + public @StringRes int getStringId() { + return stringId; + } + + public @NonNull CharSequence getEmojiCode() { + return emojiCode; + } + + static Feeling getByViewId(@IdRes int viewId) { + for (Feeling feeling : values()) { + if (feeling.viewId == viewId) { + return feeling; + } + } + + throw new AssertionError(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java new file mode 100644 index 00000000..a526ab23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.help; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.logsubmit.LogLine; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository; +import org.thoughtcrime.securesms.util.livedata.LiveDataPair; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; + +public class HelpViewModel extends ViewModel { + + private static final int MINIMUM_PROBLEM_CHARS = 10; + + private MutableLiveData problemMeetsLengthRequirements = new MutableLiveData<>(); + private MutableLiveData hasLines = new MutableLiveData<>(false); + private LiveData isFormValid = Transformations.map(new LiveDataPair<>(problemMeetsLengthRequirements, hasLines), this::transformValidationData); + + private final SubmitDebugLogRepository submitDebugLogRepository; + + private List logLines; + + public HelpViewModel() { + submitDebugLogRepository = new SubmitDebugLogRepository(); + + submitDebugLogRepository.getLogLines(lines -> { + logLines = lines; + hasLines.postValue(true); + }); + } + + LiveData isFormValid() { + return isFormValid; + } + + void onProblemChanged(@NonNull String problem) { + problemMeetsLengthRequirements.setValue(problem.length() >= MINIMUM_PROBLEM_CHARS); + } + + LiveData onSubmitClicked(boolean includeDebugLogs) { + MutableLiveData resultLiveData = new MutableLiveData<>(); + + if (includeDebugLogs) { + submitDebugLogRepository.submitLog(logLines, result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent()))); + } else { + resultLiveData.postValue(new SubmitResult(Optional.absent(), false)); + } + + return resultLiveData; + } + + private boolean transformValidationData(Pair validationData) { + return validationData.first() == Boolean.TRUE && validationData.second() == Boolean.TRUE; + } + + static class SubmitResult { + private final Optional debugLogUrl; + private final boolean isError; + + private SubmitResult(@NonNull Optional debugLogUrl, boolean isError) { + this.debugLogUrl = debugLogUrl; + this.isError = isError; + } + + @NonNull Optional getDebugLogUrl() { + return debugLogUrl; + } + + boolean isError() { + return isError; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java new file mode 100644 index 00000000..ea3cc38b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Bounds.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * The local extent of a {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}. + * i.e. all {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}s have a bounding rectangle from: + *

+ * {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}. + */ +public final class Bounds { + + public static final float LEFT = -1000f; + public static final float RIGHT = 1000f; + + public static final float TOP = -1000f; + public static final float BOTTOM = 1000f; + + public static final float CENTRE_X = (LEFT + RIGHT) / 2f; + public static final float CENTRE_Y = (TOP + BOTTOM) / 2f; + + public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y }; + + private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP, + Bounds.RIGHT, Bounds.TOP, + Bounds.RIGHT, Bounds.BOTTOM, + Bounds.LEFT, Bounds.BOTTOM }; + + static RectF newFullBounds() { + return new RectF(LEFT, TOP, RIGHT, BOTTOM); + } + + public static RectF FULL_BOUNDS = newFullBounds(); + + public static boolean contains(float x, float y) { + return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right && + y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom; + } + + /** + * Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds. + * + * @param matrix matrix to transform points by, null is treated as identity. + * @return true iff all points remain in bounds after transformation. + */ + public static boolean boundsRemainInBounds(@Nullable Matrix matrix) { + if (matrix == null) return true; + + float[] dst = new float[POINTS.length]; + + matrix.mapPoints(dst, POINTS); + + return allWithinBounds(dst); + } + + private static boolean allWithinBounds(@NonNull float[] points) { + boolean allHit = true; + + for (int i = 0; i < points.length / 2; i++) { + float x = points[2 * i]; + float y = points[2 * i + 1]; + + if (!Bounds.contains(x, y)) { + allHit = false; + break; + } + } + + return allHit; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java new file mode 100644 index 00000000..7b12689d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; + +import androidx.annotation.NonNull; + +/** + * Tracks the current matrix for a canvas. + *

+ * This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}. + * {@link Canvas#getMatrix()} provides this hint in its documentation: + * "track relevant transform state outside of the canvas." + *

+ * To achieve this, any changes to the canvas matrix must be done via this class, including save and + * restore operations where the matrix was altered in between. + */ +public final class CanvasMatrix { + + private final static int STACK_HEIGHT_LIMIT = 16; + + private final Canvas canvas; + private final Matrix canvasMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT]; + private int stackHeight; + + CanvasMatrix(Canvas canvas) { + this.canvas = canvas; + for (int i = 0; i < stack.length; i++) { + stack[i] = new Matrix(); + } + } + + public void concat(@NonNull Matrix matrix) { + canvas.concat(matrix); + canvasMatrix.preConcat(matrix); + } + + void save() { + canvas.save(); + if (stackHeight == STACK_HEIGHT_LIMIT) { + throw new AssertionError("Not enough space on stack"); + } + stack[stackHeight++].set(canvasMatrix); + } + + void restore() { + canvas.restore(); + canvasMatrix.set(stack[--stackHeight]); + } + + void getCurrent(@NonNull Matrix into) { + into.set(canvasMatrix); + } + + public void setToIdentity() { + if (canvasMatrix.invert(temp)) { + concat(temp); + } + } + + public void initial(Matrix viewMatrix) { + concat(viewMatrix); + } + + boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void mapPoints(float[] dst, float[] src) { + canvasMatrix.mapPoints(dst, src); + } + + public void copyTo(@NonNull Matrix matrix) { + matrix.set(canvasMatrix); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java new file mode 100644 index 00000000..11706dfc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.imageeditor; + +import androidx.annotation.ColorInt; + +/** + * A renderer that can have its color changed. + *

+ * For example, Lines and Text can change color. + */ +public interface ColorableRenderer extends Renderer { + + @ColorInt + int getColor(); + + void setColor(@ColorInt int color); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java new file mode 100644 index 00000000..7fe7c0dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/DrawingSession.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; + +/** + * Passes touch events into a {@link BezierDrawingRenderer}. + */ +class DrawingSession extends ElementEditSession { + + private final BezierDrawingRenderer renderer; + + private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) { + super(selected, inverseMatrix); + this.renderer = renderer; + } + + public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) { + DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer); + drawingSession.setScreenStartPoint(0, point); + renderer.setFirstPoint(drawingSession.startPointElement[0]); + return drawingSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + if (p != 0) return; + setScreenEndPoint(p, point); + renderer.addNewPoint(endPointElement[0]); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return this; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java new file mode 100644 index 00000000..3ea2148c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/EditSession.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +/** + * Represents an underway edit of the image. + *

+ * Accepts new touch positions, new touch points, released touch points and when complete can commit the edit. + *

+ * Examples of edit session implementations are, Drag, Draw, Resize: + *

+ * {@link ElementDragEditSession} for dragging with a single finger. + * {@link ElementScaleEditSession} for resize/dragging with two fingers. + * {@link DrawingSession} for drawing with a single finger. + */ +interface EditSession { + + void movePoint(int p, @NonNull PointF point); + + EditorElement getSelected(); + + EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p); + + EditSession removePoint(@NonNull Matrix newInverse, int p); + + void commit(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java new file mode 100644 index 00000000..23bd5e96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementDragEditSession extends ElementEditSession { + + private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + selected.getEditorMatrix() + .setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return ElementScaleEditSession.startScale(this, newInverse, point, p); + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return this; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java new file mode 100644 index 00000000..6bf0e229 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +abstract class ElementEditSession implements EditSession { + + private final Matrix inverseMatrix; + + final EditorElement selected; + + final PointF[] startPointElement = newTwoPointArray(); + final PointF[] endPointElement = newTwoPointArray(); + final PointF[] startPointScreen = newTwoPointArray(); + final PointF[] endPointScreen = newTwoPointArray(); + + ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + this.selected = selected; + this.inverseMatrix = inverseMatrix; + } + + void setScreenStartPoint(int p, @NonNull PointF point) { + startPointScreen[p] = point; + mapPoint(startPointElement[p], inverseMatrix, point); + } + + void setScreenEndPoint(int p, @NonNull PointF point) { + endPointScreen[p] = point; + mapPoint(endPointElement[p], inverseMatrix, point); + } + + @Override + public abstract void movePoint(int p, @NonNull PointF point); + + @Override + public void commit() { + selected.commitEditorMatrix(); + } + + @Override + public EditorElement getSelected() { + return selected; + } + + private static PointF[] newTwoPointArray() { + PointF[] array = new PointF[2]; + for (int i = 0; i < array.length; i++) { + array[i] = new PointF(); + } + return array; + } + + /** + * Map src to dst using the matrix. + * + * @param dst Output point. + * @param matrix Matrix to transform point with. + * @param src Input point. + */ + private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) { + float[] in = { src.x, src.y }; + float[] out = new float[2]; + matrix.mapPoints(out, in); + dst.set(out[0], out[1]); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java new file mode 100644 index 00000000..d18934b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementScaleEditSession extends ElementEditSession { + + private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) { + session.commit(); + ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix); + newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenStartPoint(p, point); + newSession.setScreenEndPoint(p, point); + return newSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + if (selected.getFlags().isAspectLocked()) { + + float scale = (float) findScale(startPointElement, endPointElement); + + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + editorMatrix.postScale(scale, scale); + + double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]); + + if (!selected.getFlags().isRotateLocked()) { + editorMatrix.postRotate((float) Math.toDegrees(angle)); + } + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } else { + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + + float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x); + float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y); + + editorMatrix.postScale(scaleX, scaleY); + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return convertToDrag(p, newInverse); + } + + private static double angle(@NonNull PointF a, @NonNull PointF b) { + return Math.atan2(a.y - b.y, a.x - b.x); + } + + private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) { + return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]); + } + + /** + * Find relative distance between an old and new set of Points. + * + * @param from Pair of points. + * @param to New pair of points. + * @return Scale + */ + private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) { + float originalD2 = getDistanceSquared(from[0], from[1]); + float newD2 = getDistanceSquared(to[0], to[1]); + return Math.sqrt(newD2 / originalD2); + } + + /** + * Distance between two points squared. + */ + private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) { + float dx = a.x - b.x; + float dy = a.y - b.y; + return dx * dx + dy * dy; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java new file mode 100644 index 00000000..ab1f2d1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java @@ -0,0 +1,176 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Rect; +import android.text.InputType; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +/** + * Invisible {@link android.widget.EditText} that is used during in-image text editing. + */ +final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { + + @SuppressLint("InlinedApi") + private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; + + @Nullable + private EditorElement currentTextEditorElement; + + @Nullable + private MultiLineTextRenderer currentTextEntity; + + @Nullable + private Runnable onEndEdit; + + @Nullable + private OnEditOrSelectionChange onEditOrSelectionChange; + + public HiddenEditText(Context context) { + super(context); + setAlpha(0); + setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START)); + setClickable(false); + setFocusable(true); + setFocusableInTouchMode(true); + setBackgroundColor(Color.TRANSPARENT); + setTextSize(TypedValue.COMPLEX_UNIT_SP, 1); + setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + clearFocus(); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { + super.onTextChanged(text, start, lengthBefore, lengthAfter); + if (currentTextEntity != null) { + currentTextEntity.setText(text.toString()); + postEditOrSelectionChange(); + } + } + + @Override + public void onEditorAction(int actionCode) { + super.onEditorAction(actionCode); + if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) { + currentTextEntity.setFocused(false); + endEdit(); + } + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(focused, direction, previouslyFocusedRect); + if (currentTextEntity != null) { + currentTextEntity.setFocused(focused); + if (!focused) { + endEdit(); + } + } + } + + private void endEdit() { + if (onEndEdit != null) { + onEndEdit.run(); + } + } + + private void postEditOrSelectionChange() { + if (currentTextEditorElement != null && currentTextEntity != null && onEditOrSelectionChange != null) { + onEditOrSelectionChange.onChange(currentTextEditorElement, currentTextEntity); + } + } + + @Nullable MultiLineTextRenderer getCurrentTextEntity() { + return currentTextEntity; + } + + @Nullable EditorElement getCurrentTextEditorElement() { + return currentTextEditorElement; + } + + public void setCurrentTextEditorElement(@Nullable EditorElement currentTextEditorElement) { + if (currentTextEditorElement != null && currentTextEditorElement.getRenderer() instanceof MultiLineTextRenderer) { + this.currentTextEditorElement = currentTextEditorElement; + setCurrentTextEntity((MultiLineTextRenderer) currentTextEditorElement.getRenderer()); + } else { + this.currentTextEditorElement = null; + setCurrentTextEntity(null); + } + + postEditOrSelectionChange(); + } + + private void setCurrentTextEntity(@Nullable MultiLineTextRenderer currentTextEntity) { + if (this.currentTextEntity != currentTextEntity) { + if (this.currentTextEntity != null) { + this.currentTextEntity.setFocused(false); + } + this.currentTextEntity = currentTextEntity; + if (currentTextEntity != null) { + String text = currentTextEntity.getText(); + setText(text); + setSelection(text.length()); + } else { + setText(""); + } + } + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + super.onSelectionChanged(selStart, selEnd); + if (currentTextEntity != null) { + currentTextEntity.setSelection(selStart, selEnd); + postEditOrSelectionChange(); + } + } + + @Override + public boolean requestFocus(int direction, Rect previouslyFocusedRect) { + boolean focus = super.requestFocus(direction, previouslyFocusedRect); + + if (currentTextEntity != null && focus) { + currentTextEntity.setFocused(true); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT); + if (!imm.isAcceptingText()) { + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + } + + return focus; + } + + public void hideKeyboard() { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) { + setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME + : getImeOptions() & ~INCOGNITO_KEYBOARD_IME); + } + + public void setOnEndEdit(@Nullable Runnable onEndEdit) { + this.onEndEdit = onEndEdit; + } + + public void setOnEditOrSelectionChange(@Nullable OnEditOrSelectionChange onEditOrSelectionChange) { + this.onEditOrSelectionChange = onEditOrSelectionChange; + } + + public interface OnEditOrSelectionChange { + void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java new file mode 100644 index 00000000..7a4a0f8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -0,0 +1,488 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.widget.FrameLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.GestureDetectorCompat; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +/** + * ImageEditorView + *

+ * Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and + * addition and manipulation of text/drawing/and other image layers that move with the base image. + *

+ * Drawing + *

+ * Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}. + * Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}. + *

+ * New images + *

+ * To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)} + * which centers the new item in the current crop area. + */ +public final class ImageEditorView extends FrameLayout { + + private HiddenEditText editText; + + @NonNull + private Mode mode = Mode.MoveAndResize; + + @ColorInt + private int color = 0xff000000; + + private float thickness = 0.02f; + + @NonNull + private Paint.Cap cap = Paint.Cap.ROUND; + + private EditorModel model; + + private GestureDetectorCompat doubleTap; + + @Nullable + private DrawingChangedListener drawingChangedListener; + + @Nullable + private SizeChangedListener sizeChangedListener; + + @Nullable + private UndoRedoStackListener undoRedoStackListener; + + private final Matrix viewMatrix = new Matrix(); + private final RectF viewPort = Bounds.newFullBounds(); + private final RectF visibleViewPort = Bounds.newFullBounds(); + private final RectF screen = new RectF(); + + private TapListener tapListener; + private RendererContext rendererContext; + + @Nullable + private EditSession editSession; + private boolean moreThanOnePointerUsedInSession; + + public ImageEditorView(Context context) { + super(context); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setWillNotDraw(false); + setModel(EditorModel.create()); + + editText = createAHiddenTextEntryField(); + + doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener()); + + setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event)); + } + + private HiddenEditText createAHiddenTextEntryField() { + HiddenEditText editText = new HiddenEditText(getContext()); + addView(editText); + editText.clearFocus(); + editText.setOnEndEdit(this::doneTextEditing); + editText.setOnEditOrSelectionChange(this::zoomToFitText); + return editText; + } + + public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled); + editText.setCurrentTextEditorElement(editorElement); + if (selectAll) { + editText.selectAll(); + } + editText.requestFocus(); + } + } + + private void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) { + getModel().zoomToTextElement(editorElement, textRenderer); + } + + public boolean isTextEditing() { + return editText.getCurrentTextEntity() != null; + } + + public void doneTextEditing() { + getModel().zoomOut(); + if (editText.getCurrentTextEntity() != null) { + editText.setCurrentTextEditorElement(null); + editText.hideKeyboard(); + if (tapListener != null) { + tapListener.onEntityDown(null); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (rendererContext == null || rendererContext.canvas != canvas) { + rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate); + } + rendererContext.save(); + try { + rendererContext.canvasMatrix.initial(viewMatrix); + + model.draw(rendererContext, editText.getCurrentTextEditorElement()); + } finally { + rendererContext.restore(); + } + } + + private final RendererContext.Ready rendererReady = new RendererContext.Ready() { + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + model.onReady(renderer, cropMatrix, size); + invalidate(); + } + }; + + private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate(); + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateViewMatrix(); + if (sizeChangedListener != null) { + sizeChangedListener.onSizeChanged(w, h); + } + } + + private void updateViewMatrix() { + screen.right = getWidth(); + screen.bottom = getHeight(); + + viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL); + + float[] values = new float[9]; + viewMatrix.getValues(values); + + float scale = values[0] / values[4]; + + RectF tempViewPort = Bounds.newFullBounds(); + if (scale < 1) { + tempViewPort.top /= scale; + tempViewPort.bottom /= scale; + } else { + tempViewPort.left *= scale; + tempViewPort.right *= scale; + } + + visibleViewPort.set(tempViewPort); + + viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER); + + model.setVisibleViewPort(visibleViewPort); + + invalidate(); + } + + public void setModel(@NonNull EditorModel model) { + if (this.model != model) { + if (this.model != null) { + this.model.setInvalidate(null); + this.model.setUndoRedoStackListener(null); + } + this.model = model; + this.model.setInvalidate(this::invalidate); + this.model.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); + this.model.setVisibleViewPort(visibleViewPort); + invalidate(); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + Matrix inverse = new Matrix(); + PointF point = getPoint(event); + EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse); + + moreThanOnePointerUsedInSession = false; + model.pushUndoPoint(); + editSession = startEdit(inverse, point, selected); + + if (tapListener != null && allowTaps()) { + if (editSession != null) { + tapListener.onEntityDown(editSession.getSelected()); + } else { + tapListener.onEntityDown(null); + } + } + + return true; + } + case MotionEvent.ACTION_MOVE: { + if (editSession != null) { + int historySize = event.getHistorySize(); + int pointerCount = Math.min(2, event.getPointerCount()); + + for (int h = 0; h < historySize; h++) { + for (int p = 0; p < pointerCount; p++) { + editSession.movePoint(p, getHistoricalPoint(event, p, h)); + } + } + + for (int p = 0; p < pointerCount; p++) { + editSession.movePoint(p, getPoint(event, p)); + } + model.moving(editSession.getSelected()); + invalidate(); + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_DOWN: { + if (editSession != null && event.getPointerCount() == 2) { + moreThanOnePointerUsedInSession = true; + editSession.commit(); + model.pushUndoPoint(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex()); + } else { + editSession = null; + } + if (editSession == null) { + dragDropRelease(); + } + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_UP: { + if (editSession != null && event.getActionIndex() < 2) { + editSession.commit(); + model.pushUndoPoint(); + dragDropRelease(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.removePoint(newInverse, event.getActionIndex()); + } else { + editSession = null; + } + return true; + } + break; + } + case MotionEvent.ACTION_UP: { + if (editSession != null) { + editSession.commit(); + dragDropRelease(); + + editSession = null; + model.postEdit(moreThanOnePointerUsedInSession); + invalidate(); + return true; + } else { + model.postEdit(moreThanOnePointerUsedInSession); + } + break; + } + } + + return super.onTouchEvent(event); + } + + private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + if (mode == Mode.Draw || mode == Mode.Blur) { + return startADrawingSession(point); + } else { + return startAMoveAndResizeSession(inverse, point, selected); + } + } + + private EditSession startADrawingSession(@NonNull PointF point) { + BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot()); + EditorElement element = new EditorElement(renderer, mode == Mode.Blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING); + model.addElementCentered(element, 1); + + Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix); + + return DrawingSession.start(element, renderer, elementInverseMatrix, point); + } + + private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + Matrix elementInverseMatrix; + if (selected == null) return null; + + if (selected.getRenderer() instanceof ThumbRenderer) { + ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer(); + + selected = getModel().findById(thumb.getElementToControl()); + + if (selected == null) return null; + + elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix); + if (elementInverseMatrix != null) { + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point); + } else { + return null; + } + } + + return ElementDragEditSession.startDrag(selected, inverse, point); + } + + public void setMode(@NonNull Mode mode) { + this.mode = mode; + } + + public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) { + this.thickness = thickness; + this.cap = cap; + setMode(blur ? Mode.Blur : Mode.Draw); + } + + public void setDrawingBrushColor(int color) { + this.color = color; + } + + private void dragDropRelease() { + model.dragDropRelease(); + if (drawingChangedListener != null) { + drawingChangedListener.onDrawingChanged(); + } + } + + private static PointF getPoint(MotionEvent event) { + return getPoint(event, 0); + } + + private static PointF getPoint(MotionEvent event, int p) { + return new PointF(event.getX(p), event.getY(p)); + } + + private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) { + return new PointF(event.getHistoricalX(p, historicalIndex), + event.getHistoricalY(p, historicalIndex)); + } + + public EditorModel getModel() { + return model; + } + + public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) { + this.drawingChangedListener = drawingChangedListener; + } + + public void setSizeChangedListener(@Nullable SizeChangedListener sizeChangedListener) { + this.sizeChangedListener = sizeChangedListener; + } + + public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + } + + public void setTapListener(TapListener tapListener) { + this.tapListener = tapListener; + } + + public void deleteElement(@Nullable EditorElement editorElement) { + if (editorElement != null) { + model.pushUndoPoint(); + model.delete(editorElement); + invalidate(); + } + } + + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + if (undoRedoStackListener != null) { + undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable); + } + } + + private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { + + @Override + public boolean onDoubleTap(MotionEvent e) { + if (tapListener != null && editSession != null && allowTaps()) { + tapListener.onEntityDoubleTap(editSession.getSelected()); + } + return true; + } + + @Override + public void onLongPress(MotionEvent e) {} + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (tapListener != null && allowTaps()) { + if (editSession != null) { + EditorElement selected = editSession.getSelected(); + model.indicateSelected(selected); + tapListener.onEntitySingleTap(selected); + } else { + tapListener.onEntitySingleTap(null); + } + return true; + } + return false; + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + } + + private boolean allowTaps() { + return !model.isCropping() && mode != Mode.Draw && mode != Mode.Blur; + } + + public enum Mode { + MoveAndResize, + Draw, + Blur + } + + public interface DrawingChangedListener { + void onDrawingChanged(); + } + + public interface SizeChangedListener { + void onSizeChanged(int newWidth, int newHeight); + } + + public interface TapListener { + + void onEntityDown(@Nullable EditorElement editorElement); + + void onEntitySingleTap(@Nullable EditorElement editorElement); + + void onEntityDoubleTap(@NonNull EditorElement editorElement); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java new file mode 100644 index 00000000..9b431e45 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/Renderer.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +/** + * Responsible for rendering a single {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement} to the canvas. + *

+ * Because it knows the most about the whereabouts of the image it is also responsible for hit detection. + */ +public interface Renderer extends Parcelable { + + /** + * Draw self to the context. + * + * @param rendererContext The context to draw to. + */ + void render(@NonNull RendererContext rendererContext); + + /** + * @param x Local coordinate X + * @param y Local coordinate Y + * @return true iff hit. + */ + boolean hitTest(float x, float y); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java new file mode 100644 index 00000000..f814699f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/RendererContext.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +import java.util.Collections; +import java.util.List; + +/** + * Contains all of the information required for a {@link Renderer} to do its job. + *

+ * Includes a {@link #canvas}, preconfigured with the correct matrix. + *

+ * The {@link #canvasMatrix} should further matrix manipulation be required. + */ +public final class RendererContext { + + @NonNull + public final Context context; + + @NonNull + public final Canvas canvas; + + @NonNull + public final CanvasMatrix canvasMatrix; + + @NonNull + public final Ready rendererReady; + + @NonNull + public final Invalidate invalidate; + + private boolean blockingLoad; + + private float fade = 1f; + + private boolean isEditing = true; + + private List children = Collections.emptyList(); + private Paint maskPaint; + + public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) { + this.context = context; + this.canvas = canvas; + this.canvasMatrix = new CanvasMatrix(canvas); + this.rendererReady = rendererReady; + this.invalidate = invalidate; + } + + public void setBlockingLoad(boolean blockingLoad) { + this.blockingLoad = blockingLoad; + } + + /** + * {@link Renderer}s generally run in the foreground but can load any data they require in the background. + *

+ * If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn. + *

+ * However, when isBlockingLoad is true, the renderer is running in the background for the final render + * and must load the data immediately and block the render until done so. + */ + public boolean isBlockingLoad() { + return blockingLoad; + } + + public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void setIsEditing(boolean isEditing) { + this.isEditing = isEditing; + } + + public boolean isEditing() { + return isEditing; + } + + public void setFade(float fade) { + this.fade = fade; + } + + public int getAlpha(int alpha) { + return Math.max(0, Math.min(255, (int) (fade * alpha))); + } + + /** + * Persist the current state on to a stack, must be complimented by a call to {@link #restore()}. + */ + public void save() { + canvasMatrix.save(); + } + + /** + * Restore the current state from the stack, must match a call to {@link #save()}. + */ + public void restore() { + canvasMatrix.restore(); + } + + public void getCurrent(@NonNull Matrix into) { + canvasMatrix.getCurrent(into); + } + + public void setChildren(@NonNull List children) { + this.children = children; + } + + public @NonNull List getChildren() { + return children; + } + + public void setMaskPaint(@Nullable Paint maskPaint) { + this.maskPaint = maskPaint; + } + + public @Nullable Paint getMaskPaint() { + return maskPaint; + } + + public interface Ready { + + Ready NULL = (renderer, cropMatrix, size) -> { + }; + + void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size); + } + + public interface Invalidate { + + Invalidate NULL = (renderer) -> { + }; + + void onInvalidate(@NonNull Renderer renderer); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java new file mode 100644 index 00000000..860883e5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; + +class ThumbDragEditSession extends ElementEditSession { + + @NonNull + private final ThumbRenderer.ControlPoint controlPoint; + + private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + this.controlPoint = controlPoint; + } + + static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + float x = controlPoint.opposite().getX(); + float y = controlPoint.opposite().getY(); + + float dx = endPointElement[0].x - startPointElement[0].x; + float dy = endPointElement[0].y - startPointElement[0].y; + + float xEnd = controlPoint.getX() + dx; + float yEnd = controlPoint.getY() + dy; + + boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter(); + + float defaultScale = aspectLocked ? 2 : 1; + + float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (xEnd - x) / (controlPoint.getX() - x); + float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (yEnd - y) / (controlPoint.getY() - y); + + scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite()); + } + + private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) { + float x = around.getX(); + float y = around.getY(); + editorMatrix.postTranslate(-x, -y); + if (aspectLocked) { + float minScale = Math.min(scaleX, scaleY); + editorMatrix.postScale(minScale, minScale); + } else { + editorMatrix.postScale(scaleX, scaleY); + } + editorMatrix.postTranslate(x, y); + } + + @Override + public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) { + return null; + } + + @Override + public EditSession removePoint(@NonNull Matrix newInverse, int p) { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java new file mode 100644 index 00000000..6f7b5f11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.imageeditor; + +public interface UndoRedoStackListener { + + void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java new file mode 100644 index 00000000..dda8c3fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +import androidx.annotation.Nullable; + +final class AlphaAnimation { + + private final static Interpolator interpolator = new LinearInterpolator(); + + final static AlphaAnimation NULL_1 = new AlphaAnimation(1); + + private final float from; + private final float to; + private final Runnable invalidate; + private final boolean canAnimate; + private float animatedFraction; + + private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) { + this.from = from; + this.to = to; + this.invalidate = invalidate; + this.canAnimate = invalidate != null; + } + + private AlphaAnimation(float fixed) { + this(fixed, fixed, null); + } + + static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return new AlphaAnimation(to); + } + + if (from != to) { + AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate); + animationMatrix.start(); + return animationMatrix; + } else { + return new AlphaAnimation(to); + } + } + + private void start() { + if (canAnimate && invalidate != null) { + ValueAnimator animator = ValueAnimator.ofFloat(from, to); + animator.setDuration(200); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + float getValue() { + if (!canAnimate) return to; + + return animatedFraction; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java new file mode 100644 index 00000000..e9a52f96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.view.animation.CycleInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.CanvasMatrix; + +/** + * Animation Matrix provides a matrix that animates over time down to the identity matrix. + */ +final class AnimationMatrix { + + private final static float[] iValues = new float[9]; + private final static Interpolator interpolator = new DecelerateInterpolator(); + private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f)); + + static AnimationMatrix NULL = new AnimationMatrix(); + + static { + new Matrix().getValues(iValues); + } + + private final Runnable invalidate; + private final boolean canAnimate; + private final float[] undoValues = new float[9]; + + private final Matrix temp = new Matrix(); + private final float[] tempValues = new float[9]; + + private ValueAnimator animator; + private float animatedFraction; + + private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) { + this.invalidate = invalidate; + this.canAnimate = true; + undo.getValues(undoValues); + } + + private AnimationMatrix() { + canAnimate = false; + invalidate = null; + } + + static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + Matrix undo = new Matrix(); + boolean inverted = to.invert(undo); + if (inverted) { + undo.preConcat(from); + } + if (inverted && !undo.isIdentity()) { + AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate); + animationMatrix.start(interpolator); + return animationMatrix; + } else { + return NULL; + } + } + + /** + * Animate applying a matrix and then animate removing. + */ + static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate); + animationMatrix.start(pulseInterpolator); + + return animationMatrix; + } + + private void start(@NonNull Interpolator interpolator) { + if (canAnimate) { + animator = ValueAnimator.ofFloat(1, 0); + animator.setDuration(250); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + void stop() { + ValueAnimator animator = this.animator; + if (animator != null) animator.cancel(); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull Matrix onTo) { + if (!canAnimate) return; + + onTo.preConcat(buildTemp()); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) { + if (!canAnimate) return; + + canvasMatrix.concat(buildTemp()); + } + + private Matrix buildTemp() { + if (!canAnimate) { + temp.reset(); + return temp; + } + + final float fractionCompliment = 1f - animatedFraction; + for (int i = 0; i < 9; i++) { + tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i]; + } + + temp.setValues(tempValues); + return temp; + } + + private static Interpolator inverse(@NonNull Interpolator interpolator) { + return input -> 1f - interpolator.getInterpolation(input); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java new file mode 100644 index 00000000..024c0254 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/Bisect.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class Bisect { + + static final float ACCURACY = 0.001f; + + private static final int MAX_ITERATIONS = 16; + + interface Predicate { + boolean test(); + } + + interface ModifyElement { + void applyFactor(@NonNull Matrix matrix, float factor); + } + + /** + * Given a predicate function, attempts to finds the boundary between predicate true and predicate false. + * If it returns true, it will animate the element to the closest true value found to that boundary. + * + * @param element The element to modify. + * @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate. + * @param atMost A value believed to be in bounds. + * @param predicate The out of bounds predicate. + * @param modifyElement Apply the latest value to the element local matrix. + * @param invalidate For animation if finds a result. + * @return true iff finds a result. + */ + static boolean bisectToTest(@NonNull EditorElement element, + float outOfBoundsValue, + float atMost, + @NonNull Predicate predicate, + @NonNull ModifyElement modifyElement, + @NonNull Runnable invalidate) + { + Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement); + + if (closestSuccesful != null) { + element.animateLocalTo(closestSuccesful, invalidate); + return true; + } else { + return false; + } + } + + /** + * Given a predicate function, attempts to finds the boundary between predicate true and predicate false. + * Returns new local matrix for the element if a solution is found. + * + * @param element The element to modify. + * @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate. + * @param atMost A value believed to be in bounds. + * @param predicate The out of bounds predicate. + * @param modifyElement Apply the latest value to the element local matrix. + * @return matrix to replace local matrix iff finds a result, null otherwise. + */ + static @Nullable Matrix bisectToTest(@NonNull EditorElement element, + float outOfBoundsValue, + float atMost, + @NonNull Predicate predicate, + @NonNull ModifyElement modifyElement) + { + Matrix elementMatrix = element.getLocalMatrix(); + Matrix original = new Matrix(elementMatrix); + Matrix closestSuccessful = new Matrix(); + boolean haveResult = false; + int attempt = 0; + float successValue = 0; + float inBoundsValue = atMost; + float nextValueToTry = inBoundsValue; + + do { + attempt++; + + modifyElement.applyFactor(elementMatrix, nextValueToTry); + try { + + if (predicate.test()) { + inBoundsValue = nextValueToTry; + + // if first success or closer to out of bounds than the current closest + if (!haveResult || Math.abs(nextValueToTry - outOfBoundsValue) < Math.abs(successValue - outOfBoundsValue)) { + haveResult = true; + successValue = nextValueToTry; + closestSuccessful.set(elementMatrix); + } + } else { + if (attempt == 1) { + // failure on first attempt means inBoundsValue is actually out of bounds and so no solution + return null; + } + outOfBoundsValue = nextValueToTry; + } + } finally { + // reset + elementMatrix.set(original); + } + + nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f; + + } while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY); + + if (haveResult) { + return closestSuccessful; + } + return null; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java new file mode 100644 index 00000000..30af1006 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.UUID; + +/** + * Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen. + *

+ * Does not draw anything. + */ +class CropThumbRenderer implements Renderer, ThumbRenderer { + + private final ControlPoint controlPoint; + private final UUID toControl; + + private final float[] centreOnScreen = new float[2]; + private final Matrix matrix = new Matrix(); + private int size; + + CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) { + this.controlPoint = controlPoint; + this.toControl = toControl; + } + + @Override + public ControlPoint getControlPoint() { + return controlPoint; + } + + @Override + public UUID getElementToControl() { + return toControl; + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE); + rendererContext.canvasMatrix.copyTo(matrix); + size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size); + } + + @Override + public boolean hitTest(float x, float y) { + float[] hitPointOnScreen = new float[2]; + matrix.mapPoints(hitPointOnScreen, new float[]{ x, y }); + + float dx = centreOnScreen[0] - hitPointOnScreen[0]; + float dy = centreOnScreen[1] - hitPointOnScreen[1]; + + return dx * dx + dy * dy < size * size; + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropThumbRenderer createFromParcel(Parcel in) { + return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in)); + } + + @Override + public CropThumbRenderer[] newArray(int size) { + return new CropThumbRenderer[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(controlPoint.ordinal()); + ParcelUtils.writeUUID(dest, toControl); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java new file mode 100644 index 00000000..26d10d85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java @@ -0,0 +1,354 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * An image consists of a tree of {@link EditorElement}s. + *

+ * Each element has some persisted state: + * - An optional {@link Renderer} so that it can draw itself. + * - A list of child elements that make the tree possible. + * - Its own transformation matrix, which applies to itself and all its children. + * - A set of flags controlling visibility, selectablity etc. + *

+ * Then some temporary state. + * - A editor matrix for displaying as yet uncommitted edits. + * - An animation matrix for animating from one matrix to another. + * - Deleted children to allow them to fade out on delete. + * - Temporary flags, for temporary visibility, selectablity etc. + */ +public final class EditorElement implements Parcelable { + + private static final Comparator Z_ORDER_COMPARATOR = (e1, e2) -> Integer.compare(e1.zOrder, e2.zOrder); + + private final UUID id; + private final EditorFlags flags; + private final Matrix localMatrix = new Matrix(); + private final Matrix editorMatrix = new Matrix(); + private final int zOrder; + + @Nullable + private final Renderer renderer; + + private final Matrix temp = new Matrix(); + + private final Matrix tempMatrix = new Matrix(); + + private final List children = new LinkedList<>(); + private final List deletedChildren = new LinkedList<>(); + + @NonNull + private AnimationMatrix animationMatrix = AnimationMatrix.NULL; + + @NonNull + private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1; + + public EditorElement(@Nullable Renderer renderer) { + this(renderer, 0); + } + + public EditorElement(@Nullable Renderer renderer, int zOrder) { + this.id = UUID.randomUUID(); + this.flags = new EditorFlags(); + this.renderer = renderer; + this.zOrder = zOrder; + } + + private EditorElement(Parcel in) { + id = ParcelUtils.readUUID(in); + flags = new EditorFlags(in.readInt()); + ParcelUtils.readMatrix(localMatrix, in); + renderer = in.readParcelable(Renderer.class.getClassLoader()); + zOrder = in.readInt(); + in.readTypedList(children, EditorElement.CREATOR); + } + + UUID getId() { + return id; + } + + public @Nullable Renderer getRenderer() { + return renderer; + } + + /** + * Iff Visible, + * Renders tree with the following localMatrix: + *

+ * viewModelMatrix * localMatrix * editorMatrix * animationMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix + * + * @param rendererContext Canvas to draw on to. + */ + public void draw(@NonNull RendererContext rendererContext) { + if (!flags.isVisible() && !flags.isChildrenVisible()) return; + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(localMatrix); + + if (rendererContext.isEditing()) { + rendererContext.canvasMatrix.concat(editorMatrix); + animationMatrix.preConcatValueTo(rendererContext.canvasMatrix); + } + + if (flags.isVisible()) { + float alpha = alphaAnimation.getValue(); + if (alpha > 0) { + rendererContext.setFade(alpha); + rendererContext.setChildren(children); + drawSelf(rendererContext); + rendererContext.setFade(1f); + } + } + + if (flags.isChildrenVisible()) { + drawChildren(children, rendererContext); + drawChildren(deletedChildren, rendererContext); + } + + rendererContext.restore(); + } + + private void drawSelf(@NonNull RendererContext rendererContext) { + if (renderer == null) return; + renderer.render(rendererContext); + } + + private static void drawChildren(@NonNull List children, @NonNull RendererContext rendererContext) { + for (EditorElement element : children) { + if (element.zOrder >= 0) { + element.draw(rendererContext); + } + } + } + + public void addElement(@NonNull EditorElement element) { + children.add(element); + Collections.sort(children, Z_ORDER_COMPARATOR); + } + + public Matrix getLocalMatrix() { + return localMatrix; + } + + public Matrix getEditorMatrix() { + return editorMatrix; + } + + EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element); + } + + EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) { + final float[] dst = new float[2]; + final float[] src = { x, y }; + + return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> { + Renderer renderer = element.renderer; + if (renderer == null) return false; + inverseMatrix.mapPoints(dst, src); + return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]); + }); + } + + public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) { + temp.set(viewModelMatrix); + + temp.preConcat(localMatrix); + temp.preConcat(editorMatrix); + + if (temp.invert(tempMatrix)) { + + for (int i = children.size() - 1; i >= 0; i--) { + EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate); + if (elementAt != null) { + return elementAt; + } + } + + if (predicate.test(this, tempMatrix)) { + outInverseModelMatrix.set(tempMatrix); + return this; + } + } + + return null; + } + + public EditorFlags getFlags() { + return flags; + } + + int getChildCount() { + return children.size(); + } + + EditorElement getChild(int i) { + return children.get(i); + } + + void forAllInTree(@NonNull PerElementFunction function) { + function.apply(this); + for (EditorElement child : children) { + child.forAllInTree(function); + } + } + + void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) { + Iterator iterator = children.iterator(); + while (iterator.hasNext()) { + if (iterator.next() == editorElement) { + iterator.remove(); + addDeletedChildFadingOut(editorElement, invalidate); + } + } + } + + void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) { + deletedChildren.add(fromElement); + fromElement.animateFadeOut(invalidate); + } + + private void animateFadeOut(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(1, 0, invalidate); + } + + void animateFadeIn(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(0, 1, invalidate); + } + + @Nullable EditorElement parentOf(@NonNull EditorElement element) { + if (children.contains(element)) { + return this; + } + for (EditorElement child : children) { + EditorElement parent = child.parentOf(element); + if (parent != null) { + return parent; + } + } + return null; + } + + public void singleScalePulse(@Nullable Runnable invalidate) { + Matrix scale = new Matrix(); + scale.setScale(1.2f, 1.2f); + + animationMatrix = AnimationMatrix.singlePulse(scale, invalidate); + } + + public int getZOrder() { + return zOrder; + } + + public interface PerElementFunction { + void apply(EditorElement element); + } + + public interface FindElementPredicate { + boolean test(EditorElement element, Matrix inverseMatrix); + } + + public void commitEditorMatrix() { + if (flags.isEditable()) { + localMatrix.preConcat(editorMatrix); + editorMatrix.reset(); + } else { + rollbackEditorMatrix(null); + } + } + + void rollbackEditorMatrix(@Nullable Runnable invalidate) { + animateEditorTo(new Matrix(), invalidate); + } + + void buildMap(Map map) { + map.put(id, this); + for (EditorElement child : children) { + child.buildMap(map); + } + } + + void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) { + Matrix oldMatrixCopy = new Matrix(oldMatrix); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(oldMatrixCopy); + animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate); + } + + void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate); + } + + void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate); + } + + /** + * @param destination Matrix to change + * @param source Matrix value to set + * @param invalidate Callback to allow animation + */ + private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) { + Matrix old = new Matrix(destination); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(old); + destination.set(source); + animationMatrix = AnimationMatrix.animate(old, destination, invalidate); + } + + Matrix getLocalMatrixAnimating() { + Matrix matrix = new Matrix(localMatrix); + animationMatrix.preConcatValueTo(matrix); + return matrix; + } + + void stopAnimation() { + animationMatrix.stop(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorElement createFromParcel(Parcel in) { + return new EditorElement(in); + } + + @Override + public EditorElement[] newArray(int size) { + return new EditorElement[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + ParcelUtils.writeUUID(dest, id); + dest.writeInt(this.flags.asInt()); + ParcelUtils.writeMatrix(dest, localMatrix); + dest.writeParcelable(renderer, flags); + dest.writeInt(zOrder); + dest.writeTypedList(children); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java new file mode 100644 index 00000000..2c7cb593 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -0,0 +1,392 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.OvalGuideRenderer; + +/** + * Creates and handles a strict EditorElement Hierarchy. + *

+ * root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping + * | + * |- view - contains persisted adjustments for crops + * | | + * | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view + * | | + * | |- imageRoot + * | | |- mainImage + * | | |- stickers/drawings/text + * | | + * | |- overlay - always square + * | | |- imageCrop - a crop to match the aspect of the main image + * | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view + * | | | | | All children do not move/scale or rotate. + * | | | | |- blackout + * | | | | |- thumbs + * | | | | | |- Center left thumb + * | | | | | |- Center right thumb + * | | | | | |- Top center thumb + * | | | | | |- Bottom center thumb + * | | | | | |- Top left thumb + * | | | | | |- Top right thumb + * | | | | | |- Bottom left thumb + * | | | | | |- Bottom right thumb + */ +final class EditorElementHierarchy { + + static @NonNull EditorElementHierarchy create() { + return new EditorElementHierarchy(createRoot(CropStyle.RECTANGLE)); + } + + static @NonNull EditorElementHierarchy createForCircleEditing() { + return new EditorElementHierarchy(createRoot(CropStyle.CIRCLE)); + } + + static @NonNull EditorElementHierarchy createForPinchAndPanCropping() { + return new EditorElementHierarchy(createRoot(CropStyle.PINCH_AND_PAN)); + } + + static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) { + return new EditorElementHierarchy(root); + } + + private final EditorElement root; + private final EditorElement view; + private final EditorElement flipRotate; + private final EditorElement imageRoot; + private final EditorElement overlay; + private final EditorElement imageCrop; + private final EditorElement cropEditorElement; + private final EditorElement blackout; + private final EditorElement thumbs; + + private EditorElementHierarchy(@NonNull EditorElement root) { + this.root = root; + this.view = this.root.getChild(0); + this.flipRotate = this.view.getChild(0); + this.imageRoot = this.flipRotate.getChild(0); + this.overlay = this.flipRotate.getChild(1); + this.imageCrop = this.overlay.getChild(0); + this.cropEditorElement = this.imageCrop.getChild(0); + this.blackout = this.cropEditorElement.getChild(0); + this.thumbs = this.cropEditorElement.getChild(1); + } + + private enum CropStyle { + /** + * A rectangular overlay with 8 thumbs, corners and edges. + */ + RECTANGLE, + + /** + * Cropping with a circular template overlay with Corner thumbs only. + */ + CIRCLE, + + /** + * No overlay and no thumbs. Cropping achieved through pinching and panning. + */ + PINCH_AND_PAN + } + + private static @NonNull EditorElement createRoot(@NonNull CropStyle cropStyle) { + EditorElement root = new EditorElement(null); + + EditorElement imageRoot = new EditorElement(null); + root.addElement(imageRoot); + + EditorElement flipRotate = new EditorElement(null); + imageRoot.addElement(flipRotate); + + EditorElement image = new EditorElement(null); + flipRotate.addElement(image); + + EditorElement overlay = new EditorElement(null); + flipRotate.addElement(overlay); + + EditorElement imageCrop = new EditorElement(null); + overlay.addElement(imageCrop); + + boolean renderCenterThumbs = cropStyle == CropStyle.RECTANGLE; + EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, renderCenterThumbs)); + + cropEditorElement.getFlags() + .setRotateLocked(true) + .setAspectLocked(true) + .setSelectable(false) + .setVisible(false) + .persist(); + + imageCrop.addElement(cropEditorElement); + + EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000)); + + blackout.getFlags() + .setSelectable(false) + .setEditable(false) + .persist(); + + cropEditorElement.addElement(blackout); + + if (cropStyle == CropStyle.PINCH_AND_PAN) { + cropEditorElement.addElement(new EditorElement(null)); + } else { + cropEditorElement.addElement(createThumbs(cropEditorElement, renderCenterThumbs)); + + if (cropStyle == CropStyle.CIRCLE) { + EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color)); + circle.getFlags().setSelectable(false) + .persist(); + + cropEditorElement.addElement(circle); + } + } + + return root; + } + + private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement, boolean centerThumbs) { + EditorElement thumbs = new EditorElement(null); + + thumbs.getFlags() + .setChildrenVisible(false) + .setSelectable(false) + .setVisible(false) + .persist(); + + if (centerThumbs) { + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT)); + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER)); + } + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT)); + + return thumbs; + } + + private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) { + EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId())); + + element.getFlags() + .setSelectable(false) + .persist(); + + element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY()); + + return element; + } + + EditorElement getRoot() { + return root; + } + + EditorElement getImageRoot() { + return imageRoot; + } + + /** + * The main image, null if not yet set. + */ + @Nullable EditorElement getMainImage() { + return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null; + } + + EditorElement getCropEditorElement() { + return cropEditorElement; + } + + EditorElement getImageCrop() { + return imageCrop; + } + + EditorElement getOverlay() { + return overlay; + } + + EditorElement getFlipRotate() { + return flipRotate; + } + + /** + * @param scaleIn Use 1 for no scale in, use less than 1 and it will zoom the image out + * so user can see more of the surrounding image while cropping. + */ + void startCrop(@NonNull Runnable invalidate, float scaleIn) { + Matrix editor = new Matrix(); + + editor.postScale(scaleIn, scaleIn); + root.animateEditorTo(editor, invalidate); + + cropEditorElement.getFlags() + .setVisible(true); + + blackout.getFlags() + .setVisible(false); + + thumbs.getFlags() + .setChildrenVisible(true); + + thumbs.forAllInTree(element -> element.getFlags().setSelectable(true)); + + imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false)); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + mainImage.getFlags().setSelectable(true); + } + + invalidate.run(); + } + + void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + updateViewToCrop(visibleViewPort, invalidate); + + root.rollbackEditorMatrix(invalidate); + + root.forAllInTree(element -> element.getFlags().reset()); + } + + void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + RectF dst = new RectF(); + + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + + Matrix temp = new Matrix(); + temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER); + view.animateLocalTo(temp, invalidate); + } + + private @NonNull Matrix getCropFinalMatrix() { + Matrix matrix = new Matrix(flipRotate.getLocalMatrix()); + matrix.preConcat(imageCrop.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + return matrix; + } + + /** + * Returns a matrix that maps points from the crop on to the visible image. + *

+ * i.e. if a mapped point is in bounds, then the point is on the visible image. + */ + @Nullable Matrix imageMatrixRelativeToCrop() { + EditorElement mainImage = getMainImage(); + if (mainImage == null) return null; + + Matrix matrix1 = new Matrix(imageCrop.getLocalMatrix()); + matrix1.preConcat(cropEditorElement.getLocalMatrix()); + matrix1.preConcat(cropEditorElement.getEditorMatrix()); + + Matrix matrix2 = new Matrix(mainImage.getLocalMatrix()); + matrix2.preConcat(mainImage.getEditorMatrix()); + matrix2.preConcat(imageCrop.getLocalMatrix()); + + Matrix inverse = new Matrix(); + matrix2.invert(inverse); + inverse.preConcat(matrix1); + + return inverse; + } + + void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) { + if (cropEditorElement.getFlags().isVisible()) { + updateViewToCrop(visibleViewPort, invalidate); + } + } + + RectF getCropRect() { + RectF dst = new RectF(); + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + return dst; + } + + void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + Matrix newLocal = new Matrix(flipRotate.getLocalMatrix()); + if (degrees != 0) { + newLocal.postRotate(degrees); + } + newLocal.postScale(scaleX, scaleY); + flipRotate.animateLocalTo(newLocal, invalidate); + updateViewToCrop(visibleViewPort, invalidate); + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #root} down. + */ + Matrix getMainImageFullMatrix() { + Matrix matrix = new Matrix(); + + matrix.preConcat(view.getLocalMatrix()); + matrix.preConcat(getMainImageFullMatrixFromFlipRotate()); + + return matrix; + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down. + */ + Matrix getMainImageFullMatrixFromFlipRotate() { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(imageRoot.getLocalMatrix()); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + matrix.preConcat(mainImage.getLocalMatrix()); + } + + return matrix; + } + + /** + * Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy. + * + * @param inputSize Main image size + * @return Size after applying all zooms/rotates and crops + */ + PointF getOutputSize(@NonNull Point inputSize) { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getEditorMatrix()); + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + float xScale = 1f / (xScale(mainImage.getLocalMatrix()) * xScale(mainImage.getEditorMatrix())); + matrix.preScale(xScale, xScale); + } + + float[] dst = new float[4]; + matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y }); + + float widthF = Math.abs(dst[0] - dst[2]); + float heightF = Math.abs(dst[1] - dst[3]); + + return new PointF(widthF, heightF); + } + + /** + * Extract the x scale from a matrix, which is the length of the first column. + */ + static float xScale(@NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java new file mode 100644 index 00000000..77e4dfed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import androidx.annotation.NonNull; + +/** + * Flags for an {@link EditorElement}. + *

+ * Values you set are not persisted unless you call {@link #persist()}. + *

+ * This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}. + */ +public final class EditorFlags { + + private static final int ASPECT_LOCK = 1; + private static final int ROTATE_LOCK = 2; + private static final int SELECTABLE = 4; + private static final int VISIBLE = 8; + private static final int CHILDREN_VISIBLE = 16; + private static final int EDITABLE = 32; + + private int flags; + private int markedFlags; + private int persistedFlags; + + EditorFlags() { + this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE); + } + + EditorFlags(int flags) { + this.flags = flags; + this.persistedFlags = flags; + } + + public EditorFlags setRotateLocked(boolean rotateLocked) { + setFlag(ROTATE_LOCK, rotateLocked); + return this; + } + + public boolean isRotateLocked() { + return isFlagSet(ROTATE_LOCK); + } + + public EditorFlags setAspectLocked(boolean aspectLocked) { + setFlag(ASPECT_LOCK, aspectLocked); + return this; + } + + public boolean isAspectLocked() { + return isFlagSet(ASPECT_LOCK); + } + + public EditorFlags setSelectable(boolean selectable) { + setFlag(SELECTABLE, selectable); + return this; + } + + public boolean isSelectable() { + return isFlagSet(SELECTABLE); + } + + public EditorFlags setEditable(boolean canEdit) { + setFlag(EDITABLE, canEdit); + return this; + } + + public boolean isEditable() { + return isFlagSet(EDITABLE); + } + + public EditorFlags setVisible(boolean visible) { + setFlag(VISIBLE, visible); + return this; + } + + public boolean isVisible() { + return isFlagSet(VISIBLE); + } + + public EditorFlags setChildrenVisible(boolean childrenVisible) { + setFlag(CHILDREN_VISIBLE, childrenVisible); + return this; + } + + public boolean isChildrenVisible() { + return isFlagSet(CHILDREN_VISIBLE); + } + + private void setFlag(int flag, boolean set) { + if (set) { + this.flags |= flag; + } else { + this.flags &= ~flag; + } + } + + private boolean isFlagSet(int flag) { + return (flags & flag) != 0; + } + + int asInt() { + return persistedFlags; + } + + int getCurrentState() { + return flags; + } + + public void persist() { + persistedFlags = flags; + } + + public void reset() { + restoreState(persistedFlags); + } + + void restoreState(int flags) { + this.flags = flags; + } + + void mark() { + markedFlags = flags; + } + + void restore() { + flags = markedFlags; + } + + public void set(@NonNull EditorFlags from) { + this.persistedFlags = from.persistedFlags; + this.flags = from.flags; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java new file mode 100644 index 00000000..0ed3e948 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -0,0 +1,887 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; +import org.thoughtcrime.securesms.imageeditor.UndoRedoStackListener; +import org.thoughtcrime.securesms.imageeditor.renderers.FaceBlurRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a + * reference to the {@link EditorElementHierarchy}. + *

+ * As such it is the entry point for all operations that change the image. + */ +public final class EditorModel implements Parcelable, RendererContext.Ready { + + public static final int Z_MASK = -1; + public static final int Z_DRAWING = 0; + public static final int Z_STICKERS = 0; + public static final int Z_TEXT = 1; + + private static final Runnable NULL_RUNNABLE = () -> { + }; + + private static final int MINIMUM_OUTPUT_WIDTH = 1024; + + private static final int MINIMUM_CROP_PIXEL_COUNT = 100; + private static final Point MINIMUM_RATIO = new Point(15, 1); + + @NonNull + private Runnable invalidate = NULL_RUNNABLE; + + private UndoRedoStackListener undoRedoStackListener; + + private final UndoRedoStacks undoRedoStacks; + private final UndoRedoStacks cropUndoRedoStacks; + private final InBoundsMemory inBoundsMemory = new InBoundsMemory(); + + private EditorElementHierarchy editorElementHierarchy; + + private final RectF visibleViewPort = new RectF(); + private final Point size; + private final EditingPurpose editingPurpose; + private float fixedRatio; + + private enum EditingPurpose { + IMAGE, + AVATAR_CIRCLE, + WALLPAPER + } + + private EditorModel(@NonNull Parcel in) { + ClassLoader classLoader = getClass().getClassLoader(); + this.editingPurpose = EditingPurpose.values()[in.readInt()]; + this.fixedRatio = in.readFloat(); + this.size = new Point(in.readInt(), in.readInt()); + //noinspection ConstantConditions + this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); + this.undoRedoStacks = in.readParcelable(classLoader); + this.cropUndoRedoStacks = in.readParcelable(classLoader); + } + + public EditorModel(@NonNull EditingPurpose editingPurpose, float fixedRatio, @NonNull EditorElementHierarchy editorElementHierarchy) { + this.editingPurpose = editingPurpose; + this.fixedRatio = fixedRatio; + this.size = new Point(1024, 1024); + this.editorElementHierarchy = editorElementHierarchy; + this.undoRedoStacks = new UndoRedoStacks(50); + this.cropUndoRedoStacks = new UndoRedoStacks(50); + } + + public static EditorModel create() { + return new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create()); + } + + public static EditorModel createForCircleEditing() { + EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CIRCLE, 1, EditorElementHierarchy.createForCircleEditing()); + editorModel.setCropAspectLock(true); + return editorModel; + } + + public static EditorModel createForWallpaperEditing(float fixedRatio) { + EditorModel editorModel = new EditorModel(EditingPurpose.WALLPAPER, fixedRatio, EditorElementHierarchy.createForPinchAndPanCropping()); + editorModel.setCropAspectLock(true); + return editorModel; + } + + public void setInvalidate(@Nullable Runnable invalidate) { + this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE; + } + + public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + + updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); + } + + /** + * Renders tree with the following matrix: + *

+ * viewModelMatrix * matrix * editorMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix + * + * @param rendererContext Canvas to draw on to. + * @param renderOnTop This element will appear on top of the overlay. + */ + public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) { + EditorElement root = editorElementHierarchy.getRoot(); + if (renderOnTop != null) { + root.forAllInTree(element -> element.getFlags().mark()); + + renderOnTop.getFlags().setVisible(false); + } + + // pass 1 + root.draw(rendererContext); + + if (renderOnTop != null) { + // hide all + try { + root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element)); + + // pass 2 + root.draw(rendererContext); + } finally { + root.forAllInTree(element -> element.getFlags().restore()); + } + } + } + + public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = new Matrix(); + if (findElement(element, viewMatrix, inverse)) { + return inverse; + } + return null; + } + + private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = findElementInverseMatrix(element, viewMatrix); + if (inverse != null) { + Matrix regular = new Matrix(); + inverse.invert(regular); + return regular; + } + return null; + } + + public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix); + } + + private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element; + } + + public void pushUndoPoint() { + boolean cropping = isCropping(); + if (cropping && !currentCropIsAcceptable()) { + return; + } + + getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot()); + } + + public void undo() { + boolean cropping = isCropping(); + UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping); + + undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping); + + updateUndoRedoAvailableState(stacks); + } + + public void redo() { + boolean cropping = isCropping(); + UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping); + + undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping); + + updateUndoRedoAvailableState(stacks); + } + + private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) { + final EditorElement oldRootElement = editorElementHierarchy.getRoot(); + final EditorElement popped = fromStack.pop(oldRootElement); + + if (popped != null) { + editorElementHierarchy = EditorElementHierarchy.create(popped); + toStack.tryPush(oldRootElement); + + restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState); + invalidate.run(); + + // re-zoom image root as the view port might be different now + editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + + inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + } + } + + private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) { + Map fromMap = getElementMap(fromRootElement); + Map toMap = getElementMap(toRootElement); + + for (EditorElement fromElement : fromMap.values()) { + fromElement.stopAnimation(); + EditorElement toElement = toMap.get(fromElement.getId()); + if (toElement != null) { + toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate); + + if (keepEditorState) { + toElement.getEditorMatrix().set(fromElement.getEditorMatrix()); + toElement.getFlags().set(fromElement.getFlags()); + } + } else { + // element is removed + EditorElement parentFrom = fromRootElement.parentOf(fromElement); + if (parentFrom != null) { + EditorElement toParent = toMap.get(parentFrom.getId()); + if (toParent != null) { + toParent.addDeletedChildFadingOut(fromElement, onInvalidate); + } + } + } + } + + for (EditorElement toElement : toMap.values()) { + if (!fromMap.containsKey(toElement.getId())) { + // new item + toElement.animateFadeIn(onInvalidate); + } + } + } + + private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) { + if (undoRedoStackListener == null) return; + + EditorElement root = editorElementHierarchy.getRoot(); + + undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root)); + } + + private static Map getElementMap(@NonNull EditorElement element) { + final Map result = new HashMap<>(); + element.buildMap(result); + return result; + } + + public void startCrop() { + float scaleIn = editingPurpose == EditingPurpose.WALLPAPER ? 1 : 0.8f; + + pushUndoPoint(); + cropUndoRedoStacks.clear(editorElementHierarchy.getRoot()); + editorElementHierarchy.startCrop(invalidate, scaleIn); + inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + updateUndoRedoAvailableState(cropUndoRedoStacks); + } + + public void doneCrop() { + editorElementHierarchy.doneCrop(visibleViewPort, invalidate); + updateUndoRedoAvailableState(undoRedoStacks); + } + + public void setCropAspectLock(boolean locked) { + EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags(); + int currentState = flags.setAspectLocked(locked).getCurrentState(); + + flags.reset(); + flags.setAspectLocked(locked) + .persist(); + flags.restoreState(currentState); + } + + public boolean isCropAspectLocked() { + return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked(); + } + + public void postEdit(boolean allowScaleToRepairCrop) { + boolean cropping = isCropping(); + if (cropping) { + ensureFitsBounds(allowScaleToRepairCrop); + } + + updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping)); + } + + /** + * @param cropping Set to true if cropping is underway. + * @return The correct stack for the mode of operation. + */ + private UndoRedoStacks getActiveUndoRedoStacks(boolean cropping) { + return cropping ? cropUndoRedoStacks : undoRedoStacks; + } + + private void ensureFitsBounds(boolean allowScaleToRepairCrop) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + if (mainImage == null) return; + + EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement(); + + if (!currentCropIsAcceptable()) { + if (allowScaleToRepairCrop) { + if (!tryToScaleToFit(cropEditorElement, 0.9f)) { + tryToScaleToFit(mainImage, 2f); + } + } else { + tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix()); + } + + if (!currentCropIsAcceptable()) { + inBoundsMemory.restore(mainImage, cropEditorElement, invalidate); + } else { + inBoundsMemory.push(mainImage, cropEditorElement); + } + } + + editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate); + } + + /** + * Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true. + *

+ * Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards. + * + * @param element The element to be scaled. If successful, it will be animated to the correct position. + * @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image. + * @return true if successfully scaled the element. false if the element was left unchanged. + */ + private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) { + return Bisect.bisectToTest(element, + 1, + scaleAtMost, + this::cropIsWithinMainImageBounds, + (matrix, scale) -> matrix.preScale(scale, scale), + invalidate); + } + + /** + * Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true. + * If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y. + * + * @param element The element to be translated. If successful, it will be animated to the correct position. + * @param translateXAtMost The maximum translation to apply in the x axis. + * @param translateYAtMost The maximum translation to apply in the y axis. + * @return a matrix if successfully translated the element. null if the element unable to be translated to fit. + */ + private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) { + return Bisect.bisectToTest(element, + 0, + 1, + this::cropIsWithinMainImageBounds, + (matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost)); + } + + /** + * Tries to fix an element that is out of bounds by adjusting it's translation. + * + * @param element Element to move. + * @param lastKnownGoodPosition Last known good position of element. + * @return true iff fixed the element. + */ + private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) { + final Matrix elementMatrix = element.getLocalMatrix(); + final Matrix original = new Matrix(elementMatrix); + final float[] current = new float[9]; + final float[] lastGood = new float[9]; + Matrix matrix; + + elementMatrix.getValues(current); + lastKnownGoodPosition.getValues(lastGood); + + final float xTranslate = current[2] - lastGood[2]; + final float yTranslate = current[5] - lastGood[5]; + + if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) { + return false; + } + + float pass1X; + float pass1Y; + + float pass2X; + float pass2Y; + + // try the fix by the smallest user translation first + if (Math.abs(xTranslate) < Math.abs(yTranslate)) { + // try to bisect along x + pass1X = -xTranslate; + pass1Y = 0; + + // then y + pass2X = 0; + pass2Y = -yTranslate; + } else { + // try to bisect along y + pass1X = 0; + pass1Y = -yTranslate; + + // then x + pass2X = -xTranslate; + pass2Y = 0; + } + + matrix = tryToTranslateToFit(element, pass1X, pass1Y); + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + matrix = tryToTranslateToFit(element, pass2X, pass2Y); + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + // apply pass 1 fully + elementMatrix.postTranslate(pass1X, pass1Y); + + matrix = tryToTranslateToFit(element, pass2X, pass2Y); + elementMatrix.set(original); + + if (matrix != null) { + element.animateLocalTo(matrix, invalidate); + return true; + } + + return false; + } + + public void dragDropRelease() { + editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate); + } + + /** + * Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that) + * and all points must be within the bounds. + */ + private boolean currentCropIsAcceptable() { + Point outputSize = getOutputSize(); + int outputPixelCount = outputSize.x * outputSize.y; + int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT); + + Point thinnestRatio = MINIMUM_RATIO; + + if (compareRatios(size, thinnestRatio) < 0) { + // original is narrower than the thinnestRatio + thinnestRatio = size; + } + + return compareRatios(outputSize, thinnestRatio) >= 0 && + outputPixelCount >= minimumPixelCount && + cropIsWithinMainImageBounds(); + } + + /** + * -1 iff a is a narrower ratio than b. + * +1 iff a is a squarer ratio than b. + * 0 if the ratios are the same. + */ + private static int compareRatios(@NonNull Point a, @NonNull Point b) { + int smallA = Math.min(a.x, a.y); + int largeA = Math.max(a.x, a.y); + + int smallB = Math.min(b.x, b.y); + int largeB = Math.max(b.x, b.y); + + return Integer.compare(smallA * largeB, smallB * largeA); + } + + /** + * @return true if and only if the current crop rect is fully in the bounds. + */ + private boolean cropIsWithinMainImageBounds() { + return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop()); + } + + /** + * Called as edits are underway. + */ + public void moving(@NonNull EditorElement editorElement) { + if (!isCropping()) return; + + EditorElement mainImage = editorElementHierarchy.getMainImage(); + EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement(); + + if (editorElement == mainImage || editorElement == cropEditorElement) { + if (currentCropIsAcceptable()) { + inBoundsMemory.push(mainImage, cropEditorElement); + } + } + } + + public void setVisibleViewPort(@NonNull RectF visibleViewPort) { + this.visibleViewPort.set(visibleViewPort); + this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + } + + public Set getUniqueColorsIgnoringAlpha() { + final Set colors = new LinkedHashSet<>(); + + editorElementHierarchy.getRoot().forAllInTree(element -> { + Renderer renderer = element.getRenderer(); + if (renderer instanceof ColorableRenderer) { + colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000); + } + }); + + return colors; + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorModel createFromParcel(Parcel in) { + return new EditorModel(in); + } + + @Override + public EditorModel[] newArray(int size) { + return new EditorModel[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(editingPurpose.ordinal()); + dest.writeFloat(fixedRatio); + dest.writeInt(size.x); + dest.writeInt(size.y); + dest.writeParcelable(editorElementHierarchy.getRoot(), flags); + dest.writeParcelable(undoRedoStacks, flags); + dest.writeParcelable(cropUndoRedoStacks, flags); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public @NonNull Bitmap render(@NonNull Context context) { + return render(context, null); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) { + EditorElement image = editorElementHierarchy.getFlipRotate(); + RectF cropRect = editorElementHierarchy.getCropRect(); + Point outputSize = size != null ? size : getOutputSize(); + + Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888); + try { + Canvas canvas = new Canvas(bitmap); + RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL); + + RectF bitmapArea = new RectF(); + bitmapArea.right = bitmap.getWidth(); + bitmapArea.bottom = bitmap.getHeight(); + + Matrix viewMatrix = new Matrix(); + viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL); + + rendererContext.setIsEditing(false); + rendererContext.setBlockingLoad(true); + + EditorElement overlay = editorElementHierarchy.getOverlay(); + overlay.getFlags().setVisible(false).setChildrenVisible(false); + + try { + rendererContext.canvasMatrix.initial(viewMatrix); + image.draw(rendererContext); + } finally { + overlay.getFlags().reset(); + } + } catch (Exception e) { + bitmap.recycle(); + throw e; + } + return bitmap; + } + + @NonNull + private Point getOutputSize() { + PointF outputSize = editorElementHierarchy.getOutputSize(size); + + int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x); + int height = (int) (width * outputSize.y / outputSize.x); + + return new Point(width, height); + } + + @NonNull + public Point getOutputSizeMaxWidth(int maxDimension) { + PointF outputSize = editorElementHierarchy.getOutputSize(size); + + int width = Math.min(maxDimension, (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x)); + int height = (int) (width * outputSize.y / outputSize.x); + + if (height > maxDimension) { + height = maxDimension; + width = (int) (height * outputSize.x / outputSize.y); + } + + return new Point(width, height); + } + + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) { + boolean changedBefore = isChanged(); + Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix(); + this.size.set(size.x, size.y); + if (imageCropMatrix.isIdentity()) { + imageCropMatrix.set(cropMatrix); + + if (editingPurpose == EditingPurpose.AVATAR_CIRCLE || editingPurpose == EditingPurpose.WALLPAPER) { + Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); + if (size.x > size.y) { + userCropMatrix.setScale(fixedRatio * size.y / (float) size.x, 1f); + } else { + userCropMatrix.setScale(1f, size.x / (float) size.y); + } + } + + editorElementHierarchy.doneCrop(visibleViewPort, null); + + if (!changedBefore) { + undoRedoStacks.clear(editorElementHierarchy.getRoot()); + } + + switch (editingPurpose) { + case AVATAR_CIRCLE: { + startCrop(); + break; + } + case WALLPAPER: { + setFixedRatio(fixedRatio); + startCrop(); + break; + } + } + } + } + } + + public void setFixedRatio(float r) { + fixedRatio = r; + Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); + float w = size.x; + float h = size.y; + float imageRatio = w / h; + if (imageRatio > r) { + userCropMatrix.setScale(r / imageRatio, 1f); + } else { + userCropMatrix.setScale(1f, imageRatio / r); + } + + editorElementHierarchy.doneCrop(visibleViewPort, null); + startCrop(); + } + + private boolean isRendererOfMainImage(@NonNull Renderer renderer) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null; + return mainImageRenderer == renderer; + } + + /** + * Add a new {@link EditorElement} centered in the current visible crop area. + * + * @param element New element to add. + * @param scale Initial scale for new element. + */ + public void addElementCentered(@NonNull EditorElement element, float scale) { + Matrix localMatrix = element.getLocalMatrix(); + + editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix); + + localMatrix.preScale(scale, scale); + addElement(element); + } + + /** + * Add an element to the main image, or if there is no main image, make the new element the main image. + * + * @param element New element to add. + */ + public void addElement(@NonNull EditorElement element) { + pushUndoPoint(); + addElementWithoutPushUndo(element); + } + + public void addElementWithoutPushUndo(@NonNull EditorElement element) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot(); + + parent.addElement(element); + + if (parent != mainImage) { + undoRedoStacks.clear(editorElementHierarchy.getRoot()); + } + + updateUndoRedoAvailableState(undoRedoStacks); + } + + public void clearFaceRenderers() { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + if (mainImage != null) { + boolean hasPushedUndo = false; + for (int i = mainImage.getChildCount() - 1; i >= 0; i--) { + if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) { + if (!hasPushedUndo) { + pushUndoPoint(); + hasPushedUndo = true; + } + + mainImage.deleteChild(mainImage.getChild(i), invalidate); + } + } + } + } + + public boolean hasFaceRenderer() { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + if (mainImage != null) { + for (int i = mainImage.getChildCount() - 1; i >= 0; i--) { + if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) { + return true; + } + } + } + + return false; + } + + public boolean isChanged() { + return undoRedoStacks.isChanged(editorElementHierarchy.getRoot()); + } + + public RectF findCropRelativeToRoot() { + return findCropRelativeTo(editorElementHierarchy.getRoot()); + } + + RectF findCropRelativeTo(EditorElement element) { + return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element); + } + + RectF findRelativeBounds(EditorElement from, EditorElement to) { + Matrix relative = findRelativeMatrix(from, to); + + RectF dst = new RectF(Bounds.FULL_BOUNDS); + if (relative != null) { + relative.mapRect(dst, Bounds.FULL_BOUNDS); + } + return dst; + } + + /** + * Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element. + * + * @param from + * @param to + * @return + */ + @Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) { + Matrix matrix = findElementInverseMatrix(to, new Matrix()); + Matrix outOf = findElementMatrix(from, new Matrix()); + + if (outOf != null && matrix != null) { + matrix.preConcat(outOf); + return matrix; + } + return null; + } + + public void rotate90clockwise() { + flipRotate(90, 1, 1); + } + + public void rotate90anticlockwise() { + flipRotate(-90, 1, 1); + } + + public void flipHorizontal() { + flipRotate(0, -1, 1); + } + + public void flipVertical() { + flipRotate(0, 1, -1); + } + + private void flipRotate(int degrees, int scaleX, int scaleY) { + pushUndoPoint(); + editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate); + updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping())); + } + + public EditorElement getRoot() { + return editorElementHierarchy.getRoot(); + } + + public @Nullable EditorElement getMainImage() { + return editorElementHierarchy.getMainImage(); + } + + public void delete(@NonNull EditorElement editorElement) { + editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate)); + } + + public @Nullable EditorElement findById(@NonNull UUID uuid) { + return getElementMap(getRoot()).get(uuid); + } + + /** + * Changes the temporary view so that the text element is centered in it. + * + * @param entity Entity to center on. + * @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix + * to leave space for the keyboard for example. + */ + public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) { + Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix()); + if (elementInverseMatrix != null) { + EditorElement root = editorElementHierarchy.getRoot(); + + elementInverseMatrix.preConcat(root.getEditorMatrix()); + + textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix); + + root.animateEditorTo(elementInverseMatrix, invalidate); + } + } + + public void zoomOut() { + editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate); + } + + public void indicateSelected(@NonNull EditorElement selected) { + selected.singleScalePulse(invalidate); + } + + public boolean isCropping() { + return editorElementHierarchy.getCropEditorElement().getFlags().isVisible(); + } + + /** + * Returns a matrix that maps bounds to the crop area. + */ + public Matrix getInverseCropPosition() { + Matrix matrix = new Matrix(); + matrix.set(findRelativeMatrix(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement())); + matrix.postConcat(editorElementHierarchy.getFlipRotate().getLocalMatrix()); + + Matrix positionRelativeToCrop = new Matrix(); + matrix.invert(positionRelativeToCrop); + return positionRelativeToCrop; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java new file mode 100644 index 00000000..c0dd0587 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java @@ -0,0 +1,145 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.Stack; + +/** + * Contains a stack of elements for undo and redo stacks. + *

+ * Elements are mutable, so this stack serializes the element and keeps a stack of serialized data. + *

+ * The stack has a {@link #limit} and if it exceeds that limit during a push the second to earliest item + * is removed so that it can always go back to the first state. Effectively collapsing the history for + * the start of the stack. + */ +final class ElementStack implements Parcelable { + + private final int limit; + private final Stack stack = new Stack<>(); + + ElementStack(int limit) { + this.limit = limit; + } + + private ElementStack(@NonNull Parcel in) { + this(in.readInt()); + final int count = in.readInt(); + for (int i = 0; i < count; i++) { + stack.add(i, in.createByteArray()); + } + } + + /** + * Pushes an element to the stack iff the element's serialized value is different to any found at + * the top of the stack. + *

+ * Removes the second to earliest item if it is overflowing. + * + * @param element new editor element state. + * @return true iff the pushed item was different to the top item. + */ + boolean tryPush(@NonNull EditorElement element) { + byte[] bytes = getBytes(element); + boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek()); + + if (push) { + stack.push(bytes); + if (stack.size() > limit) { + stack.remove(1); + } + } + return push; + } + + static byte[] getBytes(@NonNull Parcelable parcelable) { + Parcel parcel = Parcel.obtain(); + byte[] bytes; + try { + parcel.writeParcelable(parcelable, 0); + bytes = parcel.marshall(); + } finally { + parcel.recycle(); + } + return bytes; + } + + /** + * Pops the first different state from the supplied element. + */ + @Nullable EditorElement pop(@NonNull EditorElement element) { + if (stack.empty()) return null; + + byte[] elementBytes = getBytes(element); + byte[] stackData = null; + + while (!stack.empty() && stackData == null) { + byte[] topData = stack.pop(); + + if (!Arrays.equals(topData, elementBytes)) { + stackData = topData; + } + } + + if (stackData == null) return null; + + Parcel parcel = Parcel.obtain(); + try { + parcel.unmarshall(stackData, 0, stackData.length); + parcel.setDataPosition(0); + return parcel.readParcelable(EditorElement.class.getClassLoader()); + } finally { + parcel.recycle(); + } + } + + void clear() { + stack.clear(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ElementStack createFromParcel(Parcel in) { + return new ElementStack(in); + } + + @Override + public ElementStack[] newArray(int size) { + return new ElementStack[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(limit); + final int count = stack.size(); + dest.writeInt(count); + for (int i = 0; i < count; i++) { + dest.writeByteArray(stack.get(i)); + } + } + + boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) { + if (stack.isEmpty()) return false; + + byte[] currentStateBytes = getBytes(element); + + for (byte[] item : stack) { + if (!Arrays.equals(item, currentStateBytes)) { + return true; + } + } + + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java new file mode 100644 index 00000000..3b03375d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/InBoundsMemory.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class InBoundsMemory { + + private final Matrix lastGoodUserCrop = new Matrix(); + private final Matrix lastGoodMainImage = new Matrix(); + + void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) { + if (mainImage == null) { + lastGoodMainImage.reset(); + } else { + lastGoodMainImage.set(mainImage.getLocalMatrix()); + lastGoodMainImage.preConcat(mainImage.getEditorMatrix()); + } + + lastGoodUserCrop.set(userCrop.getLocalMatrix()); + lastGoodUserCrop.preConcat(userCrop.getEditorMatrix()); + } + + void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) { + if (mainImage != null) { + mainImage.animateLocalTo(lastGoodMainImage, invalidate); + } + cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate); + } + + Matrix getLastKnownGoodMainImageMatrix() { + return new Matrix(lastGoodMainImage); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java new file mode 100644 index 00000000..e5b8e3a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import java.util.UUID; + +public final class ParcelUtils { + + private ParcelUtils() { + } + + public static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + dest.writeFloatArray(values); + } + + public static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) { + float[] values = new float[9]; + in.readFloatArray(values); + matrix.setValues(values); + } + + public static @NonNull Matrix readMatrix(@NonNull Parcel in) { + Matrix matrix = new Matrix(); + readMatrix(matrix, in); + return matrix; + } + + public static void writeRect(@NonNull Parcel dest, @NonNull RectF rect) { + dest.writeFloat(rect.left); + dest.writeFloat(rect.top); + dest.writeFloat(rect.right); + dest.writeFloat(rect.bottom); + } + + public static @NonNull RectF readRectF(@NonNull Parcel in) { + float left = in.readFloat(); + float top = in.readFloat(); + float right = in.readFloat(); + float bottom = in.readFloat(); + return new RectF(left, top, right, bottom); + } + + static UUID readUUID(@NonNull Parcel in) { + return new UUID(in.readLong(), in.readLong()); + } + + static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) { + dest.writeLong(uuid.getMostSignificantBits()); + dest.writeLong(uuid.getLeastSignificantBits()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java new file mode 100644 index 00000000..14476644 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; + +import java.util.UUID; + +/** + * A special {@link Renderer} that controls another {@link EditorElement}. + *

+ * It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of. + *

+ * The presence of this interface on the selected element is used to launch a ThumbDragEditSession. + */ +public interface ThumbRenderer extends Renderer { + + enum ControlPoint { + + CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y), + CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y), + + TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP), + BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM), + + TOP_LEFT (Bounds.LEFT, Bounds.TOP), + TOP_RIGHT (Bounds.RIGHT, Bounds.TOP), + BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM), + BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM); + + private final float x; + private final float y; + + ControlPoint(float x, float y) { + this.x = x; + this.y = y; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public ControlPoint opposite() { + switch (this) { + case CENTER_LEFT: return CENTER_RIGHT; + case CENTER_RIGHT: return CENTER_LEFT; + case TOP_CENTER: return BOTTOM_CENTER; + case BOTTOM_CENTER: return TOP_CENTER; + case TOP_LEFT: return BOTTOM_RIGHT; + case TOP_RIGHT: return BOTTOM_LEFT; + case BOTTOM_LEFT: return TOP_RIGHT; + case BOTTOM_RIGHT: return TOP_LEFT; + default: + throw new RuntimeException(); + } + } + + public boolean isHorizontalCenter() { + return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT; + } + + public boolean isVerticalCenter() { + return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER; + } + + public boolean isCenter() { + return isHorizontalCenter() || isVerticalCenter(); + } + } + + ControlPoint getControlPoint(); + + UUID getElementToControl(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java new file mode 100644 index 00000000..7ad7f126 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +final class UndoRedoStacks implements Parcelable { + + private final ElementStack undoStack; + private final ElementStack redoStack; + + @NonNull + private byte[] unchangedState; + + UndoRedoStacks(int limit) { + this(new ElementStack(limit), new ElementStack(limit), null); + } + + private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) { + this.undoStack = undoStack; + this.redoStack = redoStack; + this.unchangedState = unchangedState != null ? unchangedState : new byte[0]; + } + + public static final Creator CREATOR = new Creator() { + @Override + public UndoRedoStacks createFromParcel(Parcel in) { + return new UndoRedoStacks( + in.readParcelable(ElementStack.class.getClassLoader()), + in.readParcelable(ElementStack.class.getClassLoader()), + in.createByteArray() + ); + } + + @Override + public UndoRedoStacks[] newArray(int size) { + return new UndoRedoStacks[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(undoStack, flags); + dest.writeParcelable(redoStack, flags); + dest.writeByteArray(unchangedState); + } + + @Override + public int describeContents() { + return 0; + } + + ElementStack getUndoStack() { + return undoStack; + } + + ElementStack getRedoStack() { + return redoStack; + } + + void pushState(@NonNull EditorElement element) { + if (undoStack.tryPush(element)) { + redoStack.clear(); + } + } + + void clear(@NonNull EditorElement element) { + undoStack.clear(); + redoStack.clear(); + unchangedState = ElementStack.getBytes(element); + } + + boolean isChanged(@NonNull EditorElement element) { + return !Arrays.equals(ElementStack.getBytes(element), unchangedState); + } + + /** + * As long as there is something different in the stack somewhere, then we can undo. + */ + boolean canUndo(@NonNull EditorElement currentState) { + return undoStack.stackContainsStateDifferentFrom(currentState); + } + + /** + * As long as there is something different in the stack somewhere, then we can redo. + */ + boolean canRedo(@NonNull EditorElement currentState) { + return redoStack.stackContainsStateDifferentFrom(currentState); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java new file mode 100644 index 00000000..dedbbc3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +/** + * Given points for a line to go though, automatically finds control points. + *

+ * Based on http://www.particleincell.com/2012/bezier-splines/ + *

+ * Can then draw that line to a {@link Canvas} given a {@link Paint}. + *

+ * Allocation efficient so that adding new points does not result in lots of array allocations. + */ +final class AutomaticControlPointBezierLine implements Parcelable { + + private static final int INITIAL_CAPACITY = 256; + + private float[] x; + private float[] y; + + // control points + private float[] p1x; + private float[] p1y; + private float[] p2x; + private float[] p2y; + + private int count; + + private final Path path = new Path(); + + private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) { + this.count = count; + this.x = x != null ? x : new float[INITIAL_CAPACITY]; + this.y = y != null ? y : new float[INITIAL_CAPACITY]; + allocControlPointsAndWorkingMemory(this.x.length); + recalculateControlPoints(); + } + + AutomaticControlPointBezierLine() { + this(null, null, 0); + } + + void reset() { + count = 0; + path.reset(); + } + + /** + * Adds a new point to the end of the line but ignores points that are too close to the last. + * + * @param x new x point + * @param y new y point + * @param thickness the maximum distance to allow, line thickness is recommended. + */ + void addPointFiltered(float x, float y, float thickness) { + if (count > 0) { + float dx = this.x[count - 1] - x; + float dy = this.y[count - 1] - y; + if (dx * dx + dy * dy < thickness * thickness) { + return; + } + } + addPoint(x, y); + } + + /** + * Adds a new point to the end of the line. + * + * @param x new x point + * @param y new y point + */ + void addPoint(float x, float y) { + if (this.x == null || count == this.x.length) { + resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY); + } + + this.x[count] = x; + this.y[count] = y; + count++; + + recalculateControlPoints(); + } + + private void resize(int newCapacity) { + x = Arrays.copyOf(x, newCapacity); + y = Arrays.copyOf(y, newCapacity); + allocControlPointsAndWorkingMemory(newCapacity - 1); + } + + private void allocControlPointsAndWorkingMemory(int max) { + p1x = new float[max]; + p1y = new float[max]; + p2x = new float[max]; + p2y = new float[max]; + + a = new float[max]; + b = new float[max]; + c = new float[max]; + r = new float[max]; + } + + private void recalculateControlPoints() { + path.reset(); + + if (count > 2) { + computeControlPoints(x, p1x, p2x, count); + computeControlPoints(y, p1y, p2y, count); + } + + path.moveTo(x[0], y[0]); + switch (count) { + case 1: + path.lineTo(x[0], y[0]); + break; + case 2: + path.lineTo(x[1], y[1]); + break; + default: + for (int i = 1; i < count - 1; i++) { + path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]); + } + } + } + + /** + * Draw the line. + * + * @param canvas The canvas to draw on. + * @param paint The paint to use. + */ + void draw(@NonNull Canvas canvas, @NonNull Paint paint) { + canvas.drawPath(path, paint); + } + + // rhs vector for computeControlPoints method + private float[] a; + private float[] b; + private float[] c; + private float[] r; + + /** + * Based on http://www.particleincell.com/2012/bezier-splines/ + * + * @param k knots x or y, must be at least 2 entries + * @param p1 corresponding first control point x or y + * @param p2 corresponding second control point x or y + * @param count number of k to process + */ + private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) { + final int n = count - 1; + + // left most segment + a[0] = 0; + b[0] = 2; + c[0] = 1; + r[0] = k[0] + 2 * k[1]; + + // internal segments + for (int i = 1; i < n - 1; i++) { + a[i] = 1; + b[i] = 4; + c[i] = 1; + r[i] = 4 * k[i] + 2 * k[i + 1]; + } + + // right segment + a[n - 1] = 2; + b[n - 1] = 7; + c[n - 1] = 0; + r[n - 1] = 8 * k[n - 1] + k[n]; + + // solves Ax=b with the Thomas algorithm + for (int i = 1; i < n; i++) { + float m = a[i] / b[i - 1]; + b[i] = b[i] - m * c[i - 1]; + r[i] = r[i] - m * r[i - 1]; + } + + p1[n - 1] = r[n - 1] / b[n - 1]; + for (int i = n - 2; i >= 0; --i) { + p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i]; + } + + // we have p1, now compute p2 + for (int i = 0; i < n - 1; i++) { + p2[i] = 2 * k[i + 1] - p1[i + 1]; + } + + p2[n - 1] = 0.5f * (k[n] + p1[n - 1]); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AutomaticControlPointBezierLine createFromParcel(Parcel in) { + float[] x = in.createFloatArray(); + float[] y = in.createFloatArray(); + return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0); + } + + @Override + public AutomaticControlPointBezierLine[] newArray(int size) { + return new AutomaticControlPointBezierLine[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeFloatArray(Arrays.copyOfRange(x, 0, count)); + dest.writeFloatArray(Arrays.copyOfRange(y, 0, count)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java new file mode 100644 index 00000000..30423b9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type. + */ +public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer { + + private final Paint paint; + private final AutomaticControlPointBezierLine bezierLine; + private final Paint.Cap cap; + + @Nullable + private final RectF clipRect; + + private int color; + private float thickness; + + private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) { + this.paint = new Paint(); + this.color = color; + this.thickness = thickness; + this.cap = cap; + this.clipRect = clipRect; + this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine(); + + updatePaint(); + } + + public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) { + this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null); + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(int color) { + if (this.color != color) { + this.color = color; + updatePaint(); + invalidate(); + } + } + + public void setThickness(float thickness) { + if (this.thickness != thickness) { + this.thickness = thickness; + updatePaint(); + invalidate(); + } + } + + private void updatePaint() { + paint.setColor(color); + paint.setStrokeWidth(thickness); + paint.setStyle(Paint.Style.STROKE); + paint.setAntiAlias(true); + paint.setStrokeCap(cap); + } + + public void setFirstPoint(PointF point) { + bezierLine.reset(); + bezierLine.addPoint(point.x, point.y); + invalidate(); + } + + public void addNewPoint(PointF point) { + if (cap != Paint.Cap.ROUND) { + bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f); + } else { + bezierLine.addPoint(point.x, point.y); + } + invalidate(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + Canvas canvas = rendererContext.canvas; + canvas.save(); + if (clipRect != null) { + canvas.clipRect(clipRect); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + paint.setXfermode(rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint().getXfermode() : null); + + bezierLine.draw(canvas, paint); + + paint.setAlpha(alpha); + rendererContext.canvas.restore(); + } + + @Override + public boolean hitTest(float x, float y) { + return false; + } + + public static final Creator CREATOR = new Creator() { + @Override + public BezierDrawingRenderer createFromParcel(Parcel in) { + int color = in.readInt(); + float thickness = in.readFloat(); + Paint.Cap cap = Paint.Cap.values()[in.readInt()]; + AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader()); + RectF clipRect = in.readParcelable(RectF.class.getClassLoader()); + + return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect); + } + + @Override + public BezierDrawingRenderer[] newArray(int size) { + return new BezierDrawingRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeFloat(thickness); + dest.writeInt(cap.ordinal()); + dest.writeParcelable(bezierLine, flags); + dest.writeParcelable(clipRect, flags); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java new file mode 100644 index 00000000..a397e06e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color} + * and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color}, + * {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}. + *

+ * Hit tests outside of the bounds. + */ +public final class CropAreaRenderer implements Renderer { + + @ColorRes + private final int color; + private final boolean renderCenterThumbs; + + private final Path cropClipPath = new Path(); + private final Path screenClipPath = new Path(); + + private final RectF dst = new RectF(); + private final Paint paint = new Paint(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Resources resources = rendererContext.context.getResources(); + + canvas.clipPath(cropClipPath); + canvas.drawColor(ResourcesCompat.getColor(resources, color, null)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + + final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness); + final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10); + + paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null)); + + rendererContext.canvasMatrix.setToIdentity(); + screenClipPath.reset(); + screenClipPath.moveTo(dst.left, dst.top); + screenClipPath.lineTo(dst.right, dst.top); + screenClipPath.lineTo(dst.right, dst.bottom); + screenClipPath.lineTo(dst.left, dst.bottom); + screenClipPath.close(); + canvas.clipPath(screenClipPath); + canvas.translate(dst.left, dst.top); + + float halfDx = (dst.right - dst.left - size + thickness) / 2; + float halfDy = (dst.bottom - dst.top - size + thickness) / 2; + + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(-halfDx, 0); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); + + rendererContext.restore(); + } + + public CropAreaRenderer(@ColorRes int color, boolean renderCenterThumbs) { + this.color = color; + this.renderCenterThumbs = renderCenterThumbs; + + cropClipPath.toggleInverseFillType(); + cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM); + cropClipPath.close(); + screenClipPath.toggleInverseFillType(); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull CropAreaRenderer createFromParcel(@NonNull Parcel in) { + return new CropAreaRenderer(in.readInt(), + in.readByte() == 1); + } + + @Override + public @NonNull CropAreaRenderer[] newArray(int size) { + return new CropAreaRenderer[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeByte((byte) (renderCenterThumbs ? 1 : 0)); + } + + @Override + public int describeContents() { + return 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/FaceBlurRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/FaceBlurRenderer.java new file mode 100644 index 00000000..db5a39a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/FaceBlurRenderer.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * A rectangle that will be rendered on the blur mask layer. Intended for blurring faces. + */ +public final class FaceBlurRenderer implements Renderer { + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, rendererContext.getMaskPaint()); + } + + @Override + public boolean hitTest(float x, float y) { + return Bounds.FULL_BOUNDS.contains(x, y); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + } + + public static final Creator CREATOR = new Creator() { + @Override + public FaceBlurRenderer createFromParcel(Parcel in) { + return new FaceBlurRenderer(); + } + + @Override + public FaceBlurRenderer[] newArray(int size) { + return new FaceBlurRenderer[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java new file mode 100644 index 00000000..fd255e4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.lang.ref.WeakReference; + +/** + * Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk. + */ +abstract class InvalidateableRenderer implements Renderer { + + private WeakReference invalidate = new WeakReference<>(null); + + @Override + public void render(@NonNull RendererContext rendererContext) { + setInvalidate(rendererContext.invalidate); + } + + private void setInvalidate(RendererContext.Invalidate invalidate) { + if (invalidate != this.invalidate.get()) { + this.invalidate = new WeakReference<>(invalidate); + } + } + + protected void invalidate() { + RendererContext.Invalidate invalidate = this.invalidate.get(); + if (invalidate != null) { + invalidate.onInvalidate(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java new file mode 100644 index 00000000..86ad18ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Path; +import android.os.Parcel; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders the {@link color} outside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class InverseFillRenderer implements Renderer { + + private final int color; + + private final Path path = new Path(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvas.save(); + rendererContext.canvas.clipPath(path); + rendererContext.canvas.drawColor(color); + rendererContext.canvas.restore(); + } + + public InverseFillRenderer(@ColorInt int color) { + this.color = color; + path.toggleInverseFillType(); + path.moveTo(Bounds.LEFT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + path.lineTo(Bounds.LEFT, Bounds.BOTTOM); + path.close(); + } + + private InverseFillRenderer(Parcel in) { + this(in.readInt()); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public InverseFillRenderer createFromParcel(Parcel in) { + return new InverseFillRenderer(in); + } + + @Override + public InverseFillRenderer[] newArray(int size) { + return new InverseFillRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java new file mode 100644 index 00000000..91d878bf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java @@ -0,0 +1,395 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.Parcel; +import android.view.animation.Interpolator; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.emptyList; + +/** + * Renders multiple lines of {@link #text} in ths specified {@link #color}. + *

+ * Scales down the text size of long lines to fit inside the {@link Bounds} width. + */ +public final class MultiLineTextRenderer extends InvalidateableRenderer implements ColorableRenderer { + + @NonNull + private String text = ""; + + @ColorInt + private int color; + + private final Paint paint = new Paint(); + private final Paint selectionPaint = new Paint(); + + private final float textScale; + + private int selStart; + private int selEnd; + private boolean hasFocus; + + private List lines = emptyList(); + + private ValueAnimator cursorAnimator; + private float cursorAnimatedValue; + + private final Matrix recommendedEditorMatrix = new Matrix(); + + public MultiLineTextRenderer(@Nullable String text, @ColorInt int color) { + setColor(color); + float regularTextSize = paint.getTextSize(); + paint.setAntiAlias(true); + paint.setTextSize(100); + paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + textScale = paint.getTextSize() / regularTextSize; + selectionPaint.setAntiAlias(true); + setText(text != null ? text : ""); + createLinesForText(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + + for (Line line : lines) { + line.render(rendererContext); + } + } + + @NonNull + public String getText() { + return text; + } + + public void setText(@NonNull String text) { + if (!this.text.equals(text)) { + this.text = text; + createLinesForText(); + } + } + + /** + * Post concats an additional matrix to the supplied matrix that scales and positions the editor + * so that all the text is visible. + * + * @param matrix editor matrix, already zoomed and positioned to fit the regular bounds. + */ + public void applyRecommendedEditorMatrix(@NonNull Matrix matrix) { + recommendedEditorMatrix.reset(); + + float scale = 1f; + for (Line line : lines) { + if (line.scale < scale) { + scale = line.scale; + } + } + + float yOff = 0; + for (Line line : lines) { + if (line.containsSelectionEnd()) { + break; + } else { + yOff -= line.heightInBounds; + } + } + + recommendedEditorMatrix.postTranslate(0, Bounds.TOP / 1.5f + yOff); + + recommendedEditorMatrix.postScale(scale, scale); + + matrix.postConcat(recommendedEditorMatrix); + } + + private void createLinesForText() { + String[] split = text.split("\n", -1); + + if (split.length == lines.size()) { + for (int i = 0; i < split.length; i++) { + lines.get(i).setText(split[i]); + } + } else { + lines = new ArrayList<>(split.length); + for (String s : split) { + lines.add(new Line(s)); + } + } + setSelection(selStart, selEnd); + } + + private class Line { + private final Matrix accentMatrix = new Matrix(); + private final Matrix decentMatrix = new Matrix(); + private final Matrix projectionMatrix = new Matrix(); + private final Matrix inverseProjectionMatrix = new Matrix(); + private final RectF selectionBounds = new RectF(); + private final RectF textBounds = new RectF(); + + private String text; + private int selStart; + private int selEnd; + private float ascentInBounds; + private float descentInBounds; + private float scale = 1f; + private float heightInBounds; + + Line(String text) { + this.text = text; + recalculate(); + } + + private void recalculate() { + RectF maxTextBounds = new RectF(); + Rect temp = new Rect(); + + getTextBoundsWithoutTrim(text, 0, text.length(), temp); + textBounds.set(temp); + + maxTextBounds.set(textBounds); + float widthLimit = 150 * textScale; + + scale = 1f / Math.max(1, maxTextBounds.right / widthLimit); + + maxTextBounds.right = widthLimit; + + if (showSelectionOrCursor()) { + Rect startTemp = new Rect(); + int startInString = Math.min(text.length(), Math.max(0, selStart)); + int endInString = Math.min(text.length(), Math.max(0, selEnd)); + String startText = this.text.substring(0, startInString); + + getTextBoundsWithoutTrim(startText, 0, startInString, startTemp); + + if (selStart != selEnd) { + // selection + getTextBoundsWithoutTrim(text, startInString, endInString, temp); + } else { + // cursor + paint.getTextBounds("|", 0, 1, temp); + int width = temp.width(); + + temp.left -= width; + temp.right -= width; + } + + temp.left += startTemp.right; + temp.right += startTemp.right; + selectionBounds.set(temp); + } + + projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + removeTranslate(projectionMatrix); + + float[] pts = { 0, paint.ascent(), 0, paint.descent() }; + projectionMatrix.mapPoints(pts); + ascentInBounds = pts[1]; + descentInBounds = pts[3]; + heightInBounds = descentInBounds - ascentInBounds; + + projectionMatrix.preTranslate(-textBounds.centerX(), 0); + projectionMatrix.invert(inverseProjectionMatrix); + + accentMatrix.setTranslate(0, -ascentInBounds); + decentMatrix.setTranslate(0, descentInBounds); + + invalidate(); + } + + private void removeTranslate(Matrix matrix) { + float[] values = new float[9]; + + matrix.getValues(values); + values[2] = 0; + values[5] = 0; + matrix.setValues(values); + } + + private boolean showSelectionOrCursor() { + return (selStart >= 0 || selEnd >= 0) && + (selStart <= text.length() || selEnd <= text.length()); + } + + private boolean containsSelectionEnd() { + return (selEnd >= 0) && + (selEnd <= text.length()); + } + + private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) { + Rect extra = new Rect(); + Rect xBounds = new Rect(); + + String cannotBeTrimmed = "x" + text.substring(Math.max(0, start), Math.min(text.length(), end)) + "x"; + + paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra); + paint.getTextBounds("x", 0, 1, xBounds); + result.set(extra); + result.right -= 2 * xBounds.width(); + + int temp = result.left; + result.left -= temp; + result.right -= temp; + } + + public boolean contains(float x, float y) { + float[] dst = new float[2]; + + inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y }); + + return textBounds.contains(dst[0], dst[1]); + } + + void setText(String text) { + if (!this.text.equals(text)) { + this.text = text; + recalculate(); + } + } + + public void render(@NonNull RendererContext rendererContext) { + // add our ascent for ourselves and the next lines + rendererContext.canvasMatrix.concat(accentMatrix); + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(projectionMatrix); + + if (hasFocus && showSelectionOrCursor()) { + if (selStart == selEnd) { + selectionPaint.setAlpha((int) (cursorAnimatedValue * 128)); + } else { + selectionPaint.setAlpha(128); + } + rendererContext.canvas.drawRect(selectionBounds, selectionPaint); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawText(text, 0, 0, paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + + // add our descent for the next lines + rendererContext.canvasMatrix.concat(decentMatrix); + } + + void setSelection(int selStart, int selEnd) { + if (selStart != this.selStart || selEnd != this.selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + recalculate(); + } + } + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(@ColorInt int color) { + if (this.color != color) { + this.color = color; + paint.setColor(color); + selectionPaint.setColor(color); + invalidate(); + } + } + + @Override + public boolean hitTest(float x, float y) { + for (Line line : lines) { + y += line.ascentInBounds; + if (line.contains(x, y)) return true; + y -= line.descentInBounds; + } + return false; + } + + public void setSelection(int selStart, int selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + for (Line line : lines) { + line.setSelection(selStart, selEnd); + + int length = line.text.length() + 1; // one for new line + + selStart -= length; + selEnd -= length; + } + } + + public void setFocused(boolean hasFocus) { + if (this.hasFocus != hasFocus) { + this.hasFocus = hasFocus; + if (cursorAnimator != null) { + cursorAnimator.cancel(); + cursorAnimator = null; + } + if (hasFocus) { + cursorAnimator = ValueAnimator.ofFloat(0, 1); + cursorAnimator.setInterpolator(pulseInterpolator()); + cursorAnimator.setRepeatCount(ValueAnimator.INFINITE); + cursorAnimator.setDuration(1000); + cursorAnimator.addUpdateListener(animation -> { + cursorAnimatedValue = (float) animation.getAnimatedValue(); + invalidate(); + }); + cursorAnimator.start(); + } else { + invalidate(); + } + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public MultiLineTextRenderer createFromParcel(Parcel in) { + return new MultiLineTextRenderer(in.readString(), in.readInt()); + } + + @Override + public MultiLineTextRenderer[] newArray(int size) { + return new MultiLineTextRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeInt(color); + } + + private static Interpolator pulseInterpolator() { + return input -> { + input *= 5; + if (input > 1) { + input = 4 - input; + } + return Math.max(0, Math.min(1, input)); + }; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java new file mode 100644 index 00000000..643e8388 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders an oval inside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class OvalGuideRenderer implements Renderer { + + private final @ColorRes int ovalGuideColor; + + private final Paint paint; + + private final RectF dst = new RectF(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Context context = rendererContext.context; + int stroke = context.getResources().getDimensionPixelSize(R.dimen.oval_guide_stroke_width); + float halfStroke = stroke / 2f; + + this.paint.setStrokeWidth(stroke); + paint.setColor(ContextCompat.getColor(context, ovalGuideColor)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + dst.set(dst.left + halfStroke, dst.top + halfStroke, dst.right - halfStroke, dst.bottom - halfStroke); + + rendererContext.canvasMatrix.setToIdentity(); + canvas.drawOval(dst, paint); + + rendererContext.restore(); + } + + public OvalGuideRenderer(@ColorRes int color) { + this.ovalGuideColor = color; + + this.paint = new Paint(); + this.paint.setStyle(Paint.Style.STROKE); + this.paint.setAntiAlias(true); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull OvalGuideRenderer createFromParcel(@NonNull Parcel in) { + return new OvalGuideRenderer(in.readInt()); + } + + @Override + public @NonNull OvalGuideRenderer[] newArray(int size) { + return new OvalGuideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(ovalGuideColor); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsAnimatorSetFactory.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsAnimatorSetFactory.java new file mode 100644 index 00000000..a46499ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsAnimatorSetFactory.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.insights; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +final class InsightsAnimatorSetFactory { + private static final int PROGRESS_ANIMATION_DURATION = 800; + private static final int DETAILS_ANIMATION_DURATION = 200; + private static final int PERCENT_SECURE_ANIMATION_DURATION = 400; + private static final int LOTTIE_ANIMATION_DURATION = 1500; + private static final int ANIMATION_START_DELAY = PROGRESS_ANIMATION_DURATION - DETAILS_ANIMATION_DURATION; + private static final float PERCENT_SECURE_MAX_SCALE = 1.3f; + + private InsightsAnimatorSetFactory() { + } + + static AnimatorSet create(int insecurePercent, + @Nullable final UpdateListener progressUpdateListener, + @Nullable final UpdateListener detailsUpdateListener, + @Nullable final UpdateListener percentSecureListener, + @Nullable final UpdateListener lottieListener) + { + final int securePercent = 100 - insecurePercent; + final AnimatorSet animatorSet = new AnimatorSet(); + final ValueAnimator[] animators = Stream.of(createProgressAnimator(securePercent, progressUpdateListener), + createDetailsAnimator(detailsUpdateListener), + createPercentSecureAnimator(percentSecureListener), + createLottieAnimator(lottieListener)) + .filter(a -> a != null) + .toArray(ValueAnimator[]::new); + + animatorSet.setInterpolator(new DecelerateInterpolator()); + animatorSet.playTogether(animators); + + return animatorSet; + } + + private static @Nullable Animator createProgressAnimator(int securePercent, @Nullable UpdateListener updateListener) { + if (updateListener == null) return null; + + final ValueAnimator progressAnimator = ValueAnimator.ofFloat(0, securePercent / 100f); + + progressAnimator.setDuration(PROGRESS_ANIMATION_DURATION); + progressAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue())); + + return progressAnimator; + } + + private static @Nullable Animator createDetailsAnimator(@Nullable UpdateListener updateListener) { + if (updateListener == null) return null; + + final ValueAnimator detailsAnimator = ValueAnimator.ofFloat(0, 1f); + + detailsAnimator.setDuration(DETAILS_ANIMATION_DURATION); + detailsAnimator.setStartDelay(ANIMATION_START_DELAY); + detailsAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue())); + + return detailsAnimator; + } + + private static @Nullable Animator createPercentSecureAnimator(@Nullable UpdateListener updateListener) { + if (updateListener == null) return null; + + final ValueAnimator percentSecureAnimator = ValueAnimator.ofFloat(1f, PERCENT_SECURE_MAX_SCALE, 1f); + + percentSecureAnimator.setStartDelay(ANIMATION_START_DELAY); + percentSecureAnimator.setDuration(PERCENT_SECURE_ANIMATION_DURATION); + percentSecureAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue())); + + return percentSecureAnimator; + } + + private static @Nullable Animator createLottieAnimator(@Nullable UpdateListener updateListener) { + if (updateListener == null) return null; + + final ValueAnimator lottieAnimator = ValueAnimator.ofFloat(0, 1f); + + lottieAnimator.setStartDelay(ANIMATION_START_DELAY); + lottieAnimator.setDuration(LOTTIE_ANIMATION_DURATION); + lottieAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue())); + + return lottieAnimator; + } + + interface UpdateListener { + void onUpdate(float value); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsConstants.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsConstants.java new file mode 100644 index 00000000..40435b33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsConstants.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.insights; + +import java.util.concurrent.TimeUnit; + +public final class InsightsConstants { + + public static final long PERIOD_IN_DAYS = 7L; + public static final long PERIOD_IN_MILLIS = TimeUnit.DAYS.toMillis(PERIOD_IN_DAYS); + + private InsightsConstants() { + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardDialogFragment.java new file mode 100644 index 00000000..c0a2ce8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardDialogFragment.java @@ -0,0 +1,271 @@ +package org.thoughtcrime.securesms.insights; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import com.airbnb.lottie.LottieAnimationView; + +import org.thoughtcrime.securesms.NewConversationActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ArcProgressBar; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.List; + +public final class InsightsDashboardDialogFragment extends DialogFragment { + + private TextView securePercentage; + private ArcProgressBar progress; + private View progressContainer; + private TextView tagline; + private TextView encryptedMessages; + private TextView title; + private TextView description; + private RecyclerView insecureRecipients; + private TextView locallyGenerated; + private AvatarImageView avatarImageView; + private InsightsInsecureRecipientsAdapter adapter; + private LottieAnimationView lottieAnimationView; + private AnimatorSet animatorSet; + private Button startAConversation; + private Toolbar toolbar; + private InsightsDashboardViewModel viewModel; + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + requireFragmentManager().beginTransaction() + .detach(this) + .attach(this) + .commit(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (ThemeUtil.isDarkTheme(requireActivity())) { + setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme); + } else { + setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.insights_dashboard, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + securePercentage = view.findViewById(R.id.insights_dashboard_percent_secure); + progress = view.findViewById(R.id.insights_dashboard_progress); + progressContainer = view.findViewById(R.id.insights_dashboard_percent_container); + encryptedMessages = view.findViewById(R.id.insights_dashboard_encrypted_messages); + tagline = view.findViewById(R.id.insights_dashboard_tagline); + title = view.findViewById(R.id.insights_dashboard_make_signal_secure); + description = view.findViewById(R.id.insights_dashboard_invite_your_contacts); + insecureRecipients = view.findViewById(R.id.insights_dashboard_recycler); + locallyGenerated = view.findViewById(R.id.insights_dashboard_this_stat_was_generated_locally); + avatarImageView = view.findViewById(R.id.insights_dashboard_avatar); + startAConversation = view.findViewById(R.id.insights_dashboard_start_a_conversation); + lottieAnimationView = view.findViewById(R.id.insights_dashboard_lottie_animation); + toolbar = view.findViewById(R.id.insights_dashboard_toolbar); + + setupStartAConversation(); + setDashboardDetailsAlpha(0f); + setNotEnoughDataAlpha(0f); + setupToolbar(); + setupRecycler(); + initializeViewModel(); + } + + private void setupStartAConversation() { + startAConversation.setOnClickListener(v -> startActivity(new Intent(requireActivity(), NewConversationActivity.class))); + } + + private void setDashboardDetailsAlpha(float alpha) { + tagline.setAlpha(alpha); + title.setAlpha(alpha); + description.setAlpha(alpha); + insecureRecipients.setAlpha(alpha); + locallyGenerated.setAlpha(alpha); + encryptedMessages.setAlpha(alpha); + } + + private void setupToolbar() { + toolbar.setNavigationOnClickListener(v -> dismiss()); + } + + private void setupRecycler() { + adapter = new InsightsInsecureRecipientsAdapter(this::handleInviteRecipient); + insecureRecipients.setAdapter(adapter); + } + + private void initializeViewModel() { + final InsightsDashboardViewModel.Repository repository = new InsightsRepository(requireContext()); + final InsightsDashboardViewModel.Factory factory = new InsightsDashboardViewModel.Factory(repository); + + viewModel = ViewModelProviders.of(this, factory).get(InsightsDashboardViewModel.class); + + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + updateInsecurePercent(state.getData()); + updateInsecureRecipients(state.getInsecureRecipients()); + updateUserAvatar(state.getUserAvatar()); + }); + } + + private void updateInsecurePercent(@Nullable InsightsData insightsData) { + if (insightsData == null) return; + + if (insightsData.hasEnoughData()) { + setTitleAndDescriptionText(insightsData.getPercentInsecure()); + animateProgress(insightsData.getPercentInsecure()); + } else { + setNotEnoughDataText(); + animateNotEnoughData(); + } + } + + private void animateProgress(int insecurePercent) { + startAConversation.setVisibility(View.GONE); + if (animatorSet == null) { + animatorSet = InsightsAnimatorSetFactory.create(insecurePercent, + this::setProgressPercentage, + this::setDashboardDetailsAlpha, + this::setPercentSecureScale, + insecurePercent == 0 ? this::setLottieProgress : null); + + if (insecurePercent == 0) { + animatorSet.addListener(new ToolbarBackgroundColorAnimationListener()); + } + + animatorSet.start(); + } + } + + private void setProgressPercentage(float percent) { + securePercentage.setText(String.valueOf(Math.round(percent * 100))); + progress.setProgress(percent); + } + + private void setPercentSecureScale(float scale) { + progressContainer.setScaleX(scale); + progressContainer.setScaleY(scale); + } + + private void setLottieProgress(float progress) { + lottieAnimationView.setProgress(progress); + } + + private void setTitleAndDescriptionText(int insecurePercent) { + startAConversation.setVisibility(View.GONE); + progressContainer.setVisibility(View.VISIBLE); + insecureRecipients.setVisibility(View.VISIBLE); + encryptedMessages.setText(R.string.InsightsDashboardFragment__encrypted_messages); + tagline.setText(getString(R.string.InsightsDashboardFragment__signal_protocol_automatically_protected, 100 - insecurePercent, InsightsConstants.PERIOD_IN_DAYS)); + + if (insecurePercent == 0) { + lottieAnimationView.setVisibility(View.VISIBLE); + title.setVisibility(View.GONE); + description.setVisibility(View.GONE); + } else { + lottieAnimationView.setVisibility(View.GONE); + title.setText(R.string.InsightsDashboardFragment__boost_your_signal); + description.setText(R.string.InsightsDashboardFragment__invite_your_contacts); + title.setVisibility(View.VISIBLE); + description.setVisibility(View.VISIBLE); + } + } + + private void setNotEnoughDataText() { + startAConversation.setVisibility(View.VISIBLE); + progressContainer.setVisibility(View.INVISIBLE); + insecureRecipients.setVisibility(View.GONE); + encryptedMessages.setText(R.string.InsightsDashboardFragment__not_enough_data); + tagline.setText(getString(R.string.InsightsDashboardFragment__your_insights_percentage_is_calculated_based_on, InsightsConstants.PERIOD_IN_DAYS)); + } + + private void animateNotEnoughData() { + if (animatorSet == null) { + animatorSet = InsightsAnimatorSetFactory.create(0, null, this::setNotEnoughDataAlpha, null, null); + animatorSet.start(); + } + } + + private void setNotEnoughDataAlpha(float alpha) { + encryptedMessages.setAlpha(alpha); + tagline.setAlpha(alpha); + startAConversation.setAlpha(alpha); + } + + private void updateInsecureRecipients(@NonNull List recipients) { + adapter.updateData(recipients); + } + + private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) { + if (userAvatar == null) avatarImageView.setImageDrawable(null); + else userAvatar.load(avatarImageView); + } + + private void handleInviteRecipient(final @NonNull Recipient recipient) { + new AlertDialog.Builder(requireContext()) + .setTitle(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_invites, 1, 1)) + .setMessage(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))) + .setPositiveButton(R.string.InsightsDashboardFragment__send, (dialog, which) -> viewModel.sendSmsInvite(recipient)) + .setNegativeButton(R.string.InsightsDashboardFragment__cancel, (dialog, which) -> dialog.dismiss()) + .show(); + } + + @Override + public void onDestroyView() { + if (animatorSet != null) { + animatorSet.cancel(); + animatorSet = null; + } + + super.onDestroyView(); + } + + private final class ToolbarBackgroundColorAnimationListener implements Animator.AnimatorListener { + + @Override + public void onAnimationStart(Animator animation) { + toolbar.setBackgroundResource(R.color.transparent); + } + + @Override + public void onAnimationEnd(Animator animation) { + toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground)); + } + + @Override + public void onAnimationCancel(Animator animation) { + toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground)); + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardState.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardState.java new file mode 100644 index 00000000..f9793334 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardState.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.insights; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.List; + +final class InsightsDashboardState { + + private final List insecureRecipients; + private final InsightsData insightsData; + private final InsightsUserAvatar userAvatar; + + private InsightsDashboardState(@NonNull Builder builder) { + this.insecureRecipients = builder.insecureRecipients; + this.insightsData = builder.insightsData; + this.userAvatar = builder.userAvatar; + } + + static @NonNull InsightsDashboardState.Builder builder() { + return new InsightsDashboardState.Builder(); + } + + @NonNull InsightsDashboardState.Builder buildUpon() { + return builder().withData(insightsData).withUserAvatar(userAvatar).withInsecureRecipients(insecureRecipients); + } + + @NonNull List getInsecureRecipients() { + return insecureRecipients; + } + + @Nullable InsightsUserAvatar getUserAvatar() { + return userAvatar; + } + + @Nullable InsightsData getData() { + return insightsData; + } + + static final class Builder { + private List insecureRecipients = Collections.emptyList(); + private InsightsUserAvatar userAvatar; + private InsightsData insightsData; + + private Builder() { + } + + @NonNull Builder withInsecureRecipients(@NonNull List insecureRecipients) { + this.insecureRecipients = insecureRecipients; + return this; + } + + @NonNull Builder withData(@NonNull InsightsData insightsData) { + this.insightsData = insightsData; + return this; + } + + @NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) { + this.userAvatar = userAvatar; + return this; + } + + @NonNull InsightsDashboardState build() { + return new InsightsDashboardState(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardViewModel.java new file mode 100644 index 00000000..af604a9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsDashboardViewModel.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.insights; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +final class InsightsDashboardViewModel extends ViewModel { + + private final MutableLiveData internalState = new MutableLiveData<>(InsightsDashboardState.builder().build()); + private final Repository repository; + + private InsightsDashboardViewModel(@NonNull Repository repository) { + this.repository = repository; + + repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data)))); + repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar)))); + updateInsecureRecipients(); + } + + private void updateInsecureRecipients() { + repository.getInsecureRecipients(recipients -> internalState.setValue(getNewState(b -> b.withInsecureRecipients(recipients)))); + } + + @MainThread + private InsightsDashboardState getNewState(Consumer builderConsumer) { + InsightsDashboardState.Builder builder = internalState.getValue().buildUpon(); + builderConsumer.accept(builder); + return builder.build(); + } + + @NonNull LiveData getState() { + return internalState; + } + + public void sendSmsInvite(@NonNull Recipient recipient) { + repository.sendSmsInvite(recipient, this::updateInsecureRecipients); + } + + interface Repository { + void getInsightsData(@NonNull Consumer insightsDataConsumer); + void getInsecureRecipients(@NonNull Consumer> insecureRecipientsConsumer); + void getUserAvatar(@NonNull Consumer userAvatarConsumer); + void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent); + } + + final static class Factory implements ViewModelProvider.Factory { + + private final Repository repository; + + Factory(@NonNull Repository repository) { + this.repository = repository; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + return (T) new InsightsDashboardViewModel(repository); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsData.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsData.java new file mode 100644 index 00000000..5fb4b5da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsData.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.insights; + +final class InsightsData { + private final boolean hasEnoughData; + private final int percentInsecure; + + InsightsData(boolean hasEnoughData, int percentInsecure) { + this.hasEnoughData = hasEnoughData; + this.percentInsecure = percentInsecure; + } + + public boolean hasEnoughData() { + return hasEnoughData; + } + + public int getPercentInsecure() { + return percentInsecure; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsInsecureRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsInsecureRecipientsAdapter.java new file mode 100644 index 00000000..d36e0be2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsInsecureRecipientsAdapter.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.insights; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.List; + +final class InsightsInsecureRecipientsAdapter extends RecyclerView.Adapter { + + private List data = Collections.emptyList(); + + private final Consumer onInviteClickedConsumer; + + InsightsInsecureRecipientsAdapter(Consumer onInviteClickedConsumer) { + this.onInviteClickedConsumer = onInviteClickedConsumer; + } + + public void updateData(List recipients) { + List oldData = data; + data = recipients; + + DiffUtil.calculateDiff(new DiffCallback(oldData, data)).dispatchUpdatesTo(this); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.insights_dashboard_adapter_item, parent, false), this::handleInviteClicked); + } + + private void handleInviteClicked(@NonNull Integer position) { + onInviteClickedConsumer.accept(data.get(position)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + + static final class ViewHolder extends RecyclerView.ViewHolder { + + private AvatarImageView avatarImageView; + private TextView displayName; + + private ViewHolder(@NonNull View itemView, Consumer onInviteClicked) { + super(itemView); + + avatarImageView = itemView.findViewById(R.id.recipient_avatar); + displayName = itemView.findViewById(R.id.recipient_display_name); + + Button invite = itemView.findViewById(R.id.recipient_invite); + invite.setOnClickListener(v -> { + int adapterPosition = getAdapterPosition(); + + if (adapterPosition == RecyclerView.NO_POSITION) return; + + onInviteClicked.accept(adapterPosition); + }); + } + + private void bind(@NonNull Recipient recipient) { + displayName.setText(recipient.getDisplayName(itemView.getContext())); + avatarImageView.setAvatar(GlideApp.with(itemView), recipient, false); + } + } + + private static class DiffCallback extends DiffUtil.Callback { + + private final List oldData; + private final List newData; + + private DiffCallback(@NonNull List oldData, + @NonNull List newData) + { + this.oldData = oldData; + this.newData = newData; + } + + @Override + public int getOldListSize() { + return oldData.size(); + } + + @Override + public int getNewListSize() { + return newData.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return oldData.get(oldItemPosition).getId() == newData.get(newItemPosition).getId(); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return oldData.get(oldItemPosition).equals(newData.get(newItemPosition)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsLauncher.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsLauncher.java new file mode 100644 index 00000000..e5de5fee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsLauncher.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.insights; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +public final class InsightsLauncher { + + private static final String MODAL_TAG = "modal.fragment"; + + public static void showInsightsModal(@NonNull Context context, @NonNull FragmentManager fragmentManager) { + if (InsightsOptOut.userHasOptedOut(context)) return; + + final Fragment fragment = fragmentManager.findFragmentByTag(MODAL_TAG); + + if (fragment == null) new InsightsModalDialogFragment().show(fragmentManager, MODAL_TAG); + } + + public static void showInsightsDashboard(@NonNull FragmentManager fragmentManager) { + new InsightsDashboardDialogFragment().show(fragmentManager, null); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalDialogFragment.java new file mode 100644 index 00000000..3f4e31d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalDialogFragment.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.insights; + +import android.animation.AnimatorSet; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ArcProgressBar; +import org.thoughtcrime.securesms.components.AvatarImageView; + +public final class InsightsModalDialogFragment extends DialogFragment { + + private ArcProgressBar progress; + private TextView securePercentage; + private AvatarImageView avatarImageView; + private AnimatorSet animatorSet; + private View progressContainer; + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + requireFragmentManager().beginTransaction() + .detach(this) + .attach(this) + .commit(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_FRAME, R.style.Theme_Signal_Insights_Modal); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent); + return dialog; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.insights_modal, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + View close = view.findViewById(R.id.insights_modal_close); + Button viewInsights = view.findViewById(R.id.insights_modal_view_insights); + + progress = view.findViewById(R.id.insights_modal_progress); + securePercentage = view.findViewById(R.id.insights_modal_percent_secure); + avatarImageView = view.findViewById(R.id.insights_modal_avatar); + progressContainer = view.findViewById(R.id.insights_modal_percent_container); + + close.setOnClickListener(v -> dismiss()); + viewInsights.setOnClickListener(v -> openInsightsAndDismiss()); + + initializeViewModel(); + } + + private void initializeViewModel() { + final InsightsModalViewModel.Repository repository = new InsightsRepository(requireContext()); + final InsightsModalViewModel.Factory factory = new InsightsModalViewModel.Factory(repository); + final InsightsModalViewModel viewModel = ViewModelProviders.of(this, factory).get(InsightsModalViewModel.class); + + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + updateInsecurePercent(state.getData()); + updateUserAvatar(state.getUserAvatar()); + }); + } + + private void updateInsecurePercent(@Nullable InsightsData insightsData) { + if (insightsData == null) return; + + if (animatorSet == null) { + animatorSet = InsightsAnimatorSetFactory.create(insightsData.getPercentInsecure(), this::setProgressPercentage, null, this::setPercentSecureScale, null); + animatorSet.start(); + } + } + + private void setProgressPercentage(float percent) { + securePercentage.setText(String.valueOf(Math.round(percent * 100))); + progress.setProgress(percent); + } + + private void setPercentSecureScale(float scale) { + progressContainer.setScaleX(scale); + progressContainer.setScaleY(scale); + } + + private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) { + if (userAvatar == null) avatarImageView.setImageDrawable(null); + else userAvatar.load(avatarImageView); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + InsightsOptOut.userRequestedOptOut(requireContext()); + } + + private void openInsightsAndDismiss() { + InsightsLauncher.showInsightsDashboard(requireFragmentManager()); + dismiss(); + } + + @Override + public void onDestroyView() { + if (animatorSet != null) { + animatorSet.cancel(); + animatorSet = null; + } + + super.onDestroyView(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalState.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalState.java new file mode 100644 index 00000000..8ce65426 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalState.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.insights; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class InsightsModalState { + + private final InsightsData insightsData; + private final InsightsUserAvatar userAvatar; + + private InsightsModalState(@NonNull Builder builder) { + this.insightsData = builder.insightsData; + this.userAvatar = builder.userAvatar; + } + + static @NonNull InsightsModalState.Builder builder() { + return new InsightsModalState.Builder(); + } + + @NonNull InsightsModalState.Builder buildUpon() { + return builder().withUserAvatar(userAvatar).withData(insightsData); + } + + @Nullable InsightsUserAvatar getUserAvatar() { + return userAvatar; + } + + @Nullable InsightsData getData() { + return insightsData; + } + + static final class Builder { + private InsightsData insightsData; + private InsightsUserAvatar userAvatar; + + private Builder() { + } + + @NonNull Builder withData(@NonNull InsightsData insightsData) { + this.insightsData = insightsData; + return this; + } + + @NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) { + this.userAvatar = userAvatar; + return this; + } + + @NonNull InsightsModalState build() { + return new InsightsModalState(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalViewModel.java new file mode 100644 index 00000000..ad80c408 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsModalViewModel.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.insights; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +final class InsightsModalViewModel extends ViewModel { + + private final MutableLiveData internalState = new MutableLiveData<>(InsightsModalState.builder().build()); + + private InsightsModalViewModel(@NonNull Repository repository) { + repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data)))); + repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar)))); + } + + @MainThread + private InsightsModalState getNewState(Consumer builderConsumer) { + InsightsModalState.Builder builder = internalState.getValue().buildUpon(); + builderConsumer.accept(builder); + return builder.build(); + } + + @NonNull LiveData getState() { + return internalState; + } + + interface Repository { + void getInsightsData(Consumer insecurePercentConsumer); + void getUserAvatar(@NonNull Consumer userAvatarConsumer); + } + + final static class Factory implements ViewModelProvider.Factory { + + private final Repository repository; + + Factory(@NonNull Repository repository) { + this.repository = repository; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + return (T) new InsightsModalViewModel(repository); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsOptOut.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsOptOut.java new file mode 100644 index 00000000..c3d90f22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsOptOut.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.insights; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public final class InsightsOptOut { + private static final String INSIGHTS_OPT_OUT_PREFERENCE = "insights.opt.out"; + + private InsightsOptOut() { + } + + static boolean userHasOptedOut(@NonNull Context context) { + return TextSecurePreferences.getBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, false); + } + + public static void userRequestedOptOut(@NonNull Context context) { + TextSecurePreferences.setBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java new file mode 100644 index 00000000..cab776f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.insights; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; + +public class InsightsRepository implements InsightsDashboardViewModel.Repository, InsightsModalViewModel.Repository { + + private final Context context; + + public InsightsRepository(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void getInsightsData(@NonNull Consumer insightsDataConsumer) { + SimpleTask.run(() -> { + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + int insecure = mmsSmsDatabase.getInsecureMessageCountForInsights(); + int secure = mmsSmsDatabase.getSecureMessageCountForInsights(); + + if (insecure + secure == 0) { + return new InsightsData(false, 0); + } else { + return new InsightsData(true, Util.clamp((int) Math.ceil((insecure * 100f) / (insecure + secure)), 0, 100)); + } + }, insightsDataConsumer::accept); + } + + @Override + public void getInsecureRecipients(@NonNull Consumer> insecureRecipientsConsumer) { + SimpleTask.run(() -> { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + List unregisteredRecipients = recipientDatabase.getUninvitedRecipientsForInsights(); + + return Stream.of(unregisteredRecipients) + .map(Recipient::resolved) + .toList(); + }, + insecureRecipientsConsumer::accept); + } + + @Override + public void getUserAvatar(@NonNull Consumer avatarConsumer) { + SimpleTask.run(() -> { + Recipient self = Recipient.self().resolve(); + String name = Optional.fromNullable(self.getName(context)).or(""); + MaterialColor fallbackColor = self.getColor(); + + if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { + fallbackColor = ContactColors.generateFor(name); + } + + return new InsightsUserAvatar(new ProfileContactPhoto(self, self.getProfileAvatar()), + fallbackColor, + new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40)); + }, avatarConsumer::accept); + } + + @Override + public void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent) { + SimpleTask.run(() -> { + Recipient resolved = recipient.resolve(); + int subscriptionId = resolved.getDefaultSubscriptionId().or(-1); + String message = context.getString(R.string.InviteActivity_lets_switch_to_signal, context.getString(R.string.install_url)); + + MessageSender.send(context, new OutgoingTextMessage(resolved, message, subscriptionId), -1L, true, null); + + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + database.setHasSentInvite(recipient.getId()); + + return null; + }, v -> onSmsMessageSent.run()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java new file mode 100644 index 00000000..24cb2243 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.insights; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; + +class InsightsUserAvatar { + private final ProfileContactPhoto profileContactPhoto; + private final MaterialColor fallbackColor; + private final FallbackContactPhoto fallbackContactPhoto; + + InsightsUserAvatar(@NonNull ProfileContactPhoto profileContactPhoto, @NonNull MaterialColor fallbackColor, @NonNull FallbackContactPhoto fallbackContactPhoto) { + this.profileContactPhoto = profileContactPhoto; + this.fallbackColor = fallbackColor; + this.fallbackContactPhoto = fallbackContactPhoto; + } + + private Drawable fallbackDrawable(@NonNull Context context) { + return fallbackContactPhoto.asDrawable(context, fallbackColor.toAvatarColor(context)); + } + + void load(ImageView into) { + GlideApp.with(into) + .load(profileContactPhoto) + .error(fallbackDrawable(into.getContext())) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(into); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderModel.java b/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderModel.java new file mode 100644 index 00000000..928f80bf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderModel.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.invites; + +import android.content.Context; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.components.reminder.FirstInviteReminder; +import org.thoughtcrime.securesms.components.reminder.Reminder; +import org.thoughtcrime.securesms.components.reminder.SecondInviteReminder; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.concurrent.atomic.AtomicReference; + +public final class InviteReminderModel { + + private static final int FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD = 10; + private static final int SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD = 500; + + private final Context context; + private final Repository repository; + private final AtomicReference reminderInfo = new AtomicReference<>(); + + public InviteReminderModel(@NonNull Context context, @NonNull Repository repository) { + this.context = context; + this.repository = repository; + } + + @MainThread + public void loadReminder(LiveRecipient liveRecipient, Runnable reminderCheckComplete) { + SimpleTask.run(() -> createReminderInfo(liveRecipient.resolve()), result -> { + reminderInfo.set(result); + reminderCheckComplete.run(); + }); + } + + @WorkerThread + private @NonNull ReminderInfo createReminderInfo(Recipient recipient) { + Recipient resolved = recipient.resolve(); + + if (resolved.isRegistered() || resolved.isGroup() || resolved.hasSeenSecondInviteReminder()) { + return new NoReminderInfo(); + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + long threadId = threadDatabase.getThreadIdFor(recipient); + + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + int conversationCount = mmsSmsDatabase.getInsecureSentCount(threadId); + + if (conversationCount >= SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenSecondInviteReminder()) { + return new SecondInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount)); + } else if (conversationCount >= FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenFirstInviteReminder()) { + return new FirstInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount)); + } else { + return new NoReminderInfo(); + } + } + + public @NonNull Optional getReminder() { + ReminderInfo info = reminderInfo.get(); + if (info == null) return Optional.absent(); + else return Optional.fromNullable(info.reminder); + } + + public void dismissReminder() { + final ReminderInfo info = reminderInfo.getAndSet(null); + + SimpleTask.run(() -> { + info.dismiss(); + return null; + }, (v) -> {}); + } + + interface Repository { + void setHasSeenFirstInviteReminder(Recipient recipient); + void setHasSeenSecondInviteReminder(Recipient recipient); + int getPercentOfInsecureMessages(int insecureCount); + } + + private static abstract class ReminderInfo { + + private final Reminder reminder; + + ReminderInfo(Reminder reminder) { + this.reminder = reminder; + } + + @WorkerThread + void dismiss() { + } + } + + private static class NoReminderInfo extends ReminderInfo { + private NoReminderInfo() { + super(null); + } + } + + private class FirstInviteReminderInfo extends ReminderInfo { + + private final Repository repository; + private final Recipient recipient; + + private FirstInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) { + super(new FirstInviteReminder(context, recipient, percentInsecure)); + + this.recipient = recipient; + this.repository = repository; + } + + @Override + @WorkerThread + void dismiss() { + repository.setHasSeenFirstInviteReminder(recipient); + } + } + + private static class SecondInviteReminderInfo extends ReminderInfo { + + private final Repository repository; + private final Recipient recipient; + + private SecondInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) { + super(new SecondInviteReminder(context, recipient, percentInsecure)); + + this.repository = repository; + this.recipient = recipient; + } + + @Override + @WorkerThread + void dismiss() { + repository.setHasSeenSecondInviteReminder(recipient); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderRepository.java b/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderRepository.java new file mode 100644 index 00000000..1c9276a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/invites/InviteReminderRepository.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.invites; + +import android.content.Context; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; + +public final class InviteReminderRepository implements InviteReminderModel.Repository { + + private final Context context; + + public InviteReminderRepository(Context context) { + this.context = context; + } + + @Override + public void setHasSeenFirstInviteReminder(Recipient recipient) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setSeenFirstInviteReminder(recipient.getId()); + } + + @Override + public void setHasSeenSecondInviteReminder(Recipient recipient) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setSeenSecondInviteReminder(recipient.getId()); + } + + @Override + public int getPercentOfInsecureMessages(int insecureCount) { + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + int insecure = mmsSmsDatabase.getInsecureMessageCountForInsights(); + int secure = mmsSmsDatabase.getSecureMessageCountForInsights(); + + if (insecure + secure == 0) return 0; + return Math.round(100f * (insecureCount / (float) (insecure + secure))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/AlarmManagerScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/AlarmManagerScheduler.java new file mode 100644 index 00000000..54057cd5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/AlarmManagerScheduler.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.AlarmManager; +import android.app.Application; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.List; +import java.util.UUID; + +/** + * Schedules tasks using the {@link AlarmManager}. + * + * Given that this scheduler is only used when {@link KeepAliveService} is also used (which keeps + * all of the {@link ConstraintObserver}s running), this only needs to schedule future runs in + * situations where all constraints are already met. Otherwise, the {@link ConstraintObserver}s will + * trigger future runs when the constraints are met. + * + * For the same reason, this class also doesn't have to schedule jobs that don't have delays. + * + * Important: Only use on API < 26. + */ +public class AlarmManagerScheduler implements Scheduler { + + private static final String TAG = AlarmManagerScheduler.class.getSimpleName(); + + private final Application application; + + AlarmManagerScheduler(@NonNull Application application) { + this.application = application; + } + + @Override + public void schedule(long delay, @NonNull List constraints) { + if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) { + setUniqueAlarm(application, System.currentTimeMillis() + delay); + } + } + + private void setUniqueAlarm(@NonNull Context context, long time) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent intent = new Intent(context, RetryReceiver.class); + + intent.setAction(BuildConfig.APPLICATION_ID + UUID.randomUUID().toString()); + alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, 0)); + + Log.i(TAG, "Set an alarm to retry a job in " + (time - System.currentTimeMillis()) + " ms."); + } + + public static class RetryReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Received an alarm to retry a job."); + ApplicationDependencies.getJobManager().wakeUp(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/BootReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/BootReceiver.java new file mode 100644 index 00000000..2ef0df81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/BootReceiver.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.signal.core.util.logging.Log; + +public class BootReceiver extends BroadcastReceiver { + + private static final String TAG = BootReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Boot received. Application is created, kickstarting JobManager."); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java new file mode 100644 index 00000000..322366f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.List; + +class CompositeScheduler implements Scheduler { + + private final List schedulers; + + CompositeScheduler(@NonNull Scheduler... schedulers) { + this.schedulers = Arrays.asList(schedulers); + } + + @Override + public void schedule(long delay, @NonNull List constraints) { + for (Scheduler scheduler : schedulers) { + scheduler.schedule(delay, constraints); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Constraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Constraint.java new file mode 100644 index 00000000..ad3c2664 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Constraint.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +public interface Constraint { + + boolean isMet(); + + @NonNull String getFactoryKey(); + + @RequiresApi(26) + void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder); + + /** + * If you do something in {@link #applyToJobInfo} you should return something here. + *

+ * It is sorted and concatenated with other constraints key parts to form a unique job id. + */ + default @Nullable String getJobSchedulerKeyPart() { + return null; + } + + interface Factory { + T create(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java new file mode 100644 index 00000000..b0a67e3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; + +public class ConstraintInstantiator { + + private final Map constraintFactories; + + ConstraintInstantiator(@NonNull Map constraintFactories) { + this.constraintFactories = new HashMap<>(constraintFactories); + } + + public @NonNull Constraint instantiate(@NonNull String constraintFactoryKey) { + if (constraintFactories.containsKey(constraintFactoryKey)) { + return constraintFactories.get(constraintFactoryKey).create(); + } else { + throw new IllegalStateException("Tried to instantiate a constraint with key '" + constraintFactoryKey + "', but no matching factory was found."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java new file mode 100644 index 00000000..fd7f4fd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +public interface ConstraintObserver { + + void register(@NonNull Notifier notifier); + + interface Notifier { + void onConstraintMet(@NonNull String reason); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java new file mode 100644 index 00000000..432d9bdf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java @@ -0,0 +1,385 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.util.Base64; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class Data { + + public static final Data EMPTY = new Data.Builder().build(); + + @JsonProperty private final Map strings; + @JsonProperty private final Map stringArrays; + @JsonProperty private final Map integers; + @JsonProperty private final Map integerArrays; + @JsonProperty private final Map longs; + @JsonProperty private final Map longArrays; + @JsonProperty private final Map floats; + @JsonProperty private final Map floatArrays; + @JsonProperty private final Map doubles; + @JsonProperty private final Map doubleArrays; + @JsonProperty private final Map booleans; + @JsonProperty private final Map booleanArrays; + + public Data(@JsonProperty("strings") @NonNull Map strings, + @JsonProperty("stringArrays") @NonNull Map stringArrays, + @JsonProperty("integers") @NonNull Map integers, + @JsonProperty("integerArrays") @NonNull Map integerArrays, + @JsonProperty("longs") @NonNull Map longs, + @JsonProperty("longArrays") @NonNull Map longArrays, + @JsonProperty("floats") @NonNull Map floats, + @JsonProperty("floatArrays") @NonNull Map floatArrays, + @JsonProperty("doubles") @NonNull Map doubles, + @JsonProperty("doubleArrays") @NonNull Map doubleArrays, + @JsonProperty("booleans") @NonNull Map booleans, + @JsonProperty("booleanArrays") @NonNull Map booleanArrays) + { + this.strings = strings; + this.stringArrays = stringArrays; + this.integers = integers; + this.integerArrays = integerArrays; + this.longs = longs; + this.longArrays = longArrays; + this.floats = floats; + this.floatArrays = floatArrays; + this.doubles = doubles; + this.doubleArrays = doubleArrays; + this.booleans = booleans; + this.booleanArrays = booleanArrays; + } + + public boolean hasString(@NonNull String key) { + return strings.containsKey(key); + } + + public String getString(@NonNull String key) { + throwIfAbsent(strings, key); + return strings.get(key); + } + + public byte[] getStringAsBlob(@NonNull String key) { + throwIfAbsent(strings, key); + return Base64.decodeOrThrow(strings.get(key)); + } + + public String getStringOrDefault(@NonNull String key, String defaultValue) { + if (hasString(key)) return getString(key); + else return defaultValue; + } + + + public boolean hasStringArray(@NonNull String key) { + return stringArrays.containsKey(key); + } + + public String[] getStringArray(@NonNull String key) { + throwIfAbsent(stringArrays, key); + return stringArrays.get(key); + } + + /** + * Helper method for {@link #getStringArray(String)} that returns the value as a list. + */ + public List getStringArrayAsList(@NonNull String key) { + throwIfAbsent(stringArrays, key); + return Arrays.asList(stringArrays.get(key)); + } + + + public boolean hasInt(@NonNull String key) { + return integers.containsKey(key); + } + + public int getInt(@NonNull String key) { + throwIfAbsent(integers, key); + return integers.get(key); + } + + public int getIntOrDefault(@NonNull String key, int defaultValue) { + if (hasInt(key)) return getInt(key); + else return defaultValue; + } + + + public boolean hasIntegerArray(@NonNull String key) { + return integerArrays.containsKey(key); + } + + public int[] getIntegerArray(@NonNull String key) { + throwIfAbsent(integerArrays, key); + return integerArrays.get(key); + } + + + public boolean hasLong(@NonNull String key) { + return longs.containsKey(key); + } + + public long getLong(@NonNull String key) { + throwIfAbsent(longs, key); + return longs.get(key); + } + + public long getLongOrDefault(@NonNull String key, long defaultValue) { + if (hasLong(key)) return getLong(key); + else return defaultValue; + } + + + public boolean hasLongArray(@NonNull String key) { + return longArrays.containsKey(key); + } + + public long[] getLongArray(@NonNull String key) { + throwIfAbsent(longArrays, key); + return longArrays.get(key); + } + + public List getLongArrayAsList(@NonNull String key) { + throwIfAbsent(longArrays, key); + + long[] array = Objects.requireNonNull(longArrays.get(key)); + List longs = new ArrayList<>(array.length); + + for (long l : array) { + longs.add(l); + } + + return longs; + } + + + public boolean hasFloat(@NonNull String key) { + return floats.containsKey(key); + } + + public float getFloat(@NonNull String key) { + throwIfAbsent(floats, key); + return floats.get(key); + } + + public float getFloatOrDefault(@NonNull String key, float defaultValue) { + if (hasFloat(key)) return getFloat(key); + else return defaultValue; + } + + + public boolean hasFloatArray(@NonNull String key) { + return floatArrays.containsKey(key); + } + + public float[] getFloatArray(@NonNull String key) { + throwIfAbsent(floatArrays, key); + return floatArrays.get(key); + } + + + public boolean hasDouble(@NonNull String key) { + return doubles.containsKey(key); + } + + public double getDouble(@NonNull String key) { + throwIfAbsent(doubles, key); + return doubles.get(key); + } + + public double getDoubleOrDefault(@NonNull String key, double defaultValue) { + if (hasDouble(key)) return getDouble(key); + else return defaultValue; + } + + + public boolean hasDoubleArray(@NonNull String key) { + return floatArrays.containsKey(key); + } + + public double[] getDoubleArray(@NonNull String key) { + throwIfAbsent(doubleArrays, key); + return doubleArrays.get(key); + } + + + public boolean hasBoolean(@NonNull String key) { + return booleans.containsKey(key); + } + + public boolean getBoolean(@NonNull String key) { + throwIfAbsent(booleans, key); + return booleans.get(key); + } + + public boolean getBooleanOrDefault(@NonNull String key, boolean defaultValue) { + if (hasBoolean(key)) return getBoolean(key); + else return defaultValue; + } + + + public boolean hasBooleanArray(@NonNull String key) { + return booleanArrays.containsKey(key); + } + + public boolean[] getBooleanArray(@NonNull String key) { + throwIfAbsent(booleanArrays, key); + return booleanArrays.get(key); + } + + + private void throwIfAbsent(@NonNull Map map, @NonNull String key) { + if (!map.containsKey(key)) { + throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present."); + } + } + + public Builder buildUpon() { + return new Builder(this); + } + + + public static class Builder { + + private final Map strings = new HashMap<>(); + private final Map stringArrays = new HashMap<>(); + private final Map integers = new HashMap<>(); + private final Map integerArrays = new HashMap<>(); + private final Map longs = new HashMap<>(); + private final Map longArrays = new HashMap<>(); + private final Map floats = new HashMap<>(); + private final Map floatArrays = new HashMap<>(); + private final Map doubles = new HashMap<>(); + private final Map doubleArrays = new HashMap<>(); + private final Map booleans = new HashMap<>(); + private final Map booleanArrays = new HashMap<>(); + + public Builder() { } + + private Builder(@NonNull Data oldData) { + strings.putAll(oldData.strings); + stringArrays.putAll(oldData.stringArrays); + integers.putAll(oldData.integers); + integerArrays.putAll(oldData.integerArrays); + longs.putAll(oldData.longs); + longArrays.putAll(oldData.longArrays); + floats.putAll(oldData.floats); + floatArrays.putAll(oldData.floatArrays); + doubles.putAll(oldData.doubles); + doubleArrays.putAll(oldData.doubleArrays); + booleans.putAll(oldData.booleans); + booleanArrays.putAll(oldData.booleanArrays); + } + + public Builder putString(@NonNull String key, @Nullable String value) { + strings.put(key, value); + return this; + } + + public Builder putStringArray(@NonNull String key, @NonNull String[] value) { + stringArrays.put(key, value); + return this; + } + + /** + * Helper method for {@link #putStringArray(String, String[])} that takes a list. + */ + public Builder putStringListAsArray(@NonNull String key, @NonNull List value) { + stringArrays.put(key, value.toArray(new String[0])); + return this; + } + + public Builder putInt(@NonNull String key, int value) { + integers.put(key, value); + return this; + } + + public Builder putIntArray(@NonNull String key, @NonNull int[] value) { + integerArrays.put(key, value); + return this; + } + + public Builder putLong(@NonNull String key, long value) { + longs.put(key, value); + return this; + } + + public Builder putLongArray(@NonNull String key, @NonNull long[] value) { + longArrays.put(key, value); + return this; + } + + public Builder putLongListAsArray(@NonNull String key, @NonNull List value) { + long[] longs = new long[value.size()]; + + for (int i = 0; i < value.size(); i++) { + longs[i] = value.get(i); + } + + longArrays.put(key, longs); + return this; + } + + public Builder putFloat(@NonNull String key, float value) { + floats.put(key, value); + return this; + } + + public Builder putFloatArray(@NonNull String key, @NonNull float[] value) { + floatArrays.put(key, value); + return this; + } + + public Builder putDouble(@NonNull String key, double value) { + doubles.put(key, value); + return this; + } + + public Builder putDoubleArray(@NonNull String key, @NonNull double[] value) { + doubleArrays.put(key, value); + return this; + } + + public Builder putBoolean(@NonNull String key, boolean value) { + booleans.put(key, value); + return this; + } + + public Builder putBooleanArray(@NonNull String key, @NonNull boolean[] value) { + booleanArrays.put(key, value); + return this; + } + + public Builder putBlobAsString(@NonNull String key, @NonNull byte[] value) { + String serialized = Base64.encodeBytes(value); + strings.put(key, serialized); + return this; + } + + public Data build() { + return new Data(strings, + stringArrays, + integers, + integerArrays, + longs, + longArrays, + floats, + floatArrays, + doubles, + doubleArrays, + booleans, + booleanArrays); + } + } + + public interface Serializer { + @NonNull String serialize(@NonNull Data data); + @NonNull Data deserialize(@NonNull String serialized); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java new file mode 100644 index 00000000..c8a266bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobmanager; + +/** + * Interface responsible for injecting dependencies into Jobs. + */ +public interface DependencyInjector { + void injectDependencies(Object object); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java new file mode 100644 index 00000000..b0c2b974 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.concurrent.ExecutorService; + +public interface ExecutorFactory { + @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java new file mode 100644 index 00000000..5b49330c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; + +import java.util.List; + +/** + * Schedules future runs on an in-app handler. Intended to be used in combination with a persistent + * {@link Scheduler} to improve responsiveness when the app is open. + * + * This should only schedule runs when all constraints are met. Because this only works when the + * app is foregrounded, jobs that don't have their constraints met will be run when the relevant + * {@link ConstraintObserver} is triggered. + * + * Similarly, this does not need to schedule retries with no delay, as this doesn't provide any + * persistence, and other mechanisms will take care of that. + */ +class InAppScheduler implements Scheduler { + + private static final String TAG = InAppScheduler.class.getSimpleName(); + + private final JobManager jobManager; + private final Handler handler; + + InAppScheduler(@NonNull JobManager jobManager) { + HandlerThread handlerThread = new HandlerThread("InAppScheduler"); + handlerThread.start(); + + this.jobManager = jobManager; + this.handler = new Handler(handlerThread.getLooper()); + } + + @Override + public void schedule(long delay, @NonNull List constraints) { + if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) { + Log.i(TAG, "Scheduling a retry in " + delay + " ms."); + handler.postDelayed(() -> { + Log.i(TAG, "Triggering a job retry."); + jobManager.wakeUp(); + }, delay); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java new file mode 100644 index 00000000..9e031a2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java @@ -0,0 +1,477 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.FeatureFlags; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * A durable unit of work. + * + * Jobs have {@link Parameters} that describe the conditions upon when you'd like them to run, how + * often they should be retried, and how long they should be retried for. + * + * Never rely on a specific instance of this class being run. It can be created and destroyed as the + * job is retried. State that you want to save is persisted to a {@link Data} object in + * {@link #serialize()}. Your job is then recreated using a {@link Factory} that you register in + * {@link JobManager.Configuration.Builder#setJobFactories(Map)}, which is given the saved + * {@link Data} bundle. + */ +public abstract class Job { + + private static final String TAG = Log.tag(Job.class); + + private final Parameters parameters; + + private int runAttempt; + private long nextRunAttemptTime; + + private volatile boolean canceled; + + protected Context context; + + public Job(@NonNull Parameters parameters) { + this.parameters = parameters; + } + + public final @NonNull String getId() { + return parameters.getId(); + } + + public final @NonNull Parameters getParameters() { + return parameters; + } + + public final int getRunAttempt() { + return runAttempt; + } + + public final long getNextRunAttemptTime() { + return nextRunAttemptTime; + } + + public final @Nullable Data getInputData() { + return parameters.getInputData(); + } + + public final @NonNull Data requireInputData() { + return Objects.requireNonNull(parameters.getInputData()); + } + + /** + * This is already called by {@link JobController} during job submission, but if you ever run a + * job without submitting it to the {@link JobManager}, then you'll need to invoke this yourself. + */ + public final void setContext(@NonNull Context context) { + this.context = context; + } + + /** Should only be invoked by {@link JobController} */ + final void setRunAttempt(int runAttempt) { + this.runAttempt = runAttempt; + } + + /** Should only be invoked by {@link JobController} */ + final void setNextRunAttemptTime(long nextRunAttemptTime) { + this.nextRunAttemptTime = nextRunAttemptTime; + } + + /** Should only be invoked by {@link JobController} */ + final void cancel() { + this.canceled = true; + } + + @WorkerThread + final void onSubmit() { + Log.i(TAG, JobLogger.format(this, "onSubmit()")); + onAdded(); + } + + /** + * @return True if your job has been marked as canceled while it was running, otherwise false. + * If a job sees that it has been canceled, it should make a best-effort attempt at + * stopping it's work. This job will have {@link #onFailure()} called after {@link #run()} + * has finished. + */ + public final boolean isCanceled() { + return canceled; + } + + /** + * Called when the job is first submitted to the {@link JobManager}. + */ + @WorkerThread + public void onAdded() { + } + + /** + * Called after a job has run and its determined that a retry is required. + */ + @WorkerThread + public void onRetry() { + } + + /** + * Serialize your job state so that it can be recreated in the future. + */ + public abstract @NonNull Data serialize(); + + /** + * Returns the key that can be used to find the relevant factory needed to create your job. + */ + public abstract @NonNull String getFactoryKey(); + + /** + * Called to do your actual work. + */ + @WorkerThread + public abstract @NonNull Result run(); + + /** + * Called when your job has completely failed and will not be run again. + */ + @WorkerThread + public abstract void onFailure(); + + public interface Factory { + @NonNull T create(@NonNull Parameters parameters, @NonNull Data data); + } + + public static final class Result { + + private static final int INVALID_BACKOFF = -1; + + private static final Result SUCCESS_NO_DATA = new Result(ResultType.SUCCESS, null, null, INVALID_BACKOFF); + private static final Result FAILURE = new Result(ResultType.FAILURE, null, null, INVALID_BACKOFF); + + private final ResultType resultType; + private final RuntimeException runtimeException; + private final Data outputData; + private final long backoffInterval; + + private Result(@NonNull ResultType resultType, @Nullable RuntimeException runtimeException, @Nullable Data outputData, long backoffInterval) { + this.resultType = resultType; + this.runtimeException = runtimeException; + this.outputData = outputData; + this.backoffInterval = backoffInterval; + } + + /** Job completed successfully. */ + public static Result success() { + return SUCCESS_NO_DATA; + } + + /** Job completed successfully and wants to provide some output data. */ + public static Result success(@Nullable Data outputData) { + return new Result(ResultType.SUCCESS, null, outputData, INVALID_BACKOFF); + } + + /** + * Job did not complete successfully, but it can be retried later. + * @param backoffInterval How long to wait before retrying + */ + public static Result retry(long backoffInterval) { + return new Result(ResultType.RETRY, null, null, backoffInterval); + } + + /** Job did not complete successfully and should not be tried again. Dependent jobs will also be failed.*/ + public static Result failure() { + return FAILURE; + } + + /** Same as {@link #failure()}, except the app should also crash with the provided exception. */ + public static Result fatalFailure(@NonNull RuntimeException runtimeException) { + return new Result(ResultType.FAILURE, runtimeException, null, INVALID_BACKOFF); + } + + boolean isSuccess() { + return resultType == ResultType.SUCCESS; + } + + boolean isRetry() { + return resultType == ResultType.RETRY; + } + + boolean isFailure() { + return resultType == ResultType.FAILURE; + } + + @Nullable RuntimeException getException() { + return runtimeException; + } + + @Nullable Data getOutputData() { + return outputData; + } + + long getBackoffInterval() { + return backoffInterval; + } + + @Override + public @NonNull String toString() { + switch (resultType) { + case SUCCESS: + case RETRY: + return resultType.toString(); + case FAILURE: + if (runtimeException == null) { + return resultType.toString(); + } else { + return "FATAL_FAILURE"; + } + } + + return "UNKNOWN?"; + } + + private enum ResultType { + SUCCESS, FAILURE, RETRY + } + } + + public static final class Parameters { + + public static final String MIGRATION_QUEUE_KEY = "MIGRATION"; + public static final int IMMORTAL = -1; + public static final int UNLIMITED = -1; + + private final String id; + private final long createTime; + private final long lifespan; + private final int maxAttempts; + private final int maxInstancesForFactory; + private final int maxInstancesForQueue; + private final String queue; + private final List constraintKeys; + private final Data inputData; + private final boolean memoryOnly; + + private Parameters(@NonNull String id, + long createTime, + long lifespan, + int maxAttempts, + int maxInstancesForFactory, + int maxInstancesForQueue, + @Nullable String queue, + @NonNull List constraintKeys, + @Nullable Data inputData, + boolean memoryOnly) + { + this.id = id; + this.createTime = createTime; + this.lifespan = lifespan; + this.maxAttempts = maxAttempts; + this.maxInstancesForFactory = maxInstancesForFactory; + this.maxInstancesForQueue = maxInstancesForQueue; + this.queue = queue; + this.constraintKeys = constraintKeys; + this.inputData = inputData; + this.memoryOnly = memoryOnly; + } + + @NonNull String getId() { + return id; + } + + long getCreateTime() { + return createTime; + } + + long getLifespan() { + return lifespan; + } + + int getMaxAttempts() { + return maxAttempts; + } + + int getMaxInstancesForFactory() { + return maxInstancesForFactory; + } + + int getMaxInstancesForQueue() { + return maxInstancesForQueue; + } + + public @Nullable String getQueue() { + return queue; + } + + @NonNull List getConstraintKeys() { + return constraintKeys; + } + + @Nullable Data getInputData() { + return inputData; + } + + boolean isMemoryOnly() { + return memoryOnly; + } + + public Builder toBuilder() { + return new Builder(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly); + } + + + public static final class Builder { + private String id; + private long createTime; + private long lifespan; + private int maxAttempts; + private int maxInstancesForFactory; + private int maxInstancesForQueue; + private String queue; + private List constraintKeys; + private Data inputData; + private boolean memoryOnly; + + public Builder() { + this(UUID.randomUUID().toString()); + } + + Builder(@NonNull String id) { + this(id, System.currentTimeMillis(), IMMORTAL, 1, UNLIMITED, UNLIMITED, null, new LinkedList<>(), null, false); + } + + private Builder(@NonNull String id, + long createTime, + long lifespan, + int maxAttempts, + int maxInstancesForFactory, + int maxInstancesForQueue, + @Nullable String queue, + @NonNull List constraintKeys, + @Nullable Data inputData, + boolean memoryOnly) + { + this.id = id; + this.createTime = createTime; + this.lifespan = lifespan; + this.maxAttempts = maxAttempts; + this.maxInstancesForFactory = maxInstancesForFactory; + this.maxInstancesForQueue = maxInstancesForQueue; + this.queue = queue; + this.constraintKeys = constraintKeys; + this.inputData = inputData; + this.memoryOnly = memoryOnly; + } + + /** Should only be invoked by {@link JobController} */ + Builder setCreateTime(long createTime) { + this.createTime = createTime; + return this; + } + + /** + * Specify the amount of time this job is allowed to be retried. Defaults to {@link #IMMORTAL}. + */ + public @NonNull Builder setLifespan(long lifespan) { + this.lifespan = lifespan; + return this; + } + + /** + * Specify the maximum number of times you want to attempt this job. Defaults to 1. + */ + public @NonNull Builder setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Specify the maximum number of instances you'd want of this job at any given time, as + * determined by the job's factory key. If enqueueing this job would put it over that limit, + * it will be ignored. + * + * This property is ignored if the job is submitted as part of a {@link JobManager.Chain}. + * + * Defaults to {@link #UNLIMITED}. + */ + public @NonNull Builder setMaxInstancesForFactory(int maxInstancesForFactory) { + this.maxInstancesForFactory = maxInstancesForFactory; + return this; + } + + /** + * Specify the maximum number of instances you'd want of this job at any given time, as + * determined by the job's factory key and queue key. If enqueueing this job would put it over + * that limit, it will be ignored. + * + * This property is ignored if the job is submitted as part of a {@link JobManager.Chain}, or + * if the job has no queue key. + * + * Defaults to {@link #UNLIMITED}. + */ + public @NonNull Builder setMaxInstancesForQueue(int maxInstancesForQueue) { + this.maxInstancesForQueue = maxInstancesForQueue; + return this; + } + + /** + * Specify a string representing a queue. All jobs within the same queue are run in a + * serialized fashion -- one after the other, in order of insertion. Failure of a job earlier + * in the queue has no impact on the execution of jobs later in the queue. + */ + public @NonNull Builder setQueue(@Nullable String queue) { + this.queue = queue; + return this; + } + + /** + * Add a constraint via the key that was used to register its factory in + * {@link JobManager.Configuration)}; + */ + public @NonNull Builder addConstraint(@NonNull String constraintKey) { + constraintKeys.add(constraintKey); + return this; + } + + /** + * Set constraints via the key that was used to register its factory in + * {@link JobManager.Configuration)}; + */ + public @NonNull Builder setConstraints(@NonNull List constraintKeys) { + this.constraintKeys.clear(); + this.constraintKeys.addAll(constraintKeys); + return this; + } + + /** + * Specify whether or not you want this job to only live in memory. If true, this job will + * *not* survive application death. This defaults to false, and should be used with care. + * + * Defaults to false. + */ + public @NonNull Builder setMemoryOnly(boolean memoryOnly) { + this.memoryOnly = memoryOnly; + return this; + } + + /** + * Sets the input data that will be made availabe to the job when it is run. + * Should only be set by {@link JobController}. + */ + @NonNull Builder setInputData(@Nullable Data inputData) { + this.inputData = inputData; + return this; + } + + public @NonNull Parameters build() { + return new Parameters(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java new file mode 100644 index 00000000..c455b441 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -0,0 +1,495 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; +import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SetUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Manages the queue of jobs. This is the only class that should write to {@link JobStorage} to + * ensure consistency. + */ +class JobController { + + private static final String TAG = JobController.class.getSimpleName(); + + private final Application application; + private final JobStorage jobStorage; + private final JobInstantiator jobInstantiator; + private final ConstraintInstantiator constraintInstantiator; + private final Data.Serializer dataSerializer; + private final JobTracker jobTracker; + private final Scheduler scheduler; + private final Debouncer debouncer; + private final Callback callback; + private final Map runningJobs; + + JobController(@NonNull Application application, + @NonNull JobStorage jobStorage, + @NonNull JobInstantiator jobInstantiator, + @NonNull ConstraintInstantiator constraintInstantiator, + @NonNull Data.Serializer dataSerializer, + @NonNull JobTracker jobTracker, + @NonNull Scheduler scheduler, + @NonNull Debouncer debouncer, + @NonNull Callback callback) + { + this.application = application; + this.jobStorage = jobStorage; + this.jobInstantiator = jobInstantiator; + this.constraintInstantiator = constraintInstantiator; + this.dataSerializer = dataSerializer; + this.jobTracker = jobTracker; + this.scheduler = scheduler; + this.debouncer = debouncer; + this.callback = callback; + this.runningJobs = new HashMap<>(); + } + + @WorkerThread + synchronized void init() { + jobStorage.updateAllJobsToBePending(); + notifyAll(); + } + + synchronized void wakeUp() { + notifyAll(); + } + + @WorkerThread + synchronized void submitNewJobChain(@NonNull List> chain) { + chain = Stream.of(chain).filterNot(List::isEmpty).toList(); + + if (chain.isEmpty()) { + Log.w(TAG, "Tried to submit an empty job chain. Skipping."); + return; + } + + if (chainExceedsMaximumInstances(chain)) { + Job solo = chain.get(0).get(0); + jobTracker.onStateChange(solo, JobTracker.JobState.IGNORED); + Log.w(TAG, JobLogger.format(solo, "Already at the max instance count. Factory limit: " + solo.getParameters().getMaxInstancesForFactory() + ", Queue limit: " + solo.getParameters().getMaxInstancesForQueue() + ". Skipping.")); + return; + } + + insertJobChain(chain); + scheduleJobs(chain.get(0)); + triggerOnSubmit(chain); + notifyAll(); + } + + @WorkerThread + synchronized void submitJobWithExistingDependencies(@NonNull Job job, @NonNull Collection dependsOn, @Nullable String dependsOnQueue) { + List> chain = Collections.singletonList(Collections.singletonList(job)); + + if (chainExceedsMaximumInstances(chain)) { + jobTracker.onStateChange(job, JobTracker.JobState.IGNORED); + Log.w(TAG, JobLogger.format(job, "Already at the max instance count. Factory limit: " + job.getParameters().getMaxInstancesForFactory() + ", Queue limit: " + job.getParameters().getMaxInstancesForQueue() + ". Skipping.")); + return; + } + + Set allDependsOn = new HashSet<>(dependsOn); + Set aliveDependsOn = Stream.of(dependsOn) + .filter(id -> jobStorage.getJobSpec(id) != null) + .collect(Collectors.toSet()); + + if (dependsOnQueue != null) { + List inQueue = Stream.of(jobStorage.getJobsInQueue(dependsOnQueue)) + .map(JobSpec::getId) + .toList(); + + allDependsOn.addAll(inQueue); + aliveDependsOn.addAll(inQueue); + } + + if (jobTracker.haveAnyFailed(allDependsOn)) { + Log.w(TAG, "This job depends on a job that failed! Failing this job immediately."); + List dependents = onFailure(job); + job.setContext(application); + job.onFailure(); + Stream.of(dependents).forEach(Job::onFailure); + return; + } + + FullSpec fullSpec = buildFullSpec(job, aliveDependsOn); + jobStorage.insertJobs(Collections.singletonList(fullSpec)); + + scheduleJobs(Collections.singletonList(job)); + triggerOnSubmit(chain); + notifyAll(); + } + + @WorkerThread + synchronized void cancelJob(@NonNull String id) { + Job runningJob = runningJobs.get(id); + + if (runningJob != null) { + Log.w(TAG, JobLogger.format(runningJob, "Canceling while running.")); + runningJob.cancel(); + } else { + JobSpec jobSpec = jobStorage.getJobSpec(id); + + if (jobSpec != null) { + Job job = createJob(jobSpec, jobStorage.getConstraintSpecs(id)); + Log.w(TAG, JobLogger.format(job, "Canceling while inactive.")); + Log.w(TAG, JobLogger.format(job, "Job failed.")); + + job.cancel(); + List dependents = onFailure(job); + job.onFailure(); + Stream.of(dependents).forEach(Job::onFailure); + } else { + Log.w(TAG, "Tried to cancel JOB::" + id + ", but it could not be found."); + } + } + } + + @WorkerThread + synchronized void cancelAllInQueue(@NonNull String queue) { + Stream.of(jobStorage.getJobsInQueue(queue)) + .map(JobSpec::getId) + .forEach(this::cancelJob); + } + + @WorkerThread + synchronized void onRetry(@NonNull Job job, long backoffInterval) { + if (backoffInterval <= 0) { + throw new IllegalArgumentException("Invalid backoff interval! " + backoffInterval); + } + + int nextRunAttempt = job.getRunAttempt() + 1; + long nextRunAttemptTime = System.currentTimeMillis() + backoffInterval; + String serializedData = dataSerializer.serialize(job.serialize()); + + jobStorage.updateJobAfterRetry(job.getId(), false, nextRunAttempt, nextRunAttemptTime, serializedData); + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); + + List constraints = Stream.of(jobStorage.getConstraintSpecs(job.getId())) + .map(ConstraintSpec::getFactoryKey) + .map(constraintInstantiator::instantiate) + .toList(); + + + long delay = Math.max(0, nextRunAttemptTime - System.currentTimeMillis()); + + Log.i(TAG, JobLogger.format(job, "Scheduling a retry in " + delay + " ms.")); + scheduler.schedule(delay, constraints); + + notifyAll(); + } + + synchronized void onJobFinished(@NonNull Job job) { + runningJobs.remove(job.getId()); + } + + @WorkerThread + synchronized void onSuccess(@NonNull Job job, @Nullable Data outputData) { + if (outputData != null) { + List updates = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(job.getId())) + .map(DependencySpec::getJobId) + .map(jobStorage::getJobSpec) + .map(jobSpec -> mapToJobWithInputData(jobSpec, outputData)) + .toList(); + + jobStorage.updateJobs(updates); + } + + jobStorage.deleteJob(job.getId()); + jobTracker.onStateChange(job, JobTracker.JobState.SUCCESS); + notifyAll(); + } + + /** + * @return The list of all dependent jobs that should also be failed. + */ + @WorkerThread + synchronized @NonNull List onFailure(@NonNull Job job) { + List dependents = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(job.getId())) + .map(DependencySpec::getJobId) + .map(jobStorage::getJobSpec) + .withoutNulls() + .map(jobSpec -> { + List constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId()); + return createJob(jobSpec, constraintSpecs); + }) + .toList(); + + List all = new ArrayList<>(dependents.size() + 1); + all.add(job); + all.addAll(dependents); + + jobStorage.deleteJobs(Stream.of(all).map(Job::getId).toList()); + Stream.of(all).forEach(j -> jobTracker.onStateChange(j, JobTracker.JobState.FAILURE)); + + return dependents; + } + + /** + * Retrieves the next job that is eligible for execution. To be 'eligible' means that the job: + * - Has no dependencies + * - Has no unmet constraints + * + * This method will block until a job is available. + * When the job returned from this method has been run, you must call {@link #onJobFinished(Job)}. + */ + @WorkerThread + synchronized @NonNull Job pullNextEligibleJobForExecution(@NonNull JobPredicate predicate) { + try { + Job job; + + while ((job = getNextEligibleJobForExecution(predicate)) == null) { + if (runningJobs.isEmpty()) { + debouncer.publish(callback::onEmpty); + } + + wait(); + } + + jobStorage.updateJobRunningState(job.getId(), true); + runningJobs.put(job.getId(), job); + jobTracker.onStateChange(job, JobTracker.JobState.RUNNING); + + return job; + } catch (InterruptedException e) { + Log.e(TAG, "Interrupted."); + throw new AssertionError(e); + } + } + + /** + * Retrieves a string representing the state of the job queue. Intended for debugging. + */ + @WorkerThread + synchronized @NonNull String getDebugInfo() { + List jobs = jobStorage.getAllJobSpecs(); + List constraints = jobStorage.getAllConstraintSpecs(); + List dependencies = jobStorage.getAllDependencySpecs(); + + StringBuilder info = new StringBuilder(); + + info.append("-- Jobs\n"); + if (!jobs.isEmpty()) { + Stream.of(jobs).forEach(j -> info.append(j.toString()).append('\n')); + } else { + info.append("None\n"); + } + + info.append("\n-- Constraints\n"); + if (!constraints.isEmpty()) { + Stream.of(constraints).forEach(c -> info.append(c.toString()).append('\n')); + } else { + info.append("None\n"); + } + + info.append("\n-- Dependencies\n"); + if (!dependencies.isEmpty()) { + Stream.of(dependencies).forEach(d -> info.append(d.toString()).append('\n')); + } else { + info.append("None\n"); + } + + return info.toString(); + } + + synchronized boolean areQueuesEmpty(@NonNull Set queueKeys) { + return jobStorage.areQueuesEmpty(queueKeys); + } + + @WorkerThread + private boolean chainExceedsMaximumInstances(@NonNull List> chain) { + if (chain.size() == 1 && chain.get(0).size() == 1) { + Job solo = chain.get(0).get(0); + + boolean exceedsFactory = solo.getParameters().getMaxInstancesForFactory() != Job.Parameters.UNLIMITED && + jobStorage.getJobCountForFactory(solo.getFactoryKey()) >= solo.getParameters().getMaxInstancesForFactory(); + + if (exceedsFactory) { + return true; + } + + boolean exceedsQueue = solo.getParameters().getQueue() != null && + solo.getParameters().getMaxInstancesForQueue() != Job.Parameters.UNLIMITED && + jobStorage.getJobCountForFactoryAndQueue(solo.getFactoryKey(), solo.getParameters().getQueue()) >= solo.getParameters().getMaxInstancesForQueue(); + + if (exceedsQueue) { + return true; + } + } + + return false; + } + + @WorkerThread + private void triggerOnSubmit(@NonNull List> chain) { + Stream.of(chain) + .forEach(list -> Stream.of(list).forEach(job -> { + job.setContext(application); + job.onSubmit(); + })); + } + + @WorkerThread + private void insertJobChain(@NonNull List> chain) { + List fullSpecs = new LinkedList<>(); + List dependsOn = Collections.emptyList(); + + for (List jobList : chain) { + for (Job job : jobList) { + fullSpecs.add(buildFullSpec(job, dependsOn)); + } + dependsOn = Stream.of(jobList).map(Job::getId).toList(); + } + + jobStorage.insertJobs(fullSpecs); + } + + @WorkerThread + private @NonNull FullSpec buildFullSpec(@NonNull Job job, @NonNull Collection dependsOn) { + job.setRunAttempt(0); + + JobSpec jobSpec = new JobSpec(job.getId(), + job.getFactoryKey(), + job.getParameters().getQueue(), + System.currentTimeMillis(), + job.getNextRunAttemptTime(), + job.getRunAttempt(), + job.getParameters().getMaxAttempts(), + job.getParameters().getLifespan(), + dataSerializer.serialize(job.serialize()), + null, + false, + job.getParameters().isMemoryOnly()); + + List constraintSpecs = Stream.of(job.getParameters().getConstraintKeys()) + .map(key -> new ConstraintSpec(jobSpec.getId(), key, jobSpec.isMemoryOnly())) + .toList(); + + List dependencySpecs = Stream.of(dependsOn) + .map(depends -> { + JobSpec dependsOnJobSpec = jobStorage.getJobSpec(depends); + boolean memoryOnly = job.getParameters().isMemoryOnly() || (dependsOnJobSpec != null && dependsOnJobSpec.isMemoryOnly()); + + return new DependencySpec(job.getId(), depends, memoryOnly); + }) + .toList(); + + return new FullSpec(jobSpec, constraintSpecs, dependencySpecs); + } + + @WorkerThread + private void scheduleJobs(@NonNull List jobs) { + for (Job job : jobs) { + List constraints = Stream.of(job.getParameters().getConstraintKeys()) + .map(key -> new ConstraintSpec(job.getId(), key, job.getParameters().isMemoryOnly())) + .map(ConstraintSpec::getFactoryKey) + .map(constraintInstantiator::instantiate) + .toList(); + + scheduler.schedule(0, constraints); + } + } + + @WorkerThread + private @Nullable Job getNextEligibleJobForExecution(@NonNull JobPredicate predicate) { + List jobSpecs = Stream.of(jobStorage.getPendingJobsWithNoDependenciesInCreatedOrder(System.currentTimeMillis())) + .filter(predicate::shouldRun) + .toList(); + + for (JobSpec jobSpec : jobSpecs) { + List constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId()); + List constraints = Stream.of(constraintSpecs) + .map(ConstraintSpec::getFactoryKey) + .map(constraintInstantiator::instantiate) + .toList(); + + if (Stream.of(constraints).allMatch(Constraint::isMet)) { + return createJob(jobSpec, constraintSpecs); + } + } + + return null; + } + + private @NonNull Job createJob(@NonNull JobSpec jobSpec, @NonNull List constraintSpecs) { + Job.Parameters parameters = buildJobParameters(jobSpec, constraintSpecs); + + try { + Data data = dataSerializer.deserialize(jobSpec.getSerializedData()); + Job job = jobInstantiator.instantiate(jobSpec.getFactoryKey(), parameters, data); + + job.setRunAttempt(jobSpec.getRunAttempt()); + job.setNextRunAttemptTime(jobSpec.getNextRunAttemptTime()); + job.setContext(application); + + return job; + } catch (RuntimeException e) { + Log.e(TAG, "Failed to instantiate job! Failing it and its dependencies without calling Job#onFailure. Crash imminent."); + + List failIds = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(jobSpec.getId())) + .map(DependencySpec::getJobId) + .toList(); + + jobStorage.deleteJob(jobSpec.getId()); + jobStorage.deleteJobs(failIds); + + Log.e(TAG, "Failed " + failIds.size() + " dependent jobs."); + + throw e; + } + } + + private @NonNull Job.Parameters buildJobParameters(@NonNull JobSpec jobSpec, @NonNull List constraintSpecs) { + return new Job.Parameters.Builder(jobSpec.getId()) + .setCreateTime(jobSpec.getCreateTime()) + .setLifespan(jobSpec.getLifespan()) + .setMaxAttempts(jobSpec.getMaxAttempts()) + .setQueue(jobSpec.getQueueKey()) + .setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList()) + .setInputData(jobSpec.getSerializedInputData() != null ? dataSerializer.deserialize(jobSpec.getSerializedInputData()) : null) + .build(); + } + + private @NonNull JobSpec mapToJobWithInputData(@NonNull JobSpec jobSpec, @NonNull Data inputData) { + return new JobSpec(jobSpec.getId(), + jobSpec.getFactoryKey(), + jobSpec.getQueueKey(), + jobSpec.getCreateTime(), + jobSpec.getNextRunAttemptTime(), + jobSpec.getRunAttempt(), + jobSpec.getMaxAttempts(), + jobSpec.getLifespan(), + jobSpec.getSerializedData(), + dataSerializer.serialize(inputData), + jobSpec.isRunning(), + jobSpec.isMemoryOnly()); + } + + interface Callback { + void onEmpty(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java new file mode 100644 index 00000000..37cba5e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; + +class JobInstantiator { + + private final Map jobFactories; + + JobInstantiator(@NonNull Map jobFactories) { + this.jobFactories = new HashMap<>(jobFactories); + } + + public @NonNull Job instantiate(@NonNull String jobFactoryKey, @NonNull Job.Parameters parameters, @NonNull Data data) { + if (jobFactories.containsKey(jobFactoryKey)) { + return jobFactories.get(jobFactoryKey).create(parameters, data); + } else { + throw new IllegalStateException("Tried to instantiate a job with key '" + jobFactoryKey + "', but no matching factory was found."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java new file mode 100644 index 00000000..331ade71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import java.util.Locale; + +public class JobLogger { + + public static String format(@NonNull Job job, @NonNull String event) { + return format(job, "", event); + } + + public static String format(@NonNull Job job, @NonNull String extraTag, @NonNull String event) { + String id = job.getId(); + String tag = TextUtils.isEmpty(extraTag) ? "" : "[" + extraTag + "]"; + long timeSinceSubmission = System.currentTimeMillis() - job.getParameters().getCreateTime(); + int runAttempt = job.getRunAttempt() + 1; + String maxAttempts = job.getParameters().getMaxAttempts() == Job.Parameters.UNLIMITED ? "Unlimited" + : String.valueOf(job.getParameters().getMaxAttempts()); + String lifespan = job.getParameters().getLifespan() == Job.Parameters.IMMORTAL ? "Immortal" + : String.valueOf(job.getParameters().getLifespan()) + " ms"; + return String.format(Locale.US, + "[%s][%s]%s %s (Time Since Submission: %d ms, Lifespan: %s, Run Attempt: %d/%s)", + "JOB::" + id, job.getClass().getSimpleName(), tag, event, timeSinceSubmission, lifespan, runAttempt, maxAttempts); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java new file mode 100644 index 00000000..000d85f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -0,0 +1,616 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.Application; +import android.content.Intent; +import android.os.Build; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.impl.DefaultExecutorFactory; +import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; +import org.thoughtcrime.securesms.jobmanager.workmanager.WorkManagerMigrator; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.FilteredExecutor; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Allows the scheduling of durable jobs that will be run as early as possible. + */ +public class JobManager implements ConstraintObserver.Notifier { + + private static final String TAG = JobManager.class.getSimpleName(); + + public static final int CURRENT_VERSION = 8; + + private final Application application; + private final Configuration configuration; + private final Executor executor; + private final JobController jobController; + private final JobTracker jobTracker; + + @GuardedBy("emptyQueueListeners") + private final Set emptyQueueListeners = new CopyOnWriteArraySet<>(); + + private volatile boolean initialized; + + public JobManager(@NonNull Application application, @NonNull Configuration configuration) { + this.application = application; + this.configuration = configuration; + this.executor = new FilteredExecutor(configuration.getExecutorFactory().newSingleThreadExecutor("signal-JobManager"), Util::isMainThread); + this.jobTracker = configuration.getJobTracker(); + this.jobController = new JobController(application, + configuration.getJobStorage(), + configuration.getJobInstantiator(), + configuration.getConstraintFactories(), + configuration.getDataSerializer(), + configuration.getJobTracker(), + Build.VERSION.SDK_INT < 26 ? new AlarmManagerScheduler(application) + : new CompositeScheduler(new InAppScheduler(this), new JobSchedulerScheduler(application)), + new Debouncer(500), + this::onEmptyQueue); + + executor.execute(() -> { + synchronized (this) { + if (WorkManagerMigrator.needsMigration(application)) { + Log.i(TAG, "Detected an old WorkManager database. Migrating."); + WorkManagerMigrator.migrate(application, configuration.getJobStorage(), configuration.getDataSerializer()); + } + + JobStorage jobStorage = configuration.getJobStorage(); + jobStorage.init(); + + int latestVersion = configuration.getJobMigrator().migrate(jobStorage, configuration.getDataSerializer()); + TextSecurePreferences.setJobManagerVersion(application, latestVersion); + + jobController.init(); + + for (ConstraintObserver constraintObserver : configuration.getConstraintObservers()) { + constraintObserver.register(this); + } + + if (Build.VERSION.SDK_INT < 26) { + application.startService(new Intent(application, KeepAliveService.class)); + } + + initialized = true; + notifyAll(); + } + }); + } + + /** + * Begins the execution of jobs. + */ + public void beginJobLoop() { + runOnExecutor(()-> { + int id = 0; + + for (int i = 0; i < configuration.getJobThreadCount(); i++) { + new JobRunner(application, ++id, jobController, JobPredicate.NONE).start(); + } + + for (JobPredicate predicate : configuration.getReservedJobRunners()) { + new JobRunner(application, ++id, jobController, predicate).start(); + } + + jobController.wakeUp(); + }); + } + + /** + * Convenience method for {@link #addListener(JobTracker.JobFilter, JobTracker.JobListener)} that + * takes in an ID to filter on. + */ + public void addListener(@NonNull String id, @NonNull JobTracker.JobListener listener) { + jobTracker.addListener(new JobIdFilter(id), listener); + } + + /** + * Add a listener to subscribe to job state updates. Listeners will be invoked on an arbitrary + * background thread. You must eventually call {@link #removeListener(JobTracker.JobListener)} to avoid + * memory leaks. + */ + public void addListener(@NonNull JobTracker.JobFilter filter, @NonNull JobTracker.JobListener listener) { + jobTracker.addListener(filter, listener); + } + + /** + * Unsubscribe the provided listener from all job updates. + */ + public void removeListener(@NonNull JobTracker.JobListener listener) { + jobTracker.removeListener(listener); + } + + /** + * Enqueues a single job to be run. + */ + public void add(@NonNull Job job) { + new Chain(this, Collections.singletonList(job)).enqueue(); + } + + /** + * Enqueues a single job that depends on a collection of job ID's. + */ + public void add(@NonNull Job job, @NonNull Collection dependsOn) { + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); + + runOnExecutor(() -> { + jobController.submitJobWithExistingDependencies(job, dependsOn, null); + jobController.wakeUp(); + }); + } + + /** + * Enqueues a single job that depends on a collection of job ID's, as well as any unfinished + * items in the specified queue. + */ + public void add(@NonNull Job job, @Nullable String dependsOnQueue) { + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); + + runOnExecutor(() -> { + jobController.submitJobWithExistingDependencies(job, Collections.emptyList(), dependsOnQueue); + jobController.wakeUp(); + }); + } + + /** + * Enqueues a single job that depends on a collection of job ID's, as well as any unfinished + * items in the specified queue. + */ + public void add(@NonNull Job job, @NonNull Collection dependsOn, @Nullable String dependsOnQueue) { + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); + + runOnExecutor(() -> { + jobController.submitJobWithExistingDependencies(job, dependsOn, dependsOnQueue); + jobController.wakeUp(); + }); + } + + /** + * Begins the creation of a job chain with a single job. + * @see Chain + */ + public Chain startChain(@NonNull Job job) { + return new Chain(this, Collections.singletonList(job)); + } + + /** + * Begins the creation of a job chain with a set of jobs that can be run in parallel. + * @see Chain + */ + public Chain startChain(@NonNull List jobs) { + return new Chain(this, jobs); + } + + /** + * Attempts to cancel a job. This is best-effort and may not actually prevent a job from + * completing if it was already running. If this job is running, this can only stop jobs that + * bother to check {@link Job#isCanceled()}. + * + * When a job is canceled, {@link Job#onFailure()} will be triggered at the earliest possible + * moment. Just like a normal failure, all later jobs in the same chain will also be failed. + */ + public void cancel(@NonNull String id) { + runOnExecutor(() -> jobController.cancelJob(id)); + } + + /** + * Cancels all jobs in the specified queue. See {@link #cancel(String)} for details. + */ + public void cancelAllInQueue(@NonNull String queue) { + runOnExecutor(() -> jobController.cancelAllInQueue(queue)); + } + + /** + * Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning + * you must take great care where you call this. It could take a very long time to complete! + * + * @return If the job completed, this will contain its completion state. If it timed out or + * otherwise didn't complete, this will be absent. + */ + @WorkerThread + public Optional runSynchronously(@NonNull Job job, long timeout) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference resultState = new AtomicReference<>(); + + addListener(job.getId(), new JobTracker.JobListener() { + @Override + public void onStateChanged(@NonNull Job job, @NonNull JobTracker.JobState jobState) { + if (jobState.isComplete()) { + removeListener(this); + resultState.set(jobState); + latch.countDown(); + } + } + }); + + add(job); + + try { + if (!latch.await(timeout, TimeUnit.MILLISECONDS)) { + return Optional.absent(); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted during runSynchronously()", e); + return Optional.absent(); + } + + return Optional.fromNullable(resultState.get()); + } + + /** + * Retrieves a string representing the state of the job queue. Intended for debugging. + */ + @WorkerThread + public @NonNull String getDebugInfo() { + AtomicReference result = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + runOnExecutor(() -> { + result.set(jobController.getDebugInfo()); + latch.countDown(); + }); + + try { + boolean finished = latch.await(10, TimeUnit.SECONDS); + if (finished) { + return result.get(); + } else { + return "Timed out waiting for Job info."; + } + } catch (InterruptedException e) { + Log.w(TAG, "Failed to retrieve Job info.", e); + return "Failed to retrieve Job info."; + } + } + + /** + * Adds a listener that will be notified when the job queue has been drained. + */ + void addOnEmptyQueueListener(@NonNull EmptyQueueListener listener) { + runOnExecutor(() -> { + synchronized (emptyQueueListeners) { + emptyQueueListeners.add(listener); + } + }); + } + + /** + * Removes a listener that was added via {@link #addOnEmptyQueueListener(EmptyQueueListener)}. + */ + void removeOnEmptyQueueListener(@NonNull EmptyQueueListener listener) { + runOnExecutor(() -> { + synchronized (emptyQueueListeners) { + emptyQueueListeners.remove(listener); + } + }); + } + + @Override + public void onConstraintMet(@NonNull String reason) { + Log.i(TAG, "onConstraintMet(" + reason + ")"); + wakeUp(); + } + + /** + * Blocks until all pending operations are finished. + */ + @WorkerThread + public void flush() { + CountDownLatch latch = new CountDownLatch(1); + + runOnExecutor(latch::countDown); + + try { + latch.await(); + Log.i(TAG, "Successfully flushed."); + } catch (InterruptedException e) { + Log.w(TAG, "Failed to finish flushing.", e); + } + } + + /** + * Can tell you if a queue is empty at the time of invocation. It is worth noting that the state + * of the queue could change immediately after this method returns due to a call on some other + * thread, and you should take that into consideration when using the result. If you want + * something to happen within a queue, the safest course of action will always be to create a + * job and place it in that queue. + * + * @return True if requested queue is empty at the time of invocation, otherwise false. + */ + @WorkerThread + public boolean isQueueEmpty(@NonNull String queueKey) { + return areQueuesEmpty(Collections.singleton(queueKey)); + } + + /** + * See {@link #isQueueEmpty(String)} + * + * @return True if *all* requested queues are empty at the time of invocation, otherwise false. + */ + @WorkerThread + public boolean areQueuesEmpty(@NonNull Set queueKeys) { + waitUntilInitialized(); + return jobController.areQueuesEmpty(queueKeys); + } + + /** + * Pokes the system to take another pass at the job queue. + */ + void wakeUp() { + runOnExecutor(jobController::wakeUp); + } + + private void enqueueChain(@NonNull Chain chain) { + for (List jobList : chain.getJobListChain()) { + for (Job job : jobList) { + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); + } + } + + runOnExecutor(() -> { + jobController.submitNewJobChain(chain.getJobListChain()); + jobController.wakeUp(); + }); + } + + private void onEmptyQueue() { + runOnExecutor(() -> { + synchronized (emptyQueueListeners) { + for (EmptyQueueListener listener : emptyQueueListeners) { + listener.onQueueEmpty(); + } + } + }); + } + + /** + * Anything that you want to ensure happens off of the main thread and after initialization, run + * it through here. + */ + private void runOnExecutor(@NonNull Runnable runnable) { + executor.execute(() -> { + waitUntilInitialized(); + runnable.run(); + }); + } + + private void waitUntilInitialized() { + if (!initialized) { + Log.i(TAG, "Waiting for initialization..."); + synchronized (this) { + while (!initialized) { + Util.wait(this, 0); + } + } + Log.i(TAG, "Initialization complete."); + } + } + + + public interface EmptyQueueListener { + void onQueueEmpty(); + } + + public static class JobIdFilter implements JobTracker.JobFilter { + private final String id; + + public JobIdFilter(@NonNull String id) { + this.id = id; + } + + @Override + public boolean matches(@NonNull Job job) { + return id.equals(job.getId()); + } + } + + /** + * Allows enqueuing work that depends on each other. Jobs that appear later in the chain will + * only run after all jobs earlier in the chain have been completed. If a job fails, all jobs + * that occur later in the chain will also be failed. + */ + public static class Chain { + + private final JobManager jobManager; + private final List> jobs; + + private Chain(@NonNull JobManager jobManager, @NonNull List jobs) { + this.jobManager = jobManager; + this.jobs = new LinkedList<>(); + + this.jobs.add(new ArrayList<>(jobs)); + } + + public Chain then(@NonNull Job job) { + return then(Collections.singletonList(job)); + } + + public Chain then(@NonNull List jobs) { + if (!jobs.isEmpty()) { + this.jobs.add(new ArrayList<>(jobs)); + } + return this; + } + + public void enqueue() { + jobManager.enqueueChain(this); + } + + private List> getJobListChain() { + return jobs; + } + } + + public static class Configuration { + + private final ExecutorFactory executorFactory; + private final int jobThreadCount; + private final JobInstantiator jobInstantiator; + private final ConstraintInstantiator constraintInstantiator; + private final List constraintObservers; + private final Data.Serializer dataSerializer; + private final JobStorage jobStorage; + private final JobMigrator jobMigrator; + private final JobTracker jobTracker; + private final List reservedJobRunners; + + private Configuration(int jobThreadCount, + @NonNull ExecutorFactory executorFactory, + @NonNull JobInstantiator jobInstantiator, + @NonNull ConstraintInstantiator constraintInstantiator, + @NonNull List constraintObservers, + @NonNull Data.Serializer dataSerializer, + @NonNull JobStorage jobStorage, + @NonNull JobMigrator jobMigrator, + @NonNull JobTracker jobTracker, + @NonNull List reservedJobRunners) + { + this.executorFactory = executorFactory; + this.jobThreadCount = jobThreadCount; + this.jobInstantiator = jobInstantiator; + this.constraintInstantiator = constraintInstantiator; + this.constraintObservers = new ArrayList<>(constraintObservers); + this.dataSerializer = dataSerializer; + this.jobStorage = jobStorage; + this.jobMigrator = jobMigrator; + this.jobTracker = jobTracker; + this.reservedJobRunners = new ArrayList<>(reservedJobRunners); + } + + int getJobThreadCount() { + return jobThreadCount; + } + + @NonNull ExecutorFactory getExecutorFactory() { + return executorFactory; + } + + @NonNull JobInstantiator getJobInstantiator() { + return jobInstantiator; + } + + @NonNull + ConstraintInstantiator getConstraintFactories() { + return constraintInstantiator; + } + + @NonNull List getConstraintObservers() { + return constraintObservers; + } + + @NonNull Data.Serializer getDataSerializer() { + return dataSerializer; + } + + @NonNull JobStorage getJobStorage() { + return jobStorage; + } + + @NonNull JobMigrator getJobMigrator() { + return jobMigrator; + } + + @NonNull JobTracker getJobTracker() { + return jobTracker; + } + + @NonNull List getReservedJobRunners() { + return reservedJobRunners; + } + + public static class Builder { + + private ExecutorFactory executorFactory = new DefaultExecutorFactory(); + private int jobThreadCount = Math.max(2, Math.min(Runtime.getRuntime().availableProcessors() - 1, 4)); + private Map jobFactories = new HashMap<>(); + private Map constraintFactories = new HashMap<>(); + private List constraintObservers = new ArrayList<>(); + private Data.Serializer dataSerializer = new JsonDataSerializer(); + private JobStorage jobStorage = null; + private JobMigrator jobMigrator = null; + private JobTracker jobTracker = new JobTracker(); + private List reservedJobRunners = new ArrayList<>(); + + public @NonNull Builder setJobThreadCount(int jobThreadCount) { + this.jobThreadCount = jobThreadCount; + return this; + } + + public @NonNull Builder addReservedJobRunner(@NonNull JobPredicate predicate) { + this.reservedJobRunners.add(predicate); + return this; + } + + public @NonNull Builder setExecutorFactory(@NonNull ExecutorFactory executorFactory) { + this.executorFactory = executorFactory; + return this; + } + + public @NonNull Builder setJobFactories(@NonNull Map jobFactories) { + this.jobFactories = jobFactories; + return this; + } + + public @NonNull Builder setConstraintFactories(@NonNull Map constraintFactories) { + this.constraintFactories = constraintFactories; + return this; + } + + public @NonNull Builder setConstraintObservers(@NonNull List constraintObservers) { + this.constraintObservers = constraintObservers; + return this; + } + + public @NonNull Builder setDataSerializer(@NonNull Data.Serializer dataSerializer) { + this.dataSerializer = dataSerializer; + return this; + } + + public @NonNull Builder setJobStorage(@NonNull JobStorage jobStorage) { + this.jobStorage = jobStorage; + return this; + } + + public @NonNull Builder setJobMigrator(@NonNull JobMigrator jobMigrator) { + this.jobMigrator = jobMigrator; + return this; + } + + public @NonNull Configuration build() { + return new Configuration(jobThreadCount, + executorFactory, + new JobInstantiator(jobFactories), + new ConstraintInstantiator(constraintFactories), + new ArrayList<>(constraintObservers), + dataSerializer, + jobStorage, + jobMigrator, + jobTracker, + reservedJobRunners); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigration.java new file mode 100644 index 00000000..216d2eb0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigration.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Create a subclass of this to perform a migration on persisted {@link Job}s. A migration targets + * a specific end version, and the assumption is that it can migrate jobs to that end version from + * the previous version. The class will be provided a bundle of job data for each persisted job and + * give back an updated version (if applicable). + */ +public abstract class JobMigration { + + private final int endVersion; + + protected JobMigration(int endVersion) { + this.endVersion = endVersion; + } + + /** + * Given a bundle of job data, return a bundle of job data that should be used in place of it. + * You may obviously return the same object if you don't wish to change it. + */ + protected abstract @NonNull JobData migrate(@NonNull JobData jobData); + + int getEndVersion() { + return endVersion; + } + + public static class JobData { + + private final String factoryKey; + private final String queueKey; + private final Data data; + + public JobData(@NonNull String factoryKey, @Nullable String queueKey, @NonNull Data data) { + this.factoryKey = factoryKey; + this.queueKey = queueKey; + this.data = data; + } + + public @NonNull JobData withFactoryKey(@NonNull String newFactoryKey) { + return new JobData(newFactoryKey, queueKey, data); + } + + public @NonNull JobData withQueueKey(@Nullable String newQueueKey) { + return new JobData(factoryKey, newQueueKey, data); + } + + public @NonNull JobData withData(@NonNull Data newData) { + return new JobData(factoryKey, queueKey, newData); + } + + public @NonNull String getFactoryKey() { + return factoryKey; + } + + public @Nullable String getQueueKey() { + return queueKey; + } + + public @NonNull Data getData() { + return data; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java new file mode 100644 index 00000000..6f1a7f6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.JobMigration.JobData; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; + +import java.util.HashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; + +@SuppressLint("UseSparseArrays") +public class JobMigrator { + + private static final String TAG = Log.tag(JobMigrator.class); + + private final int lastSeenVersion; + private final int currentVersion; + private final Map migrations; + + public JobMigrator(int lastSeenVersion, int currentVersion, @NonNull List migrations) { + this.lastSeenVersion = lastSeenVersion; + this.currentVersion = currentVersion; + this.migrations = new HashMap<>(); + + if (migrations.size() != currentVersion - 1) { + throw new AssertionError("You must have a migration for every version!"); + } + + for (int i = 0; i < migrations.size(); i++) { + JobMigration migration = migrations.get(i); + + if (migration.getEndVersion() != i + 2) { + throw new AssertionError("Missing migration for version " + (i + 2) + "!"); + } + + this.migrations.put(migration.getEndVersion(), migrations.get(i)); + } + } + + /** + * @return The version that has been migrated to. + */ + int migrate(@NonNull JobStorage jobStorage, @NonNull Data.Serializer dataSerializer) { + List jobSpecs = jobStorage.getAllJobSpecs(); + + for (int i = lastSeenVersion; i < currentVersion; i++) { + Log.i(TAG, "Migrating from " + i + " to " + (i + 1)); + + ListIterator iter = jobSpecs.listIterator(); + JobMigration migration = migrations.get(i + 1); + + assert migration != null; + + while (iter.hasNext()) { + JobSpec jobSpec = iter.next(); + Data data = dataSerializer.deserialize(jobSpec.getSerializedData()); + JobData originalJobData = new JobData(jobSpec.getFactoryKey(), jobSpec.getQueueKey(), data); + JobData updatedJobData = migration.migrate(originalJobData); + JobSpec updatedJobSpec = new JobSpec(jobSpec.getId(), + updatedJobData.getFactoryKey(), + updatedJobData.getQueueKey(), + jobSpec.getCreateTime(), + jobSpec.getNextRunAttemptTime(), + jobSpec.getRunAttempt(), + jobSpec.getMaxAttempts(), + jobSpec.getLifespan(), + dataSerializer.serialize(updatedJobData.getData()), + jobSpec.getSerializedInputData(), + jobSpec.isRunning(), + jobSpec.isMemoryOnly()); + + iter.set(updatedJobSpec); + } + } + + jobStorage.updateJobs(jobSpecs); + + return currentVersion; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobPredicate.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobPredicate.java new file mode 100644 index 00000000..5ef44a6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobPredicate.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; + +public interface JobPredicate { + JobPredicate NONE = jobSpec -> true; + + boolean shouldRun(@NonNull JobSpec jobSpec); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java new file mode 100644 index 00000000..f8301162 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.Application; +import android.os.PowerManager; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.WakeLockUtil; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * A thread that constantly checks for available {@link Job}s owned by the {@link JobController}. + * When one is available, this class will execute it and call the appropriate methods on + * {@link JobController} based on the result. + * + * {@link JobRunner} and {@link JobController} were written such that you should be able to have + * N concurrent {@link JobRunner}s operating over the same {@link JobController}. + */ +class JobRunner extends Thread { + + private static final String TAG = JobRunner.class.getSimpleName(); + + private static long WAKE_LOCK_TIMEOUT = TimeUnit.MINUTES.toMillis(10); + + private final Application application; + private final int id; + private final JobController jobController; + private final JobPredicate jobPredicate; + + JobRunner(@NonNull Application application, int id, @NonNull JobController jobController, @NonNull JobPredicate predicate) { + super("signal-JobRunner-" + id); + + this.application = application; + this.id = id; + this.jobController = jobController; + this.jobPredicate = predicate; + } + + @Override + public synchronized void run() { + //noinspection InfiniteLoopStatement + while (true) { + Job job = jobController.pullNextEligibleJobForExecution(jobPredicate); + Job.Result result = run(job); + + jobController.onJobFinished(job); + + if (result.isSuccess()) { + jobController.onSuccess(job, result.getOutputData()); + } else if (result.isRetry()) { + jobController.onRetry(job, result.getBackoffInterval()); + job.onRetry(); + } else if (result.isFailure()) { + List dependents = jobController.onFailure(job); + job.onFailure(); + Stream.of(dependents).forEach(Job::onFailure); + + if (result.getException() != null) { + throw result.getException(); + } + } else { + throw new AssertionError("Invalid job result!"); + } + } + } + + private Job.Result run(@NonNull Job job) { + long runStartTime = System.currentTimeMillis(); + Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Running job.")); + + if (isJobExpired(job)) { + Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its lifespan.")); + return Job.Result.failure(); + } + + Job.Result result = null; + PowerManager.WakeLock wakeLock = null; + + try { + wakeLock = WakeLockUtil.acquire(application, PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TIMEOUT, job.getId()); + result = job.run(); + + if (job.isCanceled()) { + Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing because the job was canceled.")); + result = Job.Result.failure(); + } + } catch (Exception e) { + Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing due to an unexpected exception."), e); + return Job.Result.failure(); + } finally { + if (wakeLock != null) { + WakeLockUtil.release(wakeLock, job.getId()); + } + } + + printResult(job, result, runStartTime); + + if (result.isRetry() && + job.getRunAttempt() + 1 >= job.getParameters().getMaxAttempts() && + job.getParameters().getMaxAttempts() != Job.Parameters.UNLIMITED) + { + Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its max number of attempts.")); + return Job.Result.failure(); + } + + return result; + } + + private boolean isJobExpired(@NonNull Job job) { + long expirationTime = job.getParameters().getCreateTime() + job.getParameters().getLifespan(); + + if (expirationTime < 0) { + expirationTime = Long.MAX_VALUE; + } + + return job.getParameters().getLifespan() != Job.Parameters.IMMORTAL && expirationTime <= System.currentTimeMillis(); + } + + private void printResult(@NonNull Job job, @NonNull Job.Result result, long runStartTime) { + if (result.getException() != null) { + Log.e(TAG, JobLogger.format(job, String.valueOf(id), "Job failed with a fatal exception. Crash imminent.")); + } else if (result.isFailure()) { + Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Job failed.")); + } else { + Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Job finished with result " + result + " in " + (System.currentTimeMillis() - runStartTime) + " ms.")); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java new file mode 100644 index 00000000..6080fc32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.Application; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.List; +import java.util.Locale; + +@RequiresApi(26) +public final class JobSchedulerScheduler implements Scheduler { + + private static final String TAG = Log.tag(JobSchedulerScheduler.class); + + private final Application application; + + JobSchedulerScheduler(@NonNull Application application) { + this.application = application; + } + + @RequiresApi(26) + @Override + public void schedule(long delay, @NonNull List constraints) { + JobScheduler jobScheduler = application.getSystemService(JobScheduler.class); + + String constraintNames = constraints.isEmpty() ? "" + : Stream.of(constraints) + .map(Constraint::getJobSchedulerKeyPart) + .withoutNulls() + .sorted() + .collect(Collectors.joining("-")); + + int jobId = constraintNames.hashCode(); + + if (jobScheduler.getPendingJob(jobId) != null) { + return; + } + + Log.i(TAG, String.format(Locale.US, "JobScheduler enqueue of %s (%d)", constraintNames, jobId)); + + JobInfo.Builder jobInfoBuilder = new JobInfo.Builder(jobId, new ComponentName(application, SystemService.class)) + .setMinimumLatency(delay) + .setPersisted(true); + + for (Constraint constraint : constraints) { + constraint.applyToJobInfo(jobInfoBuilder); + } + + jobScheduler.schedule(jobInfoBuilder.build()); + } + + @RequiresApi(api = 26) + public static class SystemService extends JobService { + + @Override + public boolean onStartJob(JobParameters params) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + Log.i(TAG, "Waking due to job: " + params.getJobId()); + + jobManager.addOnEmptyQueueListener(new JobManager.EmptyQueueListener() { + @Override + public void onQueueEmpty() { + jobManager.removeOnEmptyQueueListener(this); + jobFinished(params, false); + } + }); + + jobManager.wakeUp(); + + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java new file mode 100644 index 00000000..4604a317 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.LRUCache; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +/** + * Tracks the state of {@link Job}s and allows callers to listen to changes. + */ +public class JobTracker { + + private final Map jobInfos; + private final List jobListeners; + private final Executor listenerExecutor; + + JobTracker() { + this.jobInfos = new LRUCache<>(1000); + this.jobListeners = new ArrayList<>(); + this.listenerExecutor = SignalExecutors.BOUNDED; + } + + /** + * Add a listener to subscribe to job state updates. Listeners will be invoked on an arbitrary + * background thread. You must eventually call {@link #removeListener(JobListener)} to avoid + * memory leaks. + */ + synchronized void addListener(@NonNull JobFilter filter, @NonNull JobListener listener) { + jobListeners.add(new ListenerInfo(filter, listener)); + } + + /** + * Unsubscribe the provided listener from all job updates. + */ + synchronized void removeListener(@NonNull JobListener listener) { + Iterator iter = jobListeners.iterator(); + + while (iter.hasNext()) { + if (listener.equals(iter.next().getListener())) { + iter.remove(); + } + } + } + + /** + * Update the state of a job with the associated ID. + */ + synchronized void onStateChange(@NonNull Job job, @NonNull JobState state) { + getOrCreateJobInfo(job).setJobState(state); + + Stream.of(jobListeners) + .filter(info -> info.getFilter().matches(job)) + .map(ListenerInfo::getListener) + .forEach(listener -> { + listenerExecutor.execute(() -> listener.onStateChanged(job, state)); + }); + } + + /** + * Returns whether or not any jobs referenced by the IDs in the provided collection have failed. + * Keep in mind that this is not perfect -- our data is only kept in memory, and even then only up + * to a certain limit. + */ + synchronized boolean haveAnyFailed(@NonNull Collection jobIds) { + for (String jobId : jobIds) { + JobInfo jobInfo = jobInfos.get(jobId); + if (jobInfo != null && jobInfo.getJobState() == JobState.FAILURE) { + return true; + } + } + + return false; + } + + private @NonNull JobInfo getOrCreateJobInfo(@NonNull Job job) { + JobInfo jobInfo = jobInfos.get(job.getId()); + + if (jobInfo == null) { + jobInfo = new JobInfo(job); + } + + jobInfos.put(job.getId(), jobInfo); + + return jobInfo; + } + + public interface JobFilter { + boolean matches(@NonNull Job job); + } + + public interface JobListener { + @AnyThread + void onStateChanged(@NonNull Job job, @NonNull JobState jobState); + } + + public enum JobState { + PENDING(false), RUNNING(false), SUCCESS(true), FAILURE(true), IGNORED(true); + + private final boolean complete; + + JobState(boolean complete) { + this.complete = complete; + } + + public boolean isComplete() { + return complete; + } + } + + private static class ListenerInfo { + private final JobFilter filter; + private final JobListener listener; + + private ListenerInfo(JobFilter filter, JobListener listener) { + this.filter = filter; + this.listener = listener; + } + + @NonNull JobFilter getFilter() { + return filter; + } + + @NonNull JobListener getListener() { + return listener; + } + } + + private static class JobInfo { + private final Job job; + private JobState jobState; + + private JobInfo(Job job) { + this.job = job; + } + + @NonNull Job getJob() { + return job; + } + + void setJobState(@NonNull JobState jobState) { + this.jobState = jobState; + } + + @Nullable JobState getJobState() { + return jobState; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/KeepAliveService.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/KeepAliveService.java new file mode 100644 index 00000000..55043bc1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/KeepAliveService.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.annotation.Nullable; + +/** + * Service that keeps the application in memory while the app is closed. + * + * Important: Should only be used on API < 26. + */ +public class KeepAliveService extends Service { + + @Override + public @Nullable IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java new file mode 100644 index 00000000..194acd39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import java.util.List; + +public interface Scheduler { + void schedule(long delay, @NonNull List constraints); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BackoffUtil.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BackoffUtil.java new file mode 100644 index 00000000..f36e0c26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BackoffUtil.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +public final class BackoffUtil { + + private BackoffUtil() {} + + /** + * Simple exponential backoff with random jitter. + * @param pastAttemptCount The number of attempts that have already been made. + * + * @return The calculated backoff. + */ + public static long exponentialBackoff(int pastAttemptCount, long maxBackoff) { + if (pastAttemptCount < 1) { + throw new IllegalArgumentException("Bad attempt count! " + pastAttemptCount); + } + + int boundedAttempt = Math.min(pastAttemptCount, 30); + long exponentialBackoff = (long) Math.pow(2, boundedAttempt) * 1000; + long actualBackoff = Math.min(exponentialBackoff, maxBackoff); + double jitter = 0.75 + (Math.random() * 0.5); + + return (long) (actualBackoff * jitter); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java new file mode 100644 index 00000000..f2a41805 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.content.Context; +import android.telephony.PhoneStateListener; +import android.telephony.ServiceState; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +public class CellServiceConstraintObserver implements ConstraintObserver { + + private static final String REASON = CellServiceConstraintObserver.class.getSimpleName(); + + private volatile Notifier notifier; + private volatile ServiceState lastKnownState; + + private static volatile CellServiceConstraintObserver instance; + + public static CellServiceConstraintObserver getInstance(@NonNull Application application) { + if (instance == null) { + synchronized (CellServiceConstraintObserver.class) { + if (instance == null) { + instance = new CellServiceConstraintObserver(application); + } + } + } + return instance; + } + + private CellServiceConstraintObserver(@NonNull Application application) { + TelephonyManager telephonyManager = (TelephonyManager) application.getSystemService(Context.TELEPHONY_SERVICE); + ServiceStateListener serviceStateListener = new ServiceStateListener(); + + SignalExecutors.BOUNDED.execute(() -> { + telephonyManager.listen(serviceStateListener, PhoneStateListener.LISTEN_SERVICE_STATE); + }); + } + + @Override + public void register(@NonNull Notifier notifier) { + this.notifier = notifier; + } + + public boolean hasService() { + return lastKnownState != null && lastKnownState.getState() == ServiceState.STATE_IN_SERVICE; + } + + private class ServiceStateListener extends PhoneStateListener { + @Override + public void onServiceStateChanged(ServiceState serviceState) { + lastKnownState = serviceState; + + if (serviceState.getState() == ServiceState.STATE_IN_SERVICE && notifier != null) { + notifier.onConstraintMet(REASON); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java new file mode 100644 index 00000000..e67f71c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.jobmanager.Constraint; + +/** + * Job constraint for determining whether or not the device is actively charging. + */ +public class ChargingConstraint implements Constraint { + + public static final String KEY = "ChargingConstraint"; + + private ChargingConstraint() { + } + + @Override + public boolean isMet() { + return ChargingConstraintObserver.isCharging(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @RequiresApi(26) + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + jobInfoBuilder.setRequiresCharging(true); + } + + @Override + public String getJobSchedulerKeyPart() { + return "CHARGING"; + } + + public static final class Factory implements Constraint.Factory { + + @Override + public ChargingConstraint create() { + return new ChargingConstraint(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java new file mode 100644 index 00000000..d534fe2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +/** + * Observes the charging state of the device and notifies the JobManager system when appropriate. + */ +public class ChargingConstraintObserver implements ConstraintObserver { + + private static final String REASON = ChargingConstraintObserver.class.getSimpleName(); + private static final int STATUS_BATTERY = 0; + + private final Application application; + + private static volatile boolean charging; + + public ChargingConstraintObserver(@NonNull Application application) { + this.application = application; + } + + @Override + public void register(@NonNull Notifier notifier) { + Intent intent = application.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + boolean wasCharging = charging; + + charging = isCharging(intent); + + if (charging && !wasCharging) { + notifier.onConstraintMet(REASON); + } + } + }, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + + charging = isCharging(intent); + } + + public static boolean isCharging() { + return charging; + } + + private static boolean isCharging(@Nullable Intent intent) { + if (intent == null) { + return false; + } + + int status = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, STATUS_BATTERY); + return status != STATUS_BATTERY; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraint.java new file mode 100644 index 00000000..c6a95e55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraint.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Constraint; + +/** + * A constraint that is met once we have pulled down and decrypted all messages from the websocket + * during initial load. See {@link org.thoughtcrime.securesms.messages.IncomingMessageObserver}. + */ +public final class DecryptionsDrainedConstraint implements Constraint { + + public static final String KEY = "WebsocketDrainedConstraint"; + + private DecryptionsDrainedConstraint() { + } + + @Override + public boolean isMet() { + return ApplicationDependencies.getIncomingMessageObserver().isDecryptionDrained(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @RequiresApi(26) + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + } + + public static final class Factory implements Constraint.Factory { + + @Override + public DecryptionsDrainedConstraint create() { + return new DecryptionsDrainedConstraint(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraintObserver.java new file mode 100644 index 00000000..4bf7ec54 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DecryptionsDrainedConstraintObserver.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +/** + * An observer for {@link DecryptionsDrainedConstraint}. Will fire when the websocket is drained and + * the relevant decryptions have finished. + */ +public class DecryptionsDrainedConstraintObserver implements ConstraintObserver { + + private static final String REASON = DecryptionsDrainedConstraintObserver.class.getSimpleName(); + + @Override + public void register(@NonNull Notifier notifier) { + ApplicationDependencies.getIncomingMessageObserver().addDecryptionDrainedListener(() -> { + notifier.onConstraintMet(REASON); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java new file mode 100644 index 00000000..a9d45910 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.ExecutorFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class DefaultExecutorFactory implements ExecutorFactory { + @Override + public @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name) { + return Executors.newSingleThreadExecutor(r -> new Thread(r, name)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/FactoryJobPredicate.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/FactoryJobPredicate.java new file mode 100644 index 00000000..5959a460 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/FactoryJobPredicate.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.JobPredicate; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * A {@link JobPredicate} that will only run jobs with the provided factory keys. + */ +public final class FactoryJobPredicate implements JobPredicate { + + private final Set factories; + + public FactoryJobPredicate(String... factories) { + this.factories = new HashSet<>(Arrays.asList(factories)); + } + + @Override + public boolean shouldRun(@NonNull JobSpec jobSpec) { + return factories.contains(jobSpec.getFactoryKey()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/JsonDataSerializer.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/JsonDataSerializer.java new file mode 100644 index 00000000..73aacdd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/JsonDataSerializer.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; + +public class JsonDataSerializer implements Data.Serializer { + + private static final String TAG = Log.tag(JsonDataSerializer.class); + + @Override + public @NonNull String serialize(@NonNull Data data) { + try { + return JsonUtils.toJson(data); + } catch (IOException e) { + Log.e(TAG, "Failed to serialize to JSON.", e); + throw new AssertionError(e); + } + } + + @Override + public @NonNull Data deserialize(@NonNull String serialized) { + try { + return JsonUtils.fromJson(serialized, Data.class); + } catch (IOException e) { + Log.e(TAG, "Failed to deserialize JSON.", e); + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraint.java new file mode 100644 index 00000000..493223e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraint.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.app.job.JobInfo; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.jobmanager.Constraint; + +public class NetworkConstraint implements Constraint { + + public static final String KEY = "NetworkConstraint"; + + private final Application application; + + private NetworkConstraint(@NonNull Application application) { + this.application = application; + } + + @Override + public boolean isMet() { + return isMet(application); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @RequiresApi(26) + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + } + + @Override + public String getJobSchedulerKeyPart() { + return "NETWORK"; + } + + public static boolean isMet(@NonNull Context context) { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + + return activeNetworkInfo != null && activeNetworkInfo.isConnected(); + } + + + public static final class Factory implements Constraint.Factory { + + private final Application application; + + public Factory(@NonNull Application application) { + this.application = application; + } + + @Override + public NetworkConstraint create() { + return new NetworkConstraint(application); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java new file mode 100644 index 00000000..0dcd3bde --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +public class NetworkConstraintObserver implements ConstraintObserver { + + private static final String REASON = NetworkConstraintObserver.class.getSimpleName(); + + private final Application application; + + public NetworkConstraintObserver(Application application) { + this.application = application; + } + + @Override + public void register(@NonNull Notifier notifier) { + application.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + NetworkConstraint constraint = new NetworkConstraint.Factory(application).create(); + + if (constraint.isMet()) { + notifier.onConstraintMet(REASON); + } + } + }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java new file mode 100644 index 00000000..661a02ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Constraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class NetworkOrCellServiceConstraint implements Constraint { + + public static final String KEY = "NetworkOrCellServiceConstraint"; + public static final String LEGACY_KEY = "CellServiceConstraint"; + + private final Application application; + private final NetworkConstraint networkConstraint; + + private NetworkOrCellServiceConstraint(@NonNull Application application) { + this.application = application; + this.networkConstraint = new NetworkConstraint.Factory(application).create(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public boolean isMet() { + if (TextSecurePreferences.isWifiSmsEnabled(application)) { + return networkConstraint.isMet() || hasCellService(application); + } else { + return hasCellService(application); + } + } + + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + } + + private static boolean hasCellService(@NonNull Application application) { + return CellServiceConstraintObserver.getInstance(application).hasService(); + } + + public static class Factory implements Constraint.Factory { + + private final Application application; + + public Factory(@NonNull Application application) { + this.application = application; + } + + @Override + public NetworkOrCellServiceConstraint create() { + return new NetworkOrCellServiceConstraint(application); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraint.java new file mode 100644 index 00000000..8ca89923 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraint.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.jobmanager.Constraint; + +/** + * Constraint met when the user is not in an active, connected call. + */ +public final class NotInCallConstraint implements Constraint { + + public static final String KEY = "NotInCallConstraint"; + + @Override + public boolean isMet() { + return isNotInConnectedCall(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static boolean isNotInConnectedCall() { + WebRtcViewModel viewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class); + return viewModel == null || viewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED; + } + + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + } + + public static class Factory implements Constraint.Factory { + @Override + public NotInCallConstraint create() { + return new NotInCallConstraint(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraintObserver.java new file mode 100644 index 00000000..4a1e083a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NotInCallConstraintObserver.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +public final class NotInCallConstraintObserver implements ConstraintObserver { + + private static final String REASON = NotInCallConstraintObserver.class.getSimpleName(); + + @Override + public void register(@NonNull Notifier notifier) { + EventBus.getDefault().register(new EventBusListener(notifier)); + } + + private static final class EventBusListener { + + private final Notifier notifier; + + private EventBusListener(@NonNull Notifier notifier) { + this.notifier = notifier; + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void consume(@NonNull WebRtcViewModel viewModel) { + NotInCallConstraint constraint = new NotInCallConstraint.Factory().create(); + + if (constraint.isMet()) { + notifier.onConstraintMet(REASON); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java new file mode 100644 index 00000000..ee624cf5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.app.job.JobInfo; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Constraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class SqlCipherMigrationConstraint implements Constraint { + + public static final String KEY = "SqlCipherMigrationConstraint"; + + private final Application application; + + private SqlCipherMigrationConstraint(@NonNull Application application) { + this.application = application; + } + + @Override + public boolean isMet() { + return !TextSecurePreferences.getNeedsSqlCipherMigration(application); + } + + @NonNull + @Override + public String getFactoryKey() { + return KEY; + } + + @Override + public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { + } + + public static final class Factory implements Constraint.Factory { + + private final Application application; + + public Factory(@NonNull Application application) { + this.application = application; + } + + @Override + public SqlCipherMigrationConstraint create() { + return new SqlCipherMigrationConstraint(application); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java new file mode 100644 index 00000000..0c922543 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +public class SqlCipherMigrationConstraintObserver implements ConstraintObserver { + + private static final String REASON = SqlCipherMigrationConstraintObserver.class.getSimpleName(); + + private Notifier notifier; + + public SqlCipherMigrationConstraintObserver() { + EventBus.getDefault().register(this); + } + + @Override + public void register(@NonNull Notifier notifier) { + this.notifier = notifier; + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(SqlCipherNeedsMigrationEvent event) { + if (notifier != null) notifier.onConstraintMet(REASON); + } + + public static class SqlCipherNeedsMigrationEvent { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushDecryptMessageJobEnvelopeMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushDecryptMessageJobEnvelopeMigration.java new file mode 100644 index 00000000..f4a029d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushDecryptMessageJobEnvelopeMigration.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; +import org.thoughtcrime.securesms.jobs.FailingJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; + +import java.io.IOException; + +/** + * We removed the messageId property from the job data and replaced it with a serialized envelope, + * so we need to take jobs that referenced an ID and replace it with the envelope instead. + */ +public class PushDecryptMessageJobEnvelopeMigration extends JobMigration { + + private static final String TAG = Log.tag(PushDecryptMessageJobEnvelopeMigration.class); + + private final PushDatabase pushDatabase; + + public PushDecryptMessageJobEnvelopeMigration(@NonNull Context context) { + super(8); + this.pushDatabase = DatabaseFactory.getPushDatabase(context); + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + if ("PushDecryptJob".equals(jobData.getFactoryKey())) { + Log.i(TAG, "Found a PushDecryptJob to migrate."); + return migratePushDecryptMessageJob(pushDatabase, jobData); + } else { + return jobData; + } + } + + private static @NonNull JobData migratePushDecryptMessageJob(@NonNull PushDatabase pushDatabase, @NonNull JobData jobData) { + Data data = jobData.getData(); + + if (data.hasLong("message_id")) { + long messageId = data.getLong("message_id"); + try { + SignalServiceEnvelope envelope = pushDatabase.get(messageId); + return jobData.withData(jobData.getData() + .buildUpon() + .putBlobAsString("envelope", envelope.serialize()) + .build()); + } catch (NoSuchMessageException e) { + Log.w(TAG, "Failed to find envelope in DB! Failing."); + return jobData.withFactoryKey(FailingJob.KEY); + } + } else { + Log.w(TAG, "No message_id property?"); + return jobData; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java new file mode 100644 index 00000000..d88f5d5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; + +import java.io.IOException; + +/** + * We changed the format of the queue key for {@link org.thoughtcrime.securesms.jobs.PushProcessMessageJob} + * to have the recipient ID in it, so this migrates existing jobs to be in that format. + */ +public class PushProcessMessageQueueJobMigration extends JobMigration { + + private static final String TAG = Log.tag(PushProcessMessageQueueJobMigration.class); + + private final Context context; + + public PushProcessMessageQueueJobMigration(@NonNull Context context) { + super(6); + this.context = context; + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + if ("PushProcessJob".equals(jobData.getFactoryKey())) { + Log.i(TAG, "Found a PushProcessMessageJob to migrate."); + try { + return migratePushProcessMessageJob(context, jobData); + } catch (IOException e) { + Log.w(TAG, "Failed to migrate message job.", e); + return jobData; + } + } + return jobData; + } + + private static @NonNull JobData migratePushProcessMessageJob(@NonNull Context context, @NonNull JobData jobData) throws IOException { + Data data = jobData.getData(); + + String suffix = ""; + + if (data.getInt("message_state") == 0) { + SignalServiceContent content = SignalServiceContent.deserialize(Base64.decode(data.getString("message_content"))); + + if (content != null && content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupContext().isPresent()) { + Log.i(TAG, "Migrating a group message."); + try { + GroupId groupId = GroupUtil.idFromGroupContext(content.getDataMessage().get().getGroupContext().get()); + Recipient recipient = Recipient.externalGroupExact(context, groupId); + + suffix = recipient.getId().toQueueKey(); + } catch (BadGroupIdException e) { + Log.w(TAG, "Bad groupId! Using default queue."); + } + } else if (content != null) { + Log.i(TAG, "Migrating an individual message."); + suffix = RecipientId.from(content.getSender()).toQueueKey(); + } + } else { + Log.i(TAG, "Migrating an exception message."); + + String exceptionSender = data.getString("exception_sender"); + GroupId exceptionGroup = GroupId.parseNullableOrThrow(data.getStringOrDefault("exception_groupId", null)); + + if (exceptionGroup != null) { + suffix = Recipient.externalGroupExact(context, exceptionGroup).getId().toQueueKey(); + } else if (exceptionSender != null) { + suffix = Recipient.external(context, exceptionSender).getId().toQueueKey(); + } + } + + return jobData.withQueueKey("__PUSH_PROCESS_JOB__" + suffix); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration.java new file mode 100644 index 00000000..948517ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; + +/** + * Fixes things that went wrong in {@link RecipientIdJobMigration}. In particular, some jobs didn't + * have some necessary data fields carried over. Thankfully they're relatively non-critical, so + * we'll just swap them out with failing jobs if they're missing something. + */ +public class RecipientIdFollowUpJobMigration extends JobMigration { + + public RecipientIdFollowUpJobMigration() { + this(3); + } + + RecipientIdFollowUpJobMigration(int endVersion) { + super(endVersion); + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + switch(jobData.getFactoryKey()) { + case "RequestGroupInfoJob": return migrateRequestGroupInfoJob(jobData); + case "SendDeliveryReceiptJob": return migrateSendDeliveryReceiptJob(jobData); + default: + return jobData; + } + } + + private static @NonNull JobData migrateRequestGroupInfoJob(@NonNull JobData jobData) { + if (!jobData.getData().hasString("source") || !jobData.getData().hasString("group_id")) { + return failingJobData(); + } else { + return jobData; + } + } + + private static @NonNull JobData migrateSendDeliveryReceiptJob(@NonNull JobData jobData) { + if (!jobData.getData().hasString("recipient") || + !jobData.getData().hasLong("message_id") || + !jobData.getData().hasLong("timestamp")) + { + return failingJobData(); + } else { + return jobData; + } + } + + private static JobData failingJobData() { + return new JobData("FailingJob", null, new Data.Builder().build()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration2.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration2.java new file mode 100644 index 00000000..cb463ddf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdFollowUpJobMigration2.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +/** + * Unfortunately there was a bug in {@link RecipientIdFollowUpJobMigration} that requires it to be + * run again. + */ +public class RecipientIdFollowUpJobMigration2 extends RecipientIdFollowUpJobMigration { + public RecipientIdFollowUpJobMigration2() { + super(4); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdJobMigration.java new file mode 100644 index 00000000..a596b0a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RecipientIdJobMigration.java @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.io.Serializable; + +public class RecipientIdJobMigration extends JobMigration { + + private final Application application; + + public RecipientIdJobMigration(@NonNull Application application) { + super(2); + this.application = application; + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + switch(jobData.getFactoryKey()) { + case "MultiDeviceContactUpdateJob": return migrateMultiDeviceContactUpdateJob(jobData); + case "MultiDeviceRevealUpdateJob": return migrateMultiDeviceViewOnceOpenJob(jobData); + case "RequestGroupInfoJob": return migrateRequestGroupInfoJob(jobData); + case "SendDeliveryReceiptJob": return migrateSendDeliveryReceiptJob(jobData); + case "MultiDeviceVerifiedUpdateJob": return migrateMultiDeviceVerifiedUpdateJob(jobData); + case "RetrieveProfileJob": return migrateRetrieveProfileJob(jobData); + case "PushGroupSendJob": return migratePushGroupSendJob(jobData); + case "PushGroupUpdateJob": return migratePushGroupUpdateJob(jobData); + case "DirectoryRefreshJob": return migrateDirectoryRefreshJob(jobData); + case "RetrieveProfileAvatarJob": return migrateRetrieveProfileAvatarJob(jobData); + case "MultiDeviceReadUpdateJob": return migrateMultiDeviceReadUpdateJob(jobData); + case "PushTextSendJob": return migratePushTextSendJob(jobData); + case "PushMediaSendJob": return migratePushMediaSendJob(jobData); + case "SmsSendJob": return migrateSmsSendJob(jobData); + default: return jobData; + } + } + + private @NonNull JobData migrateMultiDeviceContactUpdateJob(@NonNull JobData jobData) { + String address = jobData.getData().hasString("address") ? jobData.getData().getString("address") : null; + Data updatedData = new Data.Builder().putString("recipient", address != null ? Recipient.external(application, address).getId().serialize() : null) + .putBoolean("force_sync", jobData.getData().getBoolean("force_sync")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateMultiDeviceViewOnceOpenJob(@NonNull JobData jobData) { + try { + String rawOld = jobData.getData().getString("message_id"); + OldSerializableSyncMessageId old = JsonUtils.fromJson(rawOld, OldSerializableSyncMessageId.class); + Recipient recipient = Recipient.external(application, old.sender); + NewSerializableSyncMessageId updated = new NewSerializableSyncMessageId(recipient.getId().serialize(), old.timestamp); + String rawUpdated = JsonUtils.toJson(updated); + Data updatedData = new Data.Builder().putString("message_id", rawUpdated).build(); + + return jobData.withData(updatedData); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private @NonNull JobData migrateRequestGroupInfoJob(@NonNull JobData jobData) { + String address = jobData.getData().getString("source"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("source", recipient.getId().serialize()) + .putString("group_id", jobData.getData().getString("group_id")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateSendDeliveryReceiptJob(@NonNull JobData jobData) { + String address = jobData.getData().getString("address"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize()) + .putLong("message_id", jobData.getData().getLong("message_id")) + .putLong("timestamp", jobData.getData().getLong("timestamp")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateMultiDeviceVerifiedUpdateJob(@NonNull JobData jobData) { + String address = jobData.getData().getString("destination"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("destination", recipient.getId().serialize()) + .putString("identity_key", jobData.getData().getString("identity_key")) + .putInt("verified_status", jobData.getData().getInt("verified_status")) + .putLong("timestamp", jobData.getData().getLong("timestamp")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateRetrieveProfileJob(@NonNull JobData jobData) { + String address = jobData.getData().getString("address"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize()).build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migratePushGroupSendJob(@NonNull JobData jobData) { + // noinspection ConstantConditions + Recipient queueRecipient = Recipient.external(application, jobData.getQueueKey()); + String address = jobData.getData().hasString("filter_address") ? jobData.getData().getString("filter_address") : null; + RecipientId recipientId = address != null ? Recipient.external(application, address).getId() : null; + Data updatedData = new Data.Builder().putString("filter_recipient", recipientId != null ? recipientId.serialize() : null) + .putLong("message_id", jobData.getData().getLong("message_id")) + .build(); + + return jobData.withQueueKey(queueRecipient.getId().toQueueKey()) + .withData(updatedData); + } + + private @NonNull JobData migratePushGroupUpdateJob(@NonNull JobData jobData) { + String address = jobData.getData().getString("source"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("source", recipient.getId().serialize()) + .putString("group_id", jobData.getData().getString("group_id")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateDirectoryRefreshJob(@NonNull JobData jobData) { + String address = jobData.getData().hasString("address") ? jobData.getData().getString("address") : null; + Recipient recipient = address != null ? Recipient.external(application, address) : null; + Data updatedData = new Data.Builder().putString("recipient", recipient != null ? recipient.getId().serialize() : null) + .putBoolean("notify_of_new_users", jobData.getData().getBoolean("notify_of_new_users")) + .build(); + + return jobData.withData(updatedData); + } + + private @NonNull JobData migrateRetrieveProfileAvatarJob(@NonNull JobData jobData) { + //noinspection ConstantConditions + String queueAddress = jobData.getQueueKey().substring("RetrieveProfileAvatarJob".length()); + Recipient queueRecipient = Recipient.external(application, queueAddress); + String address = jobData.getData().getString("address"); + Recipient recipient = Recipient.external(application, address); + Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize()) + .putString("profile_avatar", jobData.getData().getString("profile_avatar")) + .build(); + + return jobData.withQueueKey("RetrieveProfileAvatarJob::" + queueRecipient.getId().toQueueKey()) + .withData(updatedData); + } + + private @NonNull JobData migrateMultiDeviceReadUpdateJob(@NonNull JobData jobData) { + try { + String[] rawOld = jobData.getData().getStringArray("message_ids"); + String[] rawUpdated = new String[rawOld.length]; + + for (int i = 0; i < rawOld.length; i++) { + OldSerializableSyncMessageId old = JsonUtils.fromJson(rawOld[i], OldSerializableSyncMessageId.class); + Recipient recipient = Recipient.external(application, old.sender); + NewSerializableSyncMessageId updated = new NewSerializableSyncMessageId(recipient.getId().serialize(), old.timestamp); + + rawUpdated[i] = JsonUtils.toJson(updated); + } + + Data updatedData = new Data.Builder().putStringArray("message_ids", rawUpdated).build(); + + return jobData.withData(updatedData); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private @NonNull JobData migratePushTextSendJob(@NonNull JobData jobData) { + //noinspection ConstantConditions + Recipient recipient = Recipient.external(application, jobData.getQueueKey()); + return jobData.withQueueKey(recipient.getId().toQueueKey()); + } + + private @NonNull JobData migratePushMediaSendJob(@NonNull JobData jobData) { + //noinspection ConstantConditions + Recipient recipient = Recipient.external(application, jobData.getQueueKey()); + return jobData.withQueueKey(recipient.getId().toQueueKey()); + } + + private @NonNull JobData migrateSmsSendJob(@NonNull JobData jobData) { + //noinspection ConstantConditions + if (jobData.getQueueKey() != null) { + Recipient recipient = Recipient.external(application, jobData.getQueueKey()); + return jobData.withQueueKey(recipient.getId().toQueueKey()); + } else { + return jobData; + } + } + + @VisibleForTesting + static class OldSerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String sender; + @JsonProperty + private final long timestamp; + + OldSerializableSyncMessageId(@JsonProperty("sender") String sender, @JsonProperty("timestamp") long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } + } + + @VisibleForTesting + static class NewSerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String recipientId; + @JsonProperty + private final long timestamp; + + NewSerializableSyncMessageId(@JsonProperty("recipientId") String recipientId, @JsonProperty("timestamp") long timestamp) { + this.recipientId = recipientId; + this.timestamp = timestamp; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RetrieveProfileJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RetrieveProfileJobMigration.java new file mode 100644 index 00000000..aa86e4d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/RetrieveProfileJobMigration.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; + +public class RetrieveProfileJobMigration extends JobMigration { + + private static final String TAG = Log.tag(RetrieveProfileJobMigration.class); + + public RetrieveProfileJobMigration() { + super(7); + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + Log.i(TAG, "Running."); + + if ("RetrieveProfileJob".equals(jobData.getFactoryKey())) { + return migrateRetrieveProfileJob(jobData); + } + return jobData; + } + + private static @NonNull JobData migrateRetrieveProfileJob(@NonNull JobData jobData) { + Data data = jobData.getData(); + + if (data.hasString("recipient")) { + Log.i(TAG, "Migrating job."); + + String recipient = data.getString("recipient"); + return jobData.withData(new Data.Builder() + .putStringArray("recipients", new String[] { recipient }) + .build()); + } else { + return jobData; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java new file mode 100644 index 00000000..89e311f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; + +import java.util.SortedSet; +import java.util.TreeSet; + +public class SendReadReceiptsJobMigration extends JobMigration { + + private final MmsSmsDatabase mmsSmsDatabase; + + public SendReadReceiptsJobMigration(@NonNull MmsSmsDatabase mmsSmsDatabase) { + super(5); + this.mmsSmsDatabase = mmsSmsDatabase; + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + if ("SendReadReceiptJob".equals(jobData.getFactoryKey())) { + return migrateSendReadReceiptJob(mmsSmsDatabase, jobData); + } + return jobData; + } + + private static @NonNull JobData migrateSendReadReceiptJob(@NonNull MmsSmsDatabase mmsSmsDatabase, @NonNull JobData jobData) { + Data data = jobData.getData(); + + if (!data.hasLong("thread")) { + long[] messageIds = jobData.getData().getLongArray("message_ids"); + SortedSet threadIds = new TreeSet<>(); + + for (long id : messageIds) { + long threadForMessageId = mmsSmsDatabase.getThreadForMessageId(id); + if (id != -1) { + threadIds.add(threadForMessageId); + } + } + + if (threadIds.size() != 1) { + return new JobData("FailingJob", null, new Data.Builder().build()); + } else { + return jobData.withData(data.buildUpon().putLong("thread", threadIds.first()).build()); + } + + } else { + return jobData; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java new file mode 100644 index 00000000..8cf773a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.jobmanager.persistence; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +public final class ConstraintSpec { + + private final String jobSpecId; + private final String factoryKey; + private final boolean memoryOnly; + + public ConstraintSpec(@NonNull String jobSpecId, @NonNull String factoryKey, boolean memoryOnly) { + this.jobSpecId = jobSpecId; + this.factoryKey = factoryKey; + this.memoryOnly = memoryOnly; + } + + public String getJobSpecId() { + return jobSpecId; + } + + public String getFactoryKey() { + return factoryKey; + } + + public boolean isMemoryOnly() { + return memoryOnly; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConstraintSpec that = (ConstraintSpec) o; + return Objects.equals(jobSpecId, that.jobSpecId) && + Objects.equals(factoryKey, that.factoryKey) && + memoryOnly == that.memoryOnly; + } + + @Override + public int hashCode() { + return Objects.hash(jobSpecId, factoryKey, memoryOnly); + } + + @Override + public @NonNull String toString() { + return String.format("jobSpecId: JOB::%s | factoryKey: %s | memoryOnly: %b", jobSpecId, factoryKey, memoryOnly); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java new file mode 100644 index 00000000..8dfd9f13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.jobmanager.persistence; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +public final class DependencySpec { + + private final String jobId; + private final String dependsOnJobId; + private final boolean memoryOnly; + + public DependencySpec(@NonNull String jobId, @NonNull String dependsOnJobId, boolean memoryOnly) { + this.jobId = jobId; + this.dependsOnJobId = dependsOnJobId; + this.memoryOnly = memoryOnly; + } + + public @NonNull String getJobId() { + return jobId; + } + + public @NonNull String getDependsOnJobId() { + return dependsOnJobId; + } + + public boolean isMemoryOnly() { + return memoryOnly; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DependencySpec that = (DependencySpec) o; + return Objects.equals(jobId, that.jobId) && + Objects.equals(dependsOnJobId, that.dependsOnJobId) && + memoryOnly == that.memoryOnly; + } + + @Override + public int hashCode() { + return Objects.hash(jobId, dependsOnJobId, memoryOnly); + } + + @Override + public @NonNull String toString() { + return String.format("jobSpecId: JOB::%s | dependsOnJobSpecId: JOB::%s | memoryOnly: %b", jobId, dependsOnJobId, memoryOnly); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java new file mode 100644 index 00000000..03169f14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.jobmanager.persistence; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Objects; + +public final class FullSpec { + + private final JobSpec jobSpec; + private final List constraintSpecs; + private final List dependencySpecs; + + public FullSpec(@NonNull JobSpec jobSpec, + @NonNull List constraintSpecs, + @NonNull List dependencySpecs) + { + this.jobSpec = jobSpec; + this.constraintSpecs = constraintSpecs; + this.dependencySpecs = dependencySpecs; + } + + public @NonNull JobSpec getJobSpec() { + return jobSpec; + } + + public @NonNull List getConstraintSpecs() { + return constraintSpecs; + } + + public @NonNull List getDependencySpecs() { + return dependencySpecs; + } + + public boolean isMemoryOnly() { + return jobSpec.isMemoryOnly(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FullSpec fullSpec = (FullSpec) o; + return Objects.equals(jobSpec, fullSpec.jobSpec) && + Objects.equals(constraintSpecs, fullSpec.constraintSpecs) && + Objects.equals(dependencySpecs, fullSpec.dependencySpecs); + } + + @Override + public int hashCode() { + return Objects.hash(jobSpec, constraintSpecs, dependencySpecs); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java new file mode 100644 index 00000000..7c6f9654 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.jobmanager.persistence; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +public final class JobSpec { + + private final String id; + private final String factoryKey; + private final String queueKey; + private final long createTime; + private final long nextRunAttemptTime; + private final int runAttempt; + private final int maxAttempts; + private final long lifespan; + private final String serializedData; + private final String serializedInputData; + private final boolean isRunning; + private final boolean memoryOnly; + + public JobSpec(@NonNull String id, + @NonNull String factoryKey, + @Nullable String queueKey, + long createTime, + long nextRunAttemptTime, + int runAttempt, + int maxAttempts, + long lifespan, + @NonNull String serializedData, + @Nullable String serializedInputData, + boolean isRunning, + boolean memoryOnly) + { + this.id = id; + this.factoryKey = factoryKey; + this.queueKey = queueKey; + this.createTime = createTime; + this.nextRunAttemptTime = nextRunAttemptTime; + this.runAttempt = runAttempt; + this.maxAttempts = maxAttempts; + this.lifespan = lifespan; + this.serializedData = serializedData; + this.serializedInputData = serializedInputData; + this.isRunning = isRunning; + this.memoryOnly = memoryOnly; + } + + public @NonNull String getId() { + return id; + } + + public @NonNull String getFactoryKey() { + return factoryKey; + } + + public @Nullable String getQueueKey() { + return queueKey; + } + + public long getCreateTime() { + return createTime; + } + + public long getNextRunAttemptTime() { + return nextRunAttemptTime; + } + + public int getRunAttempt() { + return runAttempt; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public long getLifespan() { + return lifespan; + } + + public @NonNull String getSerializedData() { + return serializedData; + } + + public @Nullable String getSerializedInputData() { + return serializedInputData; + } + + public boolean isRunning() { + return isRunning; + } + + public boolean isMemoryOnly() { + return memoryOnly; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + JobSpec jobSpec = (JobSpec) o; + return createTime == jobSpec.createTime && + nextRunAttemptTime == jobSpec.nextRunAttemptTime && + runAttempt == jobSpec.runAttempt && + maxAttempts == jobSpec.maxAttempts && + lifespan == jobSpec.lifespan && + isRunning == jobSpec.isRunning && + memoryOnly == jobSpec.memoryOnly && + Objects.equals(id, jobSpec.id) && + Objects.equals(factoryKey, jobSpec.factoryKey) && + Objects.equals(queueKey, jobSpec.queueKey) && + Objects.equals(serializedData, jobSpec.serializedData) && + Objects.equals(serializedInputData, jobSpec.serializedInputData); + } + + @Override + public int hashCode() { + return Objects.hash(id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, lifespan, serializedData, serializedInputData, isRunning, memoryOnly); + } + + @SuppressLint("DefaultLocale") + @Override + public @NonNull String toString() { + return String.format("id: JOB::%s | factoryKey: %s | queueKey: %s | createTime: %d | nextRunAttemptTime: %d | runAttempt: %d | maxAttempts: %d | lifespan: %d | isRunning: %b | memoryOnly: %b", + id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, lifespan, isRunning, memoryOnly); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java new file mode 100644 index 00000000..119dbf67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.jobmanager.persistence; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface JobStorage { + + @WorkerThread + void init(); + + @WorkerThread + void insertJobs(@NonNull List fullSpecs); + + @WorkerThread + @Nullable JobSpec getJobSpec(@NonNull String id); + + @WorkerThread + @NonNull List getAllJobSpecs(); + + @WorkerThread + @NonNull List getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime); + + @WorkerThread + @NonNull List getJobsInQueue(@NonNull String queue); + + @WorkerThread + int getJobCountForFactory(@NonNull String factoryKey); + + @WorkerThread + int getJobCountForFactoryAndQueue(@NonNull String factoryKey, @NonNull String queueKey); + + @WorkerThread + boolean areQueuesEmpty(@NonNull Set queueKeys); + + @WorkerThread + void updateJobRunningState(@NonNull String id, boolean isRunning); + + @WorkerThread + void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime, @NonNull String serializedData); + + @WorkerThread + void updateAllJobsToBePending(); + + @WorkerThread + void updateJobs(@NonNull List jobSpecs); + + @WorkerThread + void deleteJob(@NonNull String id); + + @WorkerThread + void deleteJobs(@NonNull List ids); + + @WorkerThread + @NonNull List getConstraintSpecs(@NonNull String jobId); + + @WorkerThread + @NonNull List getAllConstraintSpecs(); + + @WorkerThread + @NonNull List getDependencySpecsThatDependOnJob(@NonNull String jobSpecId); + + @WorkerThread + @NonNull List getAllDependencySpecs(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/DataMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/DataMigrator.java new file mode 100644 index 00000000..0aa913e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/DataMigrator.java @@ -0,0 +1,169 @@ +package org.thoughtcrime.securesms.jobmanager.workmanager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * Takes a persisted data blob stored by WorkManager and converts it to our {@link Data} class. + */ +final class DataMigrator { + + private static final String TAG = Log.tag(DataMigrator.class); + + static final Data convert(@NonNull byte[] workManagerData) { + Map values = parseWorkManagerDataMap(workManagerData); + + Data.Builder builder = new Data.Builder(); + + for (Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + + if (value == null) { + builder.putString(entry.getKey(), null); + } else { + Class type = value.getClass(); + + if (type == String.class) { + builder.putString(entry.getKey(), (String) value); + } else if (type == String[].class) { + builder.putStringArray(entry.getKey(), (String[]) value); + } else if (type == Integer.class || type == int.class) { + builder.putInt(entry.getKey(), (int) value); + } else if (type == Integer[].class || type == int[].class) { + builder.putIntArray(entry.getKey(), convertToIntArray(value, type)); + } else if (type == Long.class || type == long.class) { + builder.putLong(entry.getKey(), (long) value); + } else if (type == Long[].class || type == long[].class) { + builder.putLongArray(entry.getKey(), convertToLongArray(value, type)); + } else if (type == Float.class || type == float.class) { + builder.putFloat(entry.getKey(), (float) value); + } else if (type == Float[].class || type == float[].class) { + builder.putFloatArray(entry.getKey(), convertToFloatArray(value, type)); + } else if (type == Double.class || type == double.class) { + builder.putDouble(entry.getKey(), (double) value); + } else if (type == Double[].class || type == double[].class) { + builder.putDoubleArray(entry.getKey(), convertToDoubleArray(value, type)); + } else if (type == Boolean.class || type == boolean.class) { + builder.putBoolean(entry.getKey(), (boolean) value); + } else if (type == Boolean[].class || type == boolean[].class) { + builder.putBooleanArray(entry.getKey(), convertToBooleanArray(value, type)); + } else { + Log.w(TAG, "Encountered unexpected type '" + type + "'. Skipping."); + } + } + } + + return builder.build(); + } + + private static @NonNull Map parseWorkManagerDataMap(@NonNull byte[] bytes) throws IllegalStateException { + Map map = new HashMap<>(); + ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes); + ObjectInputStream objectInputStream = null; + + try { + objectInputStream = new ObjectInputStream(inputStream); + + for (int i = objectInputStream.readInt(); i > 0; i--) { + map.put(objectInputStream.readUTF(), objectInputStream.readObject()); + } + } catch (IOException | ClassNotFoundException e) { + Log.w(TAG, "Failed to read WorkManager data.", e); + } finally { + try { + inputStream.close(); + + if (objectInputStream != null) { + objectInputStream.close(); + } + } catch (IOException e) { + Log.e(TAG, "Failed to close streams after reading WorkManager data.", e); + } + } + return map; + } + + private static int[] convertToIntArray(Object value, Class type) { + if (type == int[].class) { + return (int[]) value; + } + + Integer[] casted = (Integer[]) value; + int[] output = new int[casted.length]; + + for (int i = 0; i < casted.length; i++) { + output[i] = casted[i]; + } + + return output; + } + + private static long[] convertToLongArray(Object value, Class type) { + if (type == long[].class) { + return (long[]) value; + } + + Long[] casted = (Long[]) value; + long[] output = new long[casted.length]; + + for (int i = 0; i < casted.length; i++) { + output[i] = casted[i]; + } + + return output; + } + + private static float[] convertToFloatArray(Object value, Class type) { + if (type == float[].class) { + return (float[]) value; + } + + Float[] casted = (Float[]) value; + float[] output = new float[casted.length]; + + for (int i = 0; i < casted.length; i++) { + output[i] = casted[i]; + } + + return output; + } + + private static double[] convertToDoubleArray(Object value, Class type) { + if (type == double[].class) { + return (double[]) value; + } + + Double[] casted = (Double[]) value; + double[] output = new double[casted.length]; + + for (int i = 0; i < casted.length; i++) { + output[i] = casted[i]; + } + + return output; + } + + private static boolean[] convertToBooleanArray(Object value, Class type) { + if (type == boolean[].class) { + return (boolean[]) value; + } + + Boolean[] casted = (Boolean[]) value; + boolean[] output = new boolean[casted.length]; + + for (int i = 0; i < casted.length; i++) { + output[i] = casted[i]; + } + + return output; + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerDatabase.java new file mode 100644 index 00000000..2da4d04e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerDatabase.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.jobmanager.workmanager; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class WorkManagerDatabase extends SQLiteOpenHelper { + + private static final String TAG = WorkManagerDatabase.class.getSimpleName(); + + static final String DB_NAME = "androidx.work.workdb"; + + WorkManagerDatabase(@NonNull Context context) { + super(context, DB_NAME, null, 5); + } + + @Override + public void onCreate(SQLiteDatabase db) { + throw new UnsupportedOperationException("We should never be creating this database, only migrating an existing one!"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // There's a chance that a user who hasn't upgraded in > 6 months could hit this onUpgrade path, + // but we don't use any of the columns that were added in any migrations they could hit, so we + // can ignore this. + Log.w(TAG, "Hit onUpgrade path from " + oldVersion + " to " + newVersion); + } + + @NonNull List getAllJobs(@NonNull Data.Serializer dataSerializer) { + SQLiteDatabase db = getReadableDatabase(); + String[] columns = new String[] { "id", "worker_class_name", "input", "required_network_type"}; + List fullSpecs = new LinkedList<>(); + + try (Cursor cursor = db.query("WorkSpec", columns, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String factoryName = WorkManagerFactoryMappings.getFactoryKey(cursor.getString(cursor.getColumnIndexOrThrow("worker_class_name"))); + + if (factoryName != null) { + String id = cursor.getString(cursor.getColumnIndexOrThrow("id")); + byte[] data = cursor.getBlob(cursor.getColumnIndexOrThrow("input")); + + List constraints = new LinkedList<>(); + JobSpec jobSpec = new JobSpec(id, + factoryName, + getQueueKey(id), + System.currentTimeMillis(), + 0, + 0, + Job.Parameters.UNLIMITED, + TimeUnit.DAYS.toMillis(1), + dataSerializer.serialize(DataMigrator.convert(data)), + null, + false, + false); + + + + if (cursor.getInt(cursor.getColumnIndexOrThrow("required_network_type")) != 0) { + constraints.add(new ConstraintSpec(id, NetworkConstraint.KEY, false)); + } + + fullSpecs.add(new FullSpec(jobSpec, constraints, Collections.emptyList())); + } else { + Log.w(TAG, "Failed to find a matching factory for worker class: " + factoryName); + } + } + } + + return fullSpecs; + } + + private @Nullable String getQueueKey(@NonNull String jobId) { + String query = "work_spec_id = ?"; + String[] args = new String[] { jobId }; + + try (Cursor cursor = getReadableDatabase().query("WorkName", null, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndexOrThrow("name")); + } + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java new file mode 100644 index 00000000..5ed84820 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerFactoryMappings.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.jobmanager.workmanager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; +import org.thoughtcrime.securesms.jobs.AttachmentUploadJob; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV1DownloadJob; +import org.thoughtcrime.securesms.jobs.CleanPreKeysJob; +import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.FailingJob; +import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.jobs.LocalBackupJobApi29; +import org.thoughtcrime.securesms.jobs.MmsDownloadJob; +import org.thoughtcrime.securesms.jobs.MmsReceiveJob; +import org.thoughtcrime.securesms.jobs.MmsSendJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceReadUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.jobs.PushGroupSendJob; +import org.thoughtcrime.securesms.jobs.PushGroupUpdateJob; +import org.thoughtcrime.securesms.jobs.PushMediaSendJob; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; +import org.thoughtcrime.securesms.jobs.PushTextSendJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.jobs.RequestGroupInfoJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.jobs.RotateCertificateJob; +import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; +import org.thoughtcrime.securesms.jobs.RotateSignedPreKeyJob; +import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob; +import org.thoughtcrime.securesms.jobs.SendReadReceiptJob; +import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; +import org.thoughtcrime.securesms.jobs.SmsReceiveJob; +import org.thoughtcrime.securesms.jobs.SmsSendJob; +import org.thoughtcrime.securesms.jobs.SmsSentJob; +import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.jobs.TypingSendJob; +import org.thoughtcrime.securesms.jobs.UpdateApkJob; + +import java.util.HashMap; +import java.util.Map; + +public class WorkManagerFactoryMappings { + + private static final Map FACTORY_MAP = new HashMap() {{ + put("AttachmentDownloadJob", AttachmentDownloadJob.KEY); + put("AttachmentUploadJob", AttachmentUploadJob.KEY); + put("AvatarDownloadJob", AvatarGroupsV1DownloadJob.KEY); + put("CleanPreKeysJob", CleanPreKeysJob.KEY); + put("CreateSignedPreKeyJob", CreateSignedPreKeyJob.KEY); + put("DirectoryRefreshJob", DirectoryRefreshJob.KEY); + put("FcmRefreshJob", FcmRefreshJob.KEY); + put("LocalBackupJob", LocalBackupJob.KEY); + put("LocalBackupJobApi29", LocalBackupJobApi29.KEY); + put("MmsDownloadJob", MmsDownloadJob.KEY); + put("MmsReceiveJob", MmsReceiveJob.KEY); + put("MmsSendJob", MmsSendJob.KEY); + put("MultiDeviceBlockedUpdateJob", MultiDeviceBlockedUpdateJob.KEY); + put("MultiDeviceConfigurationUpdateJob", MultiDeviceConfigurationUpdateJob.KEY); + put("MultiDeviceContactUpdateJob", MultiDeviceContactUpdateJob.KEY); + put("MultiDeviceGroupUpdateJob", MultiDeviceGroupUpdateJob.KEY); + put("MultiDeviceProfileKeyUpdateJob", MultiDeviceProfileKeyUpdateJob.KEY); + put("MultiDeviceReadUpdateJob", MultiDeviceReadUpdateJob.KEY); + put("MultiDeviceVerifiedUpdateJob", MultiDeviceVerifiedUpdateJob.KEY); + put("PushContentReceiveJob", FailingJob.KEY); + put("PushDecryptJob", PushDecryptMessageJob.KEY); + put("PushGroupSendJob", PushGroupSendJob.KEY); + put("PushGroupUpdateJob", PushGroupUpdateJob.KEY); + put("PushMediaSendJob", PushMediaSendJob.KEY); + put("PushNotificationReceiveJob", PushNotificationReceiveJob.KEY); + put("PushTextSendJob", PushTextSendJob.KEY); + put("RefreshAttributesJob", RefreshAttributesJob.KEY); + put("RefreshPreKeysJob", RefreshPreKeysJob.KEY); + put("RefreshUnidentifiedDeliveryAbilityJob", FailingJob.KEY); + put("RequestGroupInfoJob", RequestGroupInfoJob.KEY); + put("RetrieveProfileAvatarJob", RetrieveProfileAvatarJob.KEY); + put("RetrieveProfileJob", RetrieveProfileJob.KEY); + put("RotateCertificateJob", RotateCertificateJob.KEY); + put("RotateProfileKeyJob", RotateProfileKeyJob.KEY); + put("RotateSignedPreKeyJob", RotateSignedPreKeyJob.KEY); + put("SendDeliveryReceiptJob", SendDeliveryReceiptJob.KEY); + put("SendReadReceiptJob", SendReadReceiptJob.KEY); + put("SendViewedReceiptJob", SendViewedReceiptJob.KEY); + put("ServiceOutageDetectionJob", ServiceOutageDetectionJob.KEY); + put("SmsReceiveJob", SmsReceiveJob.KEY); + put("SmsSendJob", SmsSendJob.KEY); + put("SmsSentJob", SmsSentJob.KEY); + put("TrimThreadJob", TrimThreadJob.KEY); + put("TypingSendJob", TypingSendJob.KEY); + put("UpdateApkJob", UpdateApkJob.KEY); + }}; + + public static @Nullable String getFactoryKey(@NonNull String workManagerClass) { + return FACTORY_MAP.get(workManagerClass); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerMigrator.java new file mode 100644 index 00000000..8a3ff14c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/workmanager/WorkManagerMigrator.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.jobmanager.workmanager; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; + +import java.util.List; + +public class WorkManagerMigrator { + + private static final String TAG = Log.tag(WorkManagerMigrator.class); + + @SuppressLint("DefaultLocale") + @WorkerThread + public static synchronized void migrate(@NonNull Context context, + @NonNull JobStorage jobStorage, + @NonNull Data.Serializer dataSerializer) + { + long startTime = System.currentTimeMillis(); + Log.i(TAG, "Beginning WorkManager migration."); + + WorkManagerDatabase database = new WorkManagerDatabase(context); + List fullSpecs = database.getAllJobs(dataSerializer); + + for (FullSpec fullSpec : fullSpecs) { + Log.i(TAG, String.format("Migrating job with key '%s' and %d constraint(s).", fullSpec.getJobSpec().getFactoryKey(), fullSpec.getConstraintSpecs().size())); + } + + jobStorage.insertJobs(fullSpecs); + + context.deleteDatabase(WorkManagerDatabase.DB_NAME); + Log.i(TAG, String.format("WorkManager migration finished. Migrated %d job(s) in %d ms.", fullSpecs.size(), System.currentTimeMillis() - startTime)); + } + + @WorkerThread + public static synchronized boolean needsMigration(@NonNull Context context) { + return context.getDatabasePath(WorkManagerDatabase.DB_NAME).exists(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java new file mode 100644 index 00000000..2e231a1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -0,0 +1,350 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; +import android.media.MediaDataSource; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.google.android.exoplayer2.util.MimeTypes; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.MediaStream; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ImageCompressionUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException; +import org.thoughtcrime.securesms.video.InMemoryTranscoder; +import org.thoughtcrime.securesms.video.StreamingTranscoder; +import org.thoughtcrime.securesms.video.TranscoderCancelationSignal; +import org.thoughtcrime.securesms.video.TranscoderOptions; +import org.thoughtcrime.securesms.video.VideoSourceException; +import org.thoughtcrime.securesms.video.videoconverter.EncodingException; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class AttachmentCompressionJob extends BaseJob { + + public static final String KEY = "AttachmentCompressionJob"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(AttachmentCompressionJob.class); + + private static final String KEY_ROW_ID = "row_id"; + private static final String KEY_UNIQUE_ID = "unique_id"; + private static final String KEY_MMS = "mms"; + private static final String KEY_MMS_SUBSCRIPTION_ID = "mms_subscription_id"; + + private final AttachmentId attachmentId; + private final boolean mms; + private final int mmsSubscriptionId; + + public static AttachmentCompressionJob fromAttachment(@NonNull DatabaseAttachment databaseAttachment, + boolean mms, + int mmsSubscriptionId) + { + return new AttachmentCompressionJob(databaseAttachment.getAttachmentId(), + MediaUtil.isVideo(databaseAttachment) && MediaConstraints.isVideoTranscodeAvailable(), + mms, + mmsSubscriptionId); + } + + private AttachmentCompressionJob(@NonNull AttachmentId attachmentId, + boolean isVideoTranscode, + boolean mms, + int mmsSubscriptionId) + { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue(isVideoTranscode ? "VIDEO_TRANSCODE" : "GENERIC_TRANSCODE") + .build(), + attachmentId, + mms, + mmsSubscriptionId); + } + + private AttachmentCompressionJob(@NonNull Parameters parameters, + @NonNull AttachmentId attachmentId, + boolean mms, + int mmsSubscriptionId) + { + super(parameters); + this.attachmentId = attachmentId; + this.mms = mms; + this.mmsSubscriptionId = mmsSubscriptionId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_ROW_ID, attachmentId.getRowId()) + .putLong(KEY_UNIQUE_ID, attachmentId.getUniqueId()) + .putBoolean(KEY_MMS, mms) + .putInt(KEY_MMS_SUBSCRIPTION_ID, mmsSubscriptionId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + public void onRun() throws Exception { + Log.d(TAG, "Running for: " + attachmentId); + + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment databaseAttachment = database.getAttachment(attachmentId); + + if (databaseAttachment == null) { + throw new UndeliverableMessageException("Cannot find the specified attachment."); + } + + if (databaseAttachment.getTransformProperties().shouldSkipTransform()) { + Log.i(TAG, "Skipping at the direction of the TransformProperties."); + return; + } + + MediaConstraints mediaConstraints = mms ? MediaConstraints.getMmsMediaConstraints(mmsSubscriptionId) + : MediaConstraints.getPushMediaConstraints(); + + compress(database, mediaConstraints, databaseAttachment); + } + + @Override + public void onFailure() { } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof IOException; + } + + private void compress(@NonNull AttachmentDatabase attachmentDatabase, + @NonNull MediaConstraints constraints, + @NonNull DatabaseAttachment attachment) + throws UndeliverableMessageException + { + try { + if (attachment.isSticker()) { + Log.d(TAG, "Sticker, not compressing."); + } else if (MediaUtil.isVideo(attachment)) { + Log.i(TAG, "Compressing video."); + attachment = transcodeVideoIfNeededToDatabase(context, attachmentDatabase, attachment, constraints, EventBus.getDefault(), this::isCanceled); + if (!constraints.isSatisfied(context, attachment)) { + throw new UndeliverableMessageException("Size constraints could not be met on video!"); + } + } else if (constraints.canResize(attachment)) { + Log.i(TAG, "Compressing image."); + MediaStream converted = compressImage(context, attachment, constraints); + attachmentDatabase.updateAttachmentData(attachment, converted, false); + attachmentDatabase.markAttachmentAsTransformed(attachmentId); + } else if (constraints.isSatisfied(context, attachment)) { + Log.i(TAG, "Not compressing."); + attachmentDatabase.markAttachmentAsTransformed(attachmentId); + } else { + throw new UndeliverableMessageException("Size constraints could not be met!"); + } + } catch (IOException | MmsException e) { + throw new UndeliverableMessageException(e); + } + } + + private static @NonNull DatabaseAttachment transcodeVideoIfNeededToDatabase(@NonNull Context context, + @NonNull AttachmentDatabase attachmentDatabase, + @NonNull DatabaseAttachment attachment, + @NonNull MediaConstraints constraints, + @NonNull EventBus eventBus, + @NonNull TranscoderCancelationSignal cancelationSignal) + throws UndeliverableMessageException + { + AttachmentDatabase.TransformProperties transformProperties = attachment.getTransformProperties(); + + boolean allowSkipOnFailure = false; + + if (!MediaConstraints.isVideoTranscodeAvailable()) { + if (transformProperties.isVideoEdited()) { + throw new UndeliverableMessageException("Video edited, but transcode is not available"); + } + return attachment; + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, context.getString(R.string.AttachmentUploadJob_compressing_video_start))) { + + notification.setIndeterminateProgress(); + + try (MediaDataSource dataSource = attachmentDatabase.mediaDataSourceFor(attachment.getAttachmentId())) { + if (dataSource == null) { + throw new UndeliverableMessageException("Cannot get media data source for attachment."); + } + + allowSkipOnFailure = !transformProperties.isVideoEdited(); + TranscoderOptions options = null; + if (transformProperties.isVideoTrim()) { + options = new TranscoderOptions(transformProperties.getVideoTrimStartTimeUs(), transformProperties.getVideoTrimEndTimeUs()); + } + + if (FeatureFlags.useStreamingVideoMuxer() || !MemoryFileDescriptor.supported()) { + StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getCompressedVideoMaxSize(context)); + + if (transcoder.isTranscodeRequired()) { + Log.i(TAG, "Compressing with streaming muxer"); + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + + File file = DatabaseFactory.getAttachmentDatabase(context) + .newFile(); + file.deleteOnExit(); + + try { + try (OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, file, true).second) { + transcoder.transcode(percent -> { + notification.setProgress(100, percent); + eventBus.postSticky(new PartProgressEvent(attachment, + PartProgressEvent.Type.COMPRESSION, + 100, + percent)); + }, outputStream, cancelationSignal); + } + + MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0); + attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited()); + } finally { + if (!file.delete()) { + Log.w(TAG, "Failed to delete temp file"); + } + } + + attachmentDatabase.markAttachmentAsTransformed(attachment.getAttachmentId()); + + return Objects.requireNonNull(attachmentDatabase.getAttachment(attachment.getAttachmentId())); + } else { + Log.i(TAG, "Transcode was not required"); + } + } else { + try (InMemoryTranscoder transcoder = new InMemoryTranscoder(context, dataSource, options, constraints.getCompressedVideoMaxSize(context))) { + if (transcoder.isTranscodeRequired()) { + Log.i(TAG, "Compressing with android in-memory muxer"); + + MediaStream mediaStream = transcoder.transcode(percent -> { + notification.setProgress(100, percent); + eventBus.postSticky(new PartProgressEvent(attachment, + PartProgressEvent.Type.COMPRESSION, + 100, + percent)); + }, cancelationSignal); + + attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited()); + + attachmentDatabase.markAttachmentAsTransformed(attachment.getAttachmentId()); + + return Objects.requireNonNull(attachmentDatabase.getAttachment(attachment.getAttachmentId())); + } else { + Log.i(TAG, "Transcode was not required (in-memory transcoder)"); + } + } + } + } + } catch (VideoSourceException | EncodingException | MemoryFileException e) { + if (attachment.getSize() > constraints.getVideoMaxSize(context)) { + throw new UndeliverableMessageException("Duration not found, attachment too large to skip transcode", e); + } else { + if (allowSkipOnFailure) { + Log.w(TAG, "Problem with video source, but video small enough to skip transcode", e); + } else { + throw new UndeliverableMessageException("Failed to transcode and cannot skip due to editing", e); + } + } + } catch (IOException | MmsException e) { + throw new UndeliverableMessageException("Failed to transcode", e); + } + return attachment; + } + + /** + * Compresses the images. Given that we compress every image, this has the fun side effect of + * stripping all EXIF data. + */ + @WorkerThread + private static MediaStream compressImage(@NonNull Context context, + @NonNull Attachment attachment, + @NonNull MediaConstraints mediaConstraints) + throws UndeliverableMessageException + { + Uri uri = attachment.getUri(); + + if (uri == null) { + throw new UndeliverableMessageException("No attachment URI!"); + } + + ImageCompressionUtil.Result result = null; + + try { + for (int size : mediaConstraints.getImageDimensionTargets(context)) { + result = ImageCompressionUtil.compressWithinConstraints(context, + attachment.getContentType(), + new DecryptableStreamUriLoader.DecryptableUri(uri), + size, + mediaConstraints.getImageMaxSize(context), + 70); + if (result != null) { + break; + } + } + } catch (BitmapDecodingException e) { + throw new UndeliverableMessageException(e); + } + + if (result == null) { + throw new UndeliverableMessageException("Somehow couldn't meet the constraints!"); + } + + return new MediaStream(new ByteArrayInputStream(result.getData()), + result.getMimeType(), + result.getWidth(), + result.getHeight()); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AttachmentCompressionJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AttachmentCompressionJob(parameters, + new AttachmentId(data.getLong(KEY_ROW_ID), data.getLong(KEY_UNIQUE_ID)), + data.getBoolean(KEY_MMS), + data.getInt(KEY_MMS_SUBSCRIPTION_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java new file mode 100644 index 00000000..b1f27968 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Copies the data from one attachment to another. Useful when you only want to send an attachment + * once, and then copy the data from that upload to other messages. + */ +public class AttachmentCopyJob extends BaseJob { + + public static final String KEY = "AttachmentCopyJob"; + + private static final String KEY_SOURCE_ID = "source_id"; + private static final String KEY_DESTINATION_IDS = "destination_ids"; + + private final AttachmentId sourceId; + private final List destinationIds; + + public AttachmentCopyJob(@NonNull AttachmentId sourceId, @NonNull List destinationIds) { + this(new Job.Parameters.Builder() + .setQueue("AttachmentCopyJob") + .setMaxAttempts(3) + .build(), + sourceId, + destinationIds); + } + + private AttachmentCopyJob(@NonNull Parameters parameters, + @NonNull AttachmentId sourceId, + @NonNull List destinationIds) + { + super(parameters); + this.sourceId = sourceId; + this.destinationIds = destinationIds; + } + + @Override + public @NonNull Data serialize() { + try { + String sourceIdString = JsonUtils.toJson(sourceId); + String[] destinationIdStrings = new String[destinationIds.size()]; + + for (int i = 0; i < destinationIds.size(); i++) { + destinationIdStrings[i] = JsonUtils.toJson(destinationIds.get(i)); + } + + return new Data.Builder().putString(KEY_SOURCE_ID, sourceIdString) + .putStringArray(KEY_DESTINATION_IDS, destinationIdStrings) + .build(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + protected void onRun() throws Exception { + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + + for (AttachmentId destinationId : destinationIds) { + database.copyAttachmentData(sourceId, destinationId); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return true; + } + + @Override + public void onFailure() { } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AttachmentCopyJob create(@NonNull Parameters parameters, @NonNull Data data) { + try { + String sourceIdStrings = data.getString(KEY_SOURCE_ID); + String[] destinationIdStrings = data.getStringArray(KEY_DESTINATION_IDS); + + AttachmentId sourceId = JsonUtils.fromJson(sourceIdStrings, AttachmentId.class); + List destinationIds = new ArrayList<>(destinationIdStrings.length); + + for (String idString : destinationIdStrings) { + destinationIds.add(JsonUtils.fromJson(idString, AttachmentId.class)); + } + + return new AttachmentCopyJob(parameters, sourceId, destinationIds); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java new file mode 100644 index 00000000..2ae5f576 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java @@ -0,0 +1,268 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.ContentResolver; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Pair; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.tm.androidcopysdk.DataGrabber; + +import org.archiver.ArchiveConstants; +import org.archiver.ArchiveSender; +import org.archiver.ArchiveUtil; +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobLogger; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.AttachmentUtil; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.RangeException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.TimeUnit; + +public final class AttachmentDownloadJob extends BaseJob { + + public static final String KEY = "AttachmentDownloadJob"; + + private static final int MAX_ATTACHMENT_SIZE = 150 * 1024 * 1024; + private static final String TAG = AttachmentDownloadJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_PART_ROW_ID = "part_row_id"; + private static final String KEY_PAR_UNIQUE_ID = "part_unique_id"; + private static final String KEY_MANUAL = "part_manual"; + + private long messageId; + private long partRowId; + private long partUniqueId; + private boolean manual; + + public AttachmentDownloadJob(long messageId, AttachmentId attachmentId, boolean manual) { + this(new Job.Parameters.Builder() + .setQueue("AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId()) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + messageId, + attachmentId, + manual); + } + + private AttachmentDownloadJob(@NonNull Job.Parameters parameters, long messageId, AttachmentId attachmentId, boolean manual) { + super(parameters); + + this.messageId = messageId; + this.partRowId = attachmentId.getRowId(); + this.partUniqueId = attachmentId.getUniqueId(); + this.manual = manual; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putLong(KEY_PART_ROW_ID, partRowId) + .putLong(KEY_PAR_UNIQUE_ID, partUniqueId) + .putBoolean(KEY_MANUAL, manual) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + Log.i(TAG, "onAdded() messageId: " + messageId + " partRowId: " + partRowId + " partUniqueId: " + partUniqueId + " manual: " + manual); + + final AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId); + final DatabaseAttachment attachment = database.getAttachment(attachmentId); + final boolean pending = attachment != null && attachment.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE; + + if (pending && (manual || AttachmentUtil.isAutoDownloadPermitted(context, attachment))) { + Log.i(TAG, "onAdded() Marking attachment progress as 'started'"); + database.setTransferState(messageId, attachmentId, AttachmentDatabase.TRANSFER_PROGRESS_STARTED); + } + } + + @Override + public void onRun() throws Exception { + doWork(); + ApplicationDependencies.getMessageNotifier().updateNotification(context, 0); + } + + public void doWork() throws IOException, RetryLaterException { + Log.i(TAG, "onRun() messageId: " + messageId + " partRowId: " + partRowId + " partUniqueId: " + partUniqueId + " manual: " + manual); + + final AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId); + final DatabaseAttachment attachment = database.getAttachment(attachmentId); + + if (attachment == null) { + Log.w(TAG, "attachment no longer exists."); + return; + } + + if (!attachment.isInProgress()) { + Log.w(TAG, "Attachment was already downloaded."); + return; + } + + if (!manual && !AttachmentUtil.isAutoDownloadPermitted(context, attachment)) { + Log.w(TAG, "Attachment can't be auto downloaded..."); + database.setTransferState(messageId, attachmentId, AttachmentDatabase.TRANSFER_PROGRESS_PENDING); + return; + } + + Log.i(TAG, "Downloading push part " + attachmentId); + database.setTransferState(messageId, attachmentId, AttachmentDatabase.TRANSFER_PROGRESS_STARTED); + + retrieveAttachment(messageId, attachmentId, attachment); + } + + @Override + public void onFailure() { + Log.w(TAG, JobLogger.format(this, "onFailure() messageId: " + messageId + " partRowId: " + partRowId + " partUniqueId: " + partUniqueId + " manual: " + manual)); + + final AttachmentId attachmentId = new AttachmentId(partRowId, partUniqueId); + markFailed(messageId, attachmentId); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException || + exception instanceof RetryLaterException; + } + + private void retrieveAttachment(long messageId, + final AttachmentId attachmentId, + final Attachment attachment) + throws IOException, RetryLaterException + { + + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + File attachmentFile = database.getOrCreateTransferFile(attachmentId); + + try { + SignalServiceMessageReceiver messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + SignalServiceAttachmentPointer pointer = createAttachmentPointer(attachment); + InputStream stream = messageReceiver.retrieveAttachment(pointer, attachmentFile, MAX_ATTACHMENT_SIZE, (total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress))); + + Pair inputStreamPair = FileUtils.duplicateInputStream(stream); + File tempFileWithData = FileUtils.writeFileOnInternalStorage(context, ArchiveConstants.ARCHIVE_FILE_FOLDER_NAME, ArchiveUtil.Companion.generateAttachmentName(messageId, attachmentId.getUniqueId()) + "." + FileUtils.getExtensionFromMimeType(context, attachment.getContentType()),inputStreamPair.first); + + ArchiveSender.Companion.updateArchiveSDKToSendMMSMessage(context, tempFileWithData.getName(), false); + + database.insertAttachmentsForPlaceholder(messageId, attachmentId, inputStreamPair.second); + + } catch (RangeException e) { + Log.w(TAG, "Range exception, file size " + attachmentFile.length(), e); + if (attachmentFile.delete()) { + Log.i(TAG, "Deleted temp download file to recover"); + throw new RetryLaterException(e); + } else { + throw new IOException("Failed to delete temp download file following range exception"); + } + } catch (InvalidPartException | NonSuccessfulResponseCodeException | InvalidMessageException | MmsException | MissingConfigurationException e) { + Log.w(TAG, "Experienced exception while trying to download an attachment.", e); + markFailed(messageId, attachmentId); + } + } + + private SignalServiceAttachmentPointer createAttachmentPointer(Attachment attachment) throws InvalidPartException { + if (TextUtils.isEmpty(attachment.getLocation())) { + throw new InvalidPartException("empty content id"); + } + + if (TextUtils.isEmpty(attachment.getKey())) { + throw new InvalidPartException("empty encrypted key"); + } + + try { + final SignalServiceAttachmentRemoteId remoteId = SignalServiceAttachmentRemoteId.from(attachment.getLocation()); + final byte[] key = Base64.decode(attachment.getKey()); + + if (attachment.getDigest() != null) { + Log.i(TAG, "Downloading attachment with digest: " + Hex.toString(attachment.getDigest())); + } else { + Log.i(TAG, "Downloading attachment with no digest..."); + } + + return new SignalServiceAttachmentPointer(attachment.getCdnNumber(), remoteId, null, key, + Optional.of(Util.toIntExact(attachment.getSize())), + Optional.absent(), + 0, 0, + Optional.fromNullable(attachment.getDigest()), + Optional.fromNullable(attachment.getFileName()), + attachment.isVoiceNote(), + attachment.isBorderless(), + Optional.absent(), + Optional.fromNullable(attachment.getBlurHash()).transform(BlurHash::getHash), + attachment.getUploadTimestamp()); + } catch (IOException | ArithmeticException e) { + Log.w(TAG, e); + throw new InvalidPartException(e); + } + } + + private void markFailed(long messageId, AttachmentId attachmentId) { + try { + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + database.setTransferProgressFailed(attachmentId, messageId); + } catch (MmsException e) { + Log.w(TAG, e); + } + } + + @VisibleForTesting static class InvalidPartException extends Exception { + InvalidPartException(String s) {super(s);} + InvalidPartException(Exception e) {super(e);} + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AttachmentDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AttachmentDownloadJob(parameters, + data.getLong(KEY_MESSAGE_ID), + new AttachmentId(data.getLong(KEY_PART_ROW_ID), data.getLong(KEY_PAR_UNIQUE_ID)), + data.getBoolean(KEY_MANUAL)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentMarkUploadedJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentMarkUploadedJob.java new file mode 100644 index 00000000..2771592c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentMarkUploadedJob.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Only marks an attachment as uploaded. + */ +public final class AttachmentMarkUploadedJob extends BaseJob { + + public static final String KEY = "AttachmentMarkUploadedJob"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(AttachmentMarkUploadedJob.class); + + private static final String KEY_ROW_ID = "row_id"; + private static final String KEY_UNIQUE_ID = "unique_id"; + private static final String KEY_MESSAGE_ID = "message_id"; + + private final AttachmentId attachmentId; + private final long messageId; + + public AttachmentMarkUploadedJob(long messageId, @NonNull AttachmentId attachmentId) { + this(new Parameters.Builder() + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + messageId, + attachmentId); + } + + private AttachmentMarkUploadedJob(@NonNull Parameters parameters, long messageId, @NonNull AttachmentId attachmentId) { + super(parameters); + this.attachmentId = attachmentId; + this.messageId = messageId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_ROW_ID, attachmentId.getRowId()) + .putLong(KEY_UNIQUE_ID, attachmentId.getUniqueId()) + .putLong(KEY_MESSAGE_ID, messageId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws Exception { + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment databaseAttachment = database.getAttachment(attachmentId); + + if (databaseAttachment == null) { + throw new InvalidAttachmentException("Cannot find the specified attachment."); + } + + database.markAttachmentUploaded(messageId, databaseAttachment); + } + + @Override + public void onFailure() { + } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof IOException; + } + + private class InvalidAttachmentException extends Exception { + InvalidAttachmentException(String message) { + super(message); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AttachmentMarkUploadedJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AttachmentMarkUploadedJob(parameters, + data.getLong(KEY_MESSAGE_ID), + new AttachmentId(data.getLong(KEY_ROW_ID), data.getLong(KEY_UNIQUE_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java new file mode 100644 index 00000000..00b2d842 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.jobs; + +import android.graphics.Bitmap; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.attachments.PointerAttachment; +import org.thoughtcrime.securesms.blurhash.BlurHashEncoder; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException; +import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Uploads an attachment without alteration. + *

+ * Queue {@link AttachmentCompressionJob} before to compress. + */ +public final class AttachmentUploadJob extends BaseJob { + + public static final String KEY = "AttachmentUploadJobV2"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(AttachmentUploadJob.class); + + private static final long UPLOAD_REUSE_THRESHOLD = TimeUnit.DAYS.toMillis(3); + + private static final String KEY_ROW_ID = "row_id"; + private static final String KEY_UNIQUE_ID = "unique_id"; + + /** + * Foreground notification shows while uploading attachments above this. + */ + private static final int FOREGROUND_LIMIT = 10 * 1024 * 1024; + + private final AttachmentId attachmentId; + + public AttachmentUploadJob(AttachmentId attachmentId) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + attachmentId); + } + + private AttachmentUploadJob(@NonNull Job.Parameters parameters, @NonNull AttachmentId attachmentId) { + super(parameters); + this.attachmentId = attachmentId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_ROW_ID, attachmentId.getRowId()) + .putLong(KEY_UNIQUE_ID, attachmentId.getUniqueId()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + public void onRun() throws Exception { + Data inputData = getInputData(); + + ResumableUploadSpec resumableUploadSpec; + + if (inputData != null && inputData.hasString(ResumableUploadSpecJob.KEY_RESUME_SPEC)) { + Log.d(TAG, "Using attachments V3"); + resumableUploadSpec = ResumableUploadSpec.deserialize(inputData.getString(ResumableUploadSpecJob.KEY_RESUME_SPEC)); + } else { + Log.d(TAG, "Using attachments V2"); + resumableUploadSpec = null; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment databaseAttachment = database.getAttachment(attachmentId); + + if (databaseAttachment == null) { + throw new InvalidAttachmentException("Cannot find the specified attachment."); + } + + long timeSinceUpload = System.currentTimeMillis() - databaseAttachment.getUploadTimestamp(); + if (timeSinceUpload < UPLOAD_REUSE_THRESHOLD && !TextUtils.isEmpty(databaseAttachment.getLocation())) { + Log.i(TAG, "We can re-use an already-uploaded file. It was uploaded " + timeSinceUpload + " ms ago. Skipping."); + return; + } else if (databaseAttachment.getUploadTimestamp() > 0) { + Log.i(TAG, "This file was previously-uploaded, but too long ago to be re-used. Age: " + timeSinceUpload + " ms"); + } + + Log.i(TAG, "Uploading attachment for message " + databaseAttachment.getMmsId() + " with ID " + databaseAttachment.getAttachmentId()); + + try (NotificationController notification = getNotificationForAttachment(databaseAttachment)) { + SignalServiceAttachment localAttachment = getAttachmentFor(databaseAttachment, notification, resumableUploadSpec); + SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment.asStream()); + Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.getFastPreflightId()).get(); + + database.updateAttachmentAfterUpload(databaseAttachment.getAttachmentId(), attachment, remoteAttachment.getUploadTimestamp()); + } + } + + private @Nullable NotificationController getNotificationForAttachment(@NonNull Attachment attachment) { + if (attachment.getSize() >= FOREGROUND_LIMIT) { + return GenericForegroundService.startForegroundTask(context, context.getString(R.string.AttachmentUploadJob_uploading_media)); + } else { + return null; + } + } + + @Override + public void onFailure() { + if (isCanceled()) { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof ResumeLocationInvalidException) return false; + + return exception instanceof IOException; + } + + private @NonNull SignalServiceAttachment getAttachmentFor(Attachment attachment, @Nullable NotificationController notification, @Nullable ResumableUploadSpec resumableUploadSpec) throws InvalidAttachmentException { + try { + if (attachment.getUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!"); + InputStream is = PartAuthority.getAttachmentStream(context, attachment.getUri()); + SignalServiceAttachment.Builder builder = SignalServiceAttachment.newStreamBuilder() + .withStream(is) + .withContentType(attachment.getContentType()) + .withLength(attachment.getSize()) + .withFileName(attachment.getFileName()) + .withVoiceNote(attachment.isVoiceNote()) + .withBorderless(attachment.isBorderless()) + .withWidth(attachment.getWidth()) + .withHeight(attachment.getHeight()) + .withUploadTimestamp(System.currentTimeMillis()) + .withCaption(attachment.getCaption()) + .withCancelationSignal(this::isCanceled) + .withResumableUploadSpec(resumableUploadSpec) + .withListener((total, progress) -> { + EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress)); + if (notification != null) { + notification.setProgress(total, progress); + } + }); + if (MediaUtil.isImageType(attachment.getContentType())) { + return builder.withBlurHash(getImageBlurHash(attachment)).build(); + } else if (MediaUtil.isVideoType(attachment.getContentType())) { + return builder.withBlurHash(getVideoBlurHash(attachment)).build(); + } else { + return builder.build(); + } + + } catch (IOException ioe) { + throw new InvalidAttachmentException(ioe); + } + } + + private @Nullable String getImageBlurHash(@NonNull Attachment attachment) throws IOException { + if (attachment.getBlurHash() != null) return attachment.getBlurHash().getHash(); + if (attachment.getUri() == null) return null; + + return BlurHashEncoder.encode(PartAuthority.getAttachmentStream(context, attachment.getUri())); + } + + private @Nullable String getVideoBlurHash(@NonNull Attachment attachment) throws IOException { + if (attachment.getBlurHash() != null) { + return attachment.getBlurHash().getHash(); + } + + if (Build.VERSION.SDK_INT < 23) { + Log.w(TAG, "Video thumbnails not supported..."); + return null; + } + + Bitmap bitmap = MediaUtil.getVideoThumbnail(context, Objects.requireNonNull(attachment.getUri()), 1000); + + if (bitmap != null) { + Bitmap thumb = Bitmap.createScaledBitmap(bitmap, 100, 100, false); + bitmap.recycle(); + + Log.i(TAG, "Generated video thumbnail..."); + String hash = BlurHashEncoder.encode(thumb); + thumb.recycle(); + + return hash; + } else { + return null; + } + } + + private class InvalidAttachmentException extends Exception { + InvalidAttachmentException(String message) { + super(message); + } + + InvalidAttachmentException(Exception e) { + super(e); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AttachmentUploadJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { + return new AttachmentUploadJob(parameters, new AttachmentId(data.getLong(KEY_ROW_ID), data.getLong(KEY_UNIQUE_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AutomaticSessionResetJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AutomaticSessionResetJob.java new file mode 100644 index 00000000..1bb4eb94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AutomaticSessionResetJob.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.SessionUtil; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * - Archives the session associated with the specified device + * - Inserts an error message in the conversation + * - Sends a new, empty message to trigger a fresh session with the specified device + * + * This will only be run when all decryptions have finished, and there can only be one enqueued + * per websocket drain cycle. + */ +public class AutomaticSessionResetJob extends BaseJob { + + private static final String TAG = Log.tag(AutomaticSessionResetJob.class); + + public static final String KEY = "AutomaticSessionResetJob"; + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + private static final String KEY_DEVICE_ID = "device_id"; + private static final String KEY_SENT_TIMESTAMP = "sent_timestamp"; + + private final RecipientId recipientId; + private final int deviceId; + private final long sentTimestamp; + + public AutomaticSessionResetJob(@NonNull RecipientId recipientId, int deviceId, long sentTimestamp) { + this(new Parameters.Builder() + .setQueue(PushProcessMessageJob.getQueueName(recipientId)) + .addConstraint(DecryptionsDrainedConstraint.KEY) + .setMaxInstancesForQueue(1) + .build(), + recipientId, + deviceId, + sentTimestamp); + } + + private AutomaticSessionResetJob(@NonNull Parameters parameters, + @NonNull RecipientId recipientId, + int deviceId, + long sentTimestamp) + { + super(parameters); + this.recipientId = recipientId; + this.deviceId = deviceId; + this.sentTimestamp = sentTimestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .putInt(KEY_DEVICE_ID, deviceId) + .putLong(KEY_SENT_TIMESTAMP, sentTimestamp) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + SessionUtil.archiveSession(context, recipientId, deviceId); + insertLocalMessage(); + + if (FeatureFlags.automaticSessionReset()) { + long resetInterval = TimeUnit.SECONDS.toMillis(FeatureFlags.automaticSessionResetIntervalSeconds()); + DeviceLastResetTime resetTimes = DatabaseFactory.getRecipientDatabase(context).getLastSessionResetTimes(recipientId); + long timeSinceLastReset = System.currentTimeMillis() - getLastResetTime(resetTimes, deviceId); + + Log.i(TAG, "DeviceId: " + deviceId + ", Reset interval: " + resetInterval + ", Time since last reset: " + timeSinceLastReset); + + if (timeSinceLastReset > resetInterval) { + Log.i(TAG, "We're good! Sending a null message."); + + DatabaseFactory.getRecipientDatabase(context).setLastSessionResetTime(recipientId, setLastResetTime(resetTimes, deviceId, System.currentTimeMillis())); + Log.i(TAG, "Marked last reset time: " + System.currentTimeMillis()); + + sendNullMessage(); + Log.i(TAG, "Successfully sent!"); + } else { + Log.w(TAG, "Too soon! Time since last reset: " + timeSinceLastReset); + } + } else { + Log.w(TAG, "Automatic session reset send disabled!"); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + private void insertLocalMessage() { + MessageDatabase.InsertResult result = DatabaseFactory.getSmsDatabase(context).insertDecryptionFailedMessage(recipientId, deviceId, sentTimestamp); + ApplicationDependencies.getMessageNotifier().updateNotification(context, result.getThreadId()); + } + + private void sendNullMessage() throws IOException { + Recipient recipient = Recipient.resolved(recipientId); + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient); + Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient); + + try { + messageSender.sendNullMessage(address, unidentifiedAccess); + } catch (UntrustedIdentityException e) { + Log.w(TAG, "Unable to send null message."); + } + } + + private long getLastResetTime(@NonNull DeviceLastResetTime resetTimes, int deviceId) { + for (DeviceLastResetTime.Pair pair : resetTimes.getResetTimeList()) { + if (pair.getDeviceId() == deviceId) { + return pair.getLastResetTime(); + } + } + return 0; + } + + private @NonNull DeviceLastResetTime setLastResetTime(@NonNull DeviceLastResetTime resetTimes, int deviceId, long time) { + DeviceLastResetTime.Builder builder = DeviceLastResetTime.newBuilder(); + + for (DeviceLastResetTime.Pair pair : resetTimes.getResetTimeList()) { + if (pair.getDeviceId() != deviceId) { + builder.addResetTime(pair); + } + } + + builder.addResetTime(DeviceLastResetTime.Pair.newBuilder().setDeviceId(deviceId).setLastResetTime(time)); + + return builder.build(); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AutomaticSessionResetJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AutomaticSessionResetJob(parameters, + RecipientId.from(data.getString(KEY_RECIPIENT_ID)), + data.getInt(KEY_DEVICE_ID), + data.getLong(KEY_SENT_TIMESTAMP)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java new file mode 100644 index 00000000..b517f034 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +public final class AvatarGroupsV1DownloadJob extends BaseJob { + + public static final String KEY = "AvatarDownloadJob"; + + private static final String TAG = Log.tag(AvatarGroupsV1DownloadJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + + @NonNull private final GroupId.V1 groupId; + + public AvatarGroupsV1DownloadJob(@NonNull GroupId.V1 groupId) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build(), + groupId); + } + + private AvatarGroupsV1DownloadJob(@NonNull Job.Parameters parameters, @NonNull GroupId.V1 groupId) { + super(parameters); + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + Optional record = database.getGroup(groupId); + File attachment = null; + + try { + if (record.isPresent()) { + long avatarId = record.get().getAvatarId(); + String contentType = record.get().getAvatarContentType(); + byte[] key = record.get().getAvatarKey(); + String relay = record.get().getRelay(); + Optional digest = Optional.fromNullable(record.get().getAvatarDigest()); + Optional fileName = Optional.absent(); + + if (avatarId == -1 || key == null) { + return; + } + + if (digest.isPresent()) { + Log.i(TAG, "Downloading group avatar with digest: " + Hex.toString(digest.get())); + } + + attachment = File.createTempFile("avatar", "tmp", context.getCacheDir()); + attachment.deleteOnExit(); + + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + SignalServiceAttachmentPointer pointer = new SignalServiceAttachmentPointer(0, new SignalServiceAttachmentRemoteId(avatarId), contentType, key, Optional.of(0), Optional.absent(), 0, 0, digest, fileName, false, false, Optional.absent(), Optional.absent(), System.currentTimeMillis()); + InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, AvatarHelper.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); + + AvatarHelper.setAvatar(context, record.get().getRecipientId(), inputStream); + DatabaseFactory.getGroupDatabase(context).onAvatarUpdated(groupId, true); + + inputStream.close(); + } + } catch (NonSuccessfulResponseCodeException | InvalidMessageException | MissingConfigurationException e) { + Log.w(TAG, e); + } finally { + if (attachment != null) + attachment.delete(); + } + } + + @Override + public void onFailure() {} + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof IOException) return true; + return false; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AvatarGroupsV1DownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AvatarGroupsV1DownloadJob(parameters, GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV1()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java new file mode 100644 index 00000000..f94a0f4b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.util.ByteUnit; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +public final class AvatarGroupsV2DownloadJob extends BaseJob { + + public static final String KEY = "AvatarGroupsV2DownloadJob"; + + private static final String TAG = Log.tag(AvatarGroupsV2DownloadJob.class); + + private static final long AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE = ByteUnit.MEGABYTES.toBytes(5); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String CDN_KEY = "cdn_key"; + + private final GroupId.V2 groupId; + private final String cdnKey; + + public AvatarGroupsV2DownloadJob(@NonNull GroupId.V2 groupId, @NonNull String cdnKey) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("AvatarGroupsV2DownloadJob::" + groupId) + .setMaxAttempts(10) + .build(), + groupId, + cdnKey); + } + + private AvatarGroupsV2DownloadJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, @NonNull String cdnKey) { + super(parameters); + this.groupId = groupId; + this.cdnKey = cdnKey; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putString(KEY_GROUP_ID, groupId.toString()) + .putString(CDN_KEY, cdnKey) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + GroupDatabase database = DatabaseFactory.getGroupDatabase(context); + Optional record = database.getGroup(groupId); + File attachment = null; + + try { + if (!record.isPresent()) { + Log.w(TAG, "Cannot download avatar for unknown group"); + return; + } + + if (cdnKey.length() == 0) { + Log.w(TAG, "Removing avatar for group " + groupId); + AvatarHelper.setAvatar(context, record.get().getRecipientId(), null); + database.onAvatarUpdated(groupId, false); + return; + } + + Log.i(TAG, "Downloading new avatar for group " + groupId); + byte[] decryptedAvatar = downloadGroupAvatarBytes(context, record.get().requireV2GroupProperties().getGroupMasterKey(), cdnKey); + + AvatarHelper.setAvatar(context, record.get().getRecipientId(), decryptedAvatar != null ? new ByteArrayInputStream(decryptedAvatar) : null); + database.onAvatarUpdated(groupId, true); + + } catch (NonSuccessfulResponseCodeException e) { + Log.w(TAG, e); + } + } + + public static @Nullable byte[] downloadGroupAvatarBytes(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey, + @NonNull String cdnKey) + throws IOException + { + if (cdnKey.length() == 0) { + return null; + } + + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + File attachment = File.createTempFile("avatar", "gv2", context.getCacheDir()); + attachment.deleteOnExit(); + + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + byte[] encryptedData; + + try (FileInputStream inputStream = receiver.retrieveGroupsV2ProfileAvatar(cdnKey, attachment, AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE)) { + encryptedData = new byte[(int) attachment.length()]; + + StreamUtil.readFully(inputStream, encryptedData); + + GroupsV2Operations operations = ApplicationDependencies.getGroupsV2Operations(); + GroupsV2Operations.GroupOperations groupOperations = operations.forGroup(groupSecretParams); + + return groupOperations.decryptAvatar(encryptedData); + } finally { + if (attachment.exists()) + if (!attachment.delete()) { + Log.w(TAG, "Unable to delete temp avatar file"); + } + } + } + + @Override + public void onFailure() {} + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof IOException; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull AvatarGroupsV2DownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AvatarGroupsV2DownloadJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2(), + data.getString(CDN_KEY)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java new file mode 100644 index 00000000..7564bb12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.core.util.tracing.Tracer; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobLogger; +import org.thoughtcrime.securesms.jobmanager.JobManager.Chain; +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; + +public abstract class BaseJob extends Job { + + private static final String TAG = BaseJob.class.getSimpleName(); + + private Data outputData; + + public BaseJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Result run() { + if (shouldTrace()) { + Tracer.getInstance().start(getClass().getSimpleName()); + } + + try { + onRun(); + return Result.success(outputData); + } catch (RuntimeException e) { + Log.e(TAG, "Encountered a fatal exception. Crash imminent.", e); + return Result.fatalFailure(e); + } catch (Exception e) { + if (onShouldRetry(e)) { + Log.i(TAG, JobLogger.format(this, "Encountered a retryable exception."), e); + return Result.retry(getNextRunAttemptBackoff(getRunAttempt() + 1, e)); + } else { + Log.w(TAG, JobLogger.format(this, "Encountered a failing exception."), e); + return Result.failure(); + } + } finally { + if (shouldTrace()) { + Tracer.getInstance().end(getClass().getSimpleName()); + } + } + } + + /** + * Should return how long you'd like to wait until the next retry, given the attempt count and + * exception that caused the retry. The attempt count is the number of attempts that have been + * made already, so this value will be at least 1. + * + * There is a sane default implementation here that uses exponential backoff, but jobs can + * override this behavior to define custom backoff behavior. + */ + public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) { + return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getDefaultMaxBackoff()); + } + + protected abstract void onRun() throws Exception; + + protected abstract boolean onShouldRetry(@NonNull Exception e); + + /** + * Whether or not the job should be traced with the {@link org.signal.core.util.tracing.Tracer}. + */ + protected boolean shouldTrace() { + return false; + } + + /** + * If this job is part of a {@link Chain}, data set here will be passed as input data to the next + * job(s) in the chain. + */ + protected void setOutputData(@Nullable Data outputData) { + this.outputData = outputData; + } + + protected void log(@NonNull String tag, @NonNull String message) { + Log.i(tag, JobLogger.format(this, message)); + } + + protected void log(@NonNull String tag, @NonNull String extra, @NonNull String message) { + Log.i(tag, JobLogger.format(this, extra, message)); + } + + protected void warn(@NonNull String tag, @NonNull String message) { + warn(tag, "", message, null); + } + + protected void warn(@NonNull String tag, @NonNull String event, @NonNull String message) { + warn(tag, event, message, null); + } + + protected void warn(@NonNull String tag, @Nullable Throwable t) { + warn(tag, "", t); + } + + protected void warn(@NonNull String tag, @NonNull String message, @Nullable Throwable t) { + warn(tag, "", message, t); + } + + protected void warn(@NonNull String tag, @NonNull String extra, @NonNull String message, @Nullable Throwable t) { + Log.w(tag, JobLogger.format(this, extra, message), t); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CleanPreKeysJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/CleanPreKeysJob.java new file mode 100644 index 00000000..c0fd9b2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CleanPreKeysJob.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.PreKeyUtil; +import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.whispersystems.libsignal.InvalidKeyIdException; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyStore; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class CleanPreKeysJob extends BaseJob { + + public static final String KEY = "CleanPreKeysJob"; + + private static final String TAG = CleanPreKeysJob.class.getSimpleName(); + + private static final long ARCHIVE_AGE = TimeUnit.DAYS.toMillis(30); + + public CleanPreKeysJob() { + this(new Job.Parameters.Builder() + .setQueue("CleanPreKeysJob") + .setMaxAttempts(5) + .build()); + } + + private CleanPreKeysJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + try { + Log.i(TAG, "Cleaning prekeys..."); + + int activeSignedPreKeyId = PreKeyUtil.getActiveSignedPreKeyId(context); + SignedPreKeyStore signedPreKeyStore = new SignalProtocolStoreImpl(context); + + if (activeSignedPreKeyId < 0) return; + + SignedPreKeyRecord currentRecord = signedPreKeyStore.loadSignedPreKey(activeSignedPreKeyId); + List allRecords = signedPreKeyStore.loadSignedPreKeys(); + LinkedList oldRecords = removeRecordFrom(currentRecord, allRecords); + + Collections.sort(oldRecords, new SignedPreKeySorter()); + + Log.i(TAG, "Active signed prekey: " + activeSignedPreKeyId); + Log.i(TAG, "Old signed prekey record count: " + oldRecords.size()); + + boolean foundAgedRecord = false; + + for (SignedPreKeyRecord oldRecord : oldRecords) { + long archiveDuration = System.currentTimeMillis() - oldRecord.getTimestamp(); + + if (archiveDuration >= ARCHIVE_AGE) { + if (!foundAgedRecord) { + foundAgedRecord = true; + } else { + Log.i(TAG, "Removing signed prekey record: " + oldRecord.getId() + " with timestamp: " + oldRecord.getTimestamp()); + signedPreKeyStore.removeSignedPreKey(oldRecord.getId()); + } + } + } + } catch (InvalidKeyIdException e) { + Log.w(TAG, e); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception throwable) { + if (throwable instanceof NonSuccessfulResponseCodeException) return false; + if (throwable instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to execute clean signed prekeys task."); + } + + private LinkedList removeRecordFrom(SignedPreKeyRecord currentRecord, + List records) + + { + LinkedList others = new LinkedList<>(); + + for (SignedPreKeyRecord record : records) { + if (record.getId() != currentRecord.getId()) { + others.add(record); + } + } + + return others; + } + + private static class SignedPreKeySorter implements Comparator { + @Override + public int compare(SignedPreKeyRecord lhs, SignedPreKeyRecord rhs) { + if (lhs.getTimestamp() > rhs.getTimestamp()) return -1; + else if (lhs.getTimestamp() < rhs.getTimestamp()) return 1; + else return 0; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull CleanPreKeysJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new CleanPreKeysJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ClearFallbackKbsEnclaveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ClearFallbackKbsEnclaveJob.java new file mode 100644 index 00000000..7e8e8b82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ClearFallbackKbsEnclaveJob.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.KbsEnclave; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.pin.KbsEnclaves; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Clears data from an old KBS enclave. + */ +public class ClearFallbackKbsEnclaveJob extends BaseJob { + + public static final String KEY = "ClearFallbackKbsEnclaveJob"; + + private static final String TAG = Log.tag(ClearFallbackKbsEnclaveJob.class); + + private static final String KEY_ENCLAVE_NAME = "enclaveName"; + private static final String KEY_SERVICE_ID = "serviceId"; + private static final String KEY_MR_ENCLAVE = "mrEnclave"; + + private final KbsEnclave enclave; + + ClearFallbackKbsEnclaveJob(@NonNull KbsEnclave enclave) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(90)) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue("ClearFallbackKbsEnclaveJob") + .build(), + enclave); + } + + public static void clearAll() { + if (KbsEnclaves.fallbacks().isEmpty()) { + Log.i(TAG, "No fallbacks!"); + return; + } + + JobManager jobManager = ApplicationDependencies.getJobManager(); + + for (KbsEnclave enclave : KbsEnclaves.fallbacks()) { + jobManager.add(new ClearFallbackKbsEnclaveJob(enclave)); + } + } + + private ClearFallbackKbsEnclaveJob(@NonNull Parameters parameters, @NonNull KbsEnclave enclave) { + super(parameters); + this.enclave = enclave; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_ENCLAVE_NAME, enclave.getEnclaveName()) + .putString(KEY_SERVICE_ID, enclave.getServiceId()) + .putString(KEY_MR_ENCLAVE, enclave.getMrEnclave()) + .build(); + } + + @Override + public void onRun() throws IOException, UnauthenticatedResponseException { + Log.i(TAG, "Preparing to delete data from " + enclave.getEnclaveName()); + ApplicationDependencies.getKeyBackupService(enclave).newPinChangeSession().removePin(); + Log.i(TAG, "Successfully deleted the data from " + enclave.getEnclaveName()); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return true; + } + + @Override + public void onFailure() { + throw new AssertionError("This job should never fail. " + getClass().getSimpleName()); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull ClearFallbackKbsEnclaveJob create(@NonNull Parameters parameters, @NonNull Data data) { + KbsEnclave enclave = new KbsEnclave(data.getString(KEY_ENCLAVE_NAME), + data.getString(KEY_SERVICE_ID), + data.getString(KEY_MR_ENCLAVE)); + + return new ClearFallbackKbsEnclaveJob(parameters, enclave); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ConversationShortcutUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ConversationShortcutUpdateJob.java new file mode 100644 index 00000000..1736c0f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ConversationShortcutUpdateJob.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.jobs; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.thoughtcrime.securesms.util.ConversationUtil.CONVERSATION_SUPPORT_VERSION; + +/** + * On some devices, interacting with the ShortcutManager can take a very long time (several seconds). + * So, we interact with it in a job instead, and keep it in one queue so it can't starve the other + * job runners. + */ +public class ConversationShortcutUpdateJob extends BaseJob { + + private static final String TAG = Log.tag(ConversationShortcutUpdateJob.class); + + public static final String KEY = "ConversationShortcutUpdateJob"; + + public static void enqueue() { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + ApplicationDependencies.getJobManager().add(new ConversationShortcutUpdateJob()); + } + } + + private ConversationShortcutUpdateJob() { + this(new Parameters.Builder() + .setQueue("ConversationShortcutUpdateJob") + .setLifespan(TimeUnit.MINUTES.toMillis(15)) + .setMaxInstancesForFactory(1) + .build()); + } + + private ConversationShortcutUpdateJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + protected void onRun() throws Exception { + if (TextSecurePreferences.isScreenLockEnabled(context)) { + Log.i(TAG, "Screen lock enabled. Clearing shortcuts."); + ConversationUtil.clearAllShortcuts(context); + return; + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + int maxShortcuts = ConversationUtil.getMaxShortcuts(context); + List ranked = new ArrayList<>(maxShortcuts); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(maxShortcuts, false, false))) { + ThreadRecord record; + while ((record = reader.getNext()) != null) { + ranked.add(record.getRecipient().resolve()); + } + } + + boolean success = ConversationUtil.setActiveShortcuts(context, ranked); + + if (!success) { + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull ConversationShortcutUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ConversationShortcutUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateSignedPreKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateSignedPreKeyJob.java new file mode 100644 index 00000000..b4baf776 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateSignedPreKeyJob.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.PreKeyUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + +public class CreateSignedPreKeyJob extends BaseJob { + + public static final String KEY = "CreateSignedPreKeyJob"; + + private static final String TAG = CreateSignedPreKeyJob.class.getSimpleName(); + + public CreateSignedPreKeyJob(Context context) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("CreateSignedPreKeyJob") + .setMaxAttempts(25) + .build()); + } + + private CreateSignedPreKeyJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + if (TextSecurePreferences.isSignedPreKeyRegistered(context)) { + Log.w(TAG, "Signed prekey already registered..."); + return; + } + + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.w(TAG, "Not yet registered..."); + return; + } + + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(context); + SignedPreKeyRecord signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(context, identityKeyPair, true); + + accountManager.setSignedPreKey(signedPreKeyRecord); + TextSecurePreferences.setSignedPreKeyRegistered(context, true); + } + + @Override + public void onFailure() {} + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull CreateSignedPreKeyJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new CreateSignedPreKeyJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java new file mode 100644 index 00000000..980f8195 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + +public class DirectoryRefreshJob extends BaseJob { + + public static final String KEY = "DirectoryRefreshJob"; + + private static final String TAG = DirectoryRefreshJob.class.getSimpleName(); + + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_NOTIFY_OF_NEW_USERS = "notify_of_new_users"; + + @Nullable private Recipient recipient; + private boolean notifyOfNewUsers; + + public DirectoryRefreshJob(boolean notifyOfNewUsers) { + this(null, notifyOfNewUsers); + } + + public DirectoryRefreshJob(@Nullable Recipient recipient, + boolean notifyOfNewUsers) + { + this(new Job.Parameters.Builder() + .setQueue(StorageSyncJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build(), + recipient, + notifyOfNewUsers); + } + + private DirectoryRefreshJob(@NonNull Job.Parameters parameters, @Nullable Recipient recipient, boolean notifyOfNewUsers) { + super(parameters); + + this.recipient = recipient; + this.notifyOfNewUsers = notifyOfNewUsers; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT, recipient != null ? recipient.getId().serialize() : null) + .putBoolean(KEY_NOTIFY_OF_NEW_USERS, notifyOfNewUsers) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + public void onRun() throws IOException { + Log.i(TAG, "DirectoryRefreshJob.onRun()"); + + if (recipient == null) { + DirectoryHelper.refreshDirectory(context, notifyOfNewUsers); + } else { + DirectoryHelper.refreshDirectoryFor(context, recipient, notifyOfNewUsers); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() {} + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull DirectoryRefreshJob create(@NonNull Parameters parameters, @NonNull Data data) { + String serialized = data.hasString(KEY_RECIPIENT) ? data.getString(KEY_RECIPIENT) : null; + Recipient recipient = serialized != null ? Recipient.resolved(RecipientId.from(serialized)) : null; + boolean notifyOfNewUsers = data.getBoolean(KEY_NOTIFY_OF_NEW_USERS); + + return new DirectoryRefreshJob(parameters, recipient, notifyOfNewUsers); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FailingJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FailingJob.java new file mode 100644 index 00000000..d75ceaba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FailingJob.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +/** + * A job that always fails. Not useful on it's own, but you can register it's factory for jobs that + * have been removed that you'd like to fail instead of keeping around. + */ +public final class FailingJob extends Job { + + public static final String KEY = "FailingJob"; + + private FailingJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @NonNull + @Override + public String getFactoryKey() { + return KEY; + } + + @Override + public @NonNull Result run() { + return Result.failure(); + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull FailingJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new FailingJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java new file mode 100644 index 00000000..e6b07c1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java @@ -0,0 +1,396 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.JobDatabase; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; +import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Set; + +public class FastJobStorage implements JobStorage { + + private static final String TAG = Log.tag(FastJobStorage.class); + + private final JobDatabase jobDatabase; + + private final List jobs; + private final Map> constraintsByJobId; + private final Map> dependenciesByJobId; + + public FastJobStorage(@NonNull JobDatabase jobDatabase) { + this.jobDatabase = jobDatabase; + this.jobs = new ArrayList<>(); + this.constraintsByJobId = new HashMap<>(); + this.dependenciesByJobId = new HashMap<>(); + } + + @Override + public synchronized void init() { + List jobSpecs = jobDatabase.getAllJobSpecs(); + List constraintSpecs = jobDatabase.getAllConstraintSpecs(); + List dependencySpecs = jobDatabase.getAllDependencySpecs(); + + jobs.addAll(jobSpecs); + + for (ConstraintSpec constraintSpec: constraintSpecs) { + List jobConstraints = Util.getOrDefault(constraintsByJobId, constraintSpec.getJobSpecId(), new LinkedList<>()); + jobConstraints.add(constraintSpec); + constraintsByJobId.put(constraintSpec.getJobSpecId(), jobConstraints); + } + + for (DependencySpec dependencySpec : dependencySpecs) { + List jobDependencies = Util.getOrDefault(dependenciesByJobId, dependencySpec.getJobId(), new LinkedList<>()); + jobDependencies.add(dependencySpec); + dependenciesByJobId.put(dependencySpec.getJobId(), jobDependencies); + } + } + + @Override + public synchronized void insertJobs(@NonNull List fullSpecs) { + List durable = Stream.of(fullSpecs).filterNot(FullSpec::isMemoryOnly).toList(); + if (durable.size() > 0) { + jobDatabase.insertJobs(durable); + } + + for (FullSpec fullSpec : fullSpecs) { + jobs.add(fullSpec.getJobSpec()); + constraintsByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getConstraintSpecs()); + dependenciesByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getDependencySpecs()); + } + } + + @Override + public synchronized @Nullable JobSpec getJobSpec(@NonNull String id) { + for (JobSpec jobSpec : jobs) { + if (jobSpec.getId().equals(id)) { + return jobSpec; + } + } + return null; + } + + @Override + public synchronized @NonNull List getAllJobSpecs() { + return new ArrayList<>(jobs); + } + + @Override + public synchronized @NonNull List getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime) { + Optional migrationJob = getMigrationJob(); + + if (migrationJob.isPresent() && !migrationJob.get().isRunning() && migrationJob.get().getNextRunAttemptTime() <= currentTime) { + return Collections.singletonList(migrationJob.get()); + } else if (migrationJob.isPresent()) { + return Collections.emptyList(); + } else { + return Stream.of(jobs) + .groupBy(jobSpec -> { + String queueKey = jobSpec.getQueueKey(); + if (queueKey != null) { + return queueKey; + } else { + return jobSpec.getId(); + } + }) + .map(byQueueKey -> + Stream.of(byQueueKey.getValue()).sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) + .findFirst() + .orElse(null) + ) + .withoutNulls() + .filter(j -> { + List dependencies = dependenciesByJobId.get(j.getId()); + return dependencies == null || dependencies.isEmpty(); + }) + .filterNot(JobSpec::isRunning) + .filter(j -> j.getNextRunAttemptTime() <= currentTime) + .sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) + .toList(); + } + } + + @Override + public synchronized @NonNull List getJobsInQueue(@NonNull String queue) { + return Stream.of(jobs) + .filter(j -> queue.equals(j.getQueueKey())) + .sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) + .toList(); + } + + private Optional getMigrationJob() { + return Optional.fromNullable(Stream.of(jobs) + .filter(j -> Job.Parameters.MIGRATION_QUEUE_KEY.equals(j.getQueueKey())) + .filter(this::firstInQueue) + .findFirst() + .orElse(null)); + } + + private boolean firstInQueue(@NonNull JobSpec job) { + if (job.getQueueKey() == null) { + return true; + } + + return Stream.of(jobs) + .filter(j -> Util.equals(j.getQueueKey(), job.getQueueKey())) + .sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) + .toList() + .get(0) + .equals(job); + } + + @Override + public synchronized int getJobCountForFactory(@NonNull String factoryKey) { + return (int) Stream.of(jobs) + .filter(j -> j.getFactoryKey().equals(factoryKey)) + .count(); + } + + @Override + public synchronized int getJobCountForFactoryAndQueue(@NonNull String factoryKey, @NonNull String queueKey) { + return (int) Stream.of(jobs) + .filter(j -> factoryKey.equals(j.getFactoryKey()) && + queueKey.equals(j.getQueueKey())) + .count(); + } + + @Override + public boolean areQueuesEmpty(@NonNull Set queueKeys) { + return Stream.of(jobs) + .noneMatch(j -> j.getQueueKey() != null && queueKeys.contains(j.getQueueKey())); + } + + @Override + public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) { + JobSpec job = getJobById(id); + if (job == null || !job.isMemoryOnly()) { + jobDatabase.updateJobRunningState(id, isRunning); + } + + ListIterator iter = jobs.listIterator(); + + while (iter.hasNext()) { + JobSpec existing = iter.next(); + if (existing.getId().equals(id)) { + JobSpec updated = new JobSpec(existing.getId(), + existing.getFactoryKey(), + existing.getQueueKey(), + existing.getCreateTime(), + existing.getNextRunAttemptTime(), + existing.getRunAttempt(), + existing.getMaxAttempts(), + existing.getLifespan(), + existing.getSerializedData(), + existing.getSerializedInputData(), + isRunning, + existing.isMemoryOnly()); + iter.set(updated); + } + } + } + + @Override + public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime, @NonNull String serializedData) { + JobSpec job = getJobById(id); + if (job == null || !job.isMemoryOnly()) { + jobDatabase.updateJobAfterRetry(id, isRunning, runAttempt, nextRunAttemptTime, serializedData); + } + + ListIterator iter = jobs.listIterator(); + + while (iter.hasNext()) { + JobSpec existing = iter.next(); + if (existing.getId().equals(id)) { + JobSpec updated = new JobSpec(existing.getId(), + existing.getFactoryKey(), + existing.getQueueKey(), + existing.getCreateTime(), + nextRunAttemptTime, + runAttempt, + existing.getMaxAttempts(), + existing.getLifespan(), + serializedData, + existing.getSerializedInputData(), + isRunning, + existing.isMemoryOnly()); + iter.set(updated); + } + } + } + + @Override + public synchronized void updateAllJobsToBePending() { + jobDatabase.updateAllJobsToBePending(); + + ListIterator iter = jobs.listIterator(); + + while (iter.hasNext()) { + JobSpec existing = iter.next(); + JobSpec updated = new JobSpec(existing.getId(), + existing.getFactoryKey(), + existing.getQueueKey(), + existing.getCreateTime(), + existing.getNextRunAttemptTime(), + existing.getRunAttempt(), + existing.getMaxAttempts(), + existing.getLifespan(), + existing.getSerializedData(), + existing.getSerializedInputData(), + false, + existing.isMemoryOnly()); + iter.set(updated); + } + } + + @Override + public void updateJobs(@NonNull List jobSpecs) { + List durable = new ArrayList<>(jobSpecs.size()); + for (JobSpec update : jobSpecs) { + JobSpec found = getJobById(update.getId()); + if (found == null || !found.isMemoryOnly()) { + durable.add(update); + } + } + + if (durable.size() > 0) { + jobDatabase.updateJobs(durable); + } + + Map updates = Stream.of(jobSpecs).collect(Collectors.toMap(JobSpec::getId)); + ListIterator iter = jobs.listIterator(); + + while (iter.hasNext()) { + JobSpec existing = iter.next(); + JobSpec update = updates.get(existing.getId()); + + if (update != null) { + iter.set(update); + } + } + } + + @Override + public synchronized void deleteJob(@NonNull String jobId) { + deleteJobs(Collections.singletonList(jobId)); + } + + @Override + public synchronized void deleteJobs(@NonNull List jobIds) { + List durableIds = new ArrayList<>(jobIds.size()); + for (String id : jobIds) { + JobSpec job = getJobById(id); + if (job == null || !job.isMemoryOnly()) { + durableIds.add(id); + } + } + + if (durableIds.size() > 0) { + jobDatabase.deleteJobs(durableIds); + } + + Set deleteIds = new HashSet<>(jobIds); + + Iterator jobIter = jobs.iterator(); + while (jobIter.hasNext()) { + if (deleteIds.contains(jobIter.next().getId())) { + jobIter.remove(); + } + } + + for (String jobId : jobIds) { + constraintsByJobId.remove(jobId); + dependenciesByJobId.remove(jobId); + + for (Map.Entry> entry : dependenciesByJobId.entrySet()) { + Iterator depedencyIter = entry.getValue().iterator(); + + while (depedencyIter.hasNext()) { + if (depedencyIter.next().getDependsOnJobId().equals(jobId)) { + depedencyIter.remove(); + } + } + } + } + } + + @Override + public synchronized @NonNull List getConstraintSpecs(@NonNull String jobId) { + return Util.getOrDefault(constraintsByJobId, jobId, new LinkedList<>()); + } + + @Override + public synchronized @NonNull List getAllConstraintSpecs() { + return Stream.of(constraintsByJobId) + .map(Map.Entry::getValue) + .flatMap(Stream::of) + .toList(); + } + + @Override + public synchronized @NonNull List getDependencySpecsThatDependOnJob(@NonNull String jobSpecId) { + List layer = getSingleLayerOfDependencySpecsThatDependOnJob(jobSpecId); + List all = new ArrayList<>(layer); + + Set activeJobIds; + + do { + activeJobIds = Stream.of(layer).map(DependencySpec::getJobId).collect(Collectors.toSet()); + layer.clear(); + + for (String activeJobId : activeJobIds) { + layer.addAll(getSingleLayerOfDependencySpecsThatDependOnJob(activeJobId)); + } + + all.addAll(layer); + } while (!layer.isEmpty()); + + return all; + } + + private @NonNull List getSingleLayerOfDependencySpecsThatDependOnJob(@NonNull String jobSpecId) { + return Stream.of(dependenciesByJobId.entrySet()) + .map(Map.Entry::getValue) + .flatMap(Stream::of) + .filter(j -> j.getDependsOnJobId().equals(jobSpecId)) + .toList(); + } + + @Override + public @NonNull List getAllDependencySpecs() { + return Stream.of(dependenciesByJobId) + .map(Map.Entry::getValue) + .flatMap(Stream::of) + .toList(); + } + + private JobSpec getJobById(@NonNull String id) { + for (JobSpec job : jobs) { + if (job.getId().equals(id)) { + return job; + } + } + Log.w(TAG, "Was looking for job with ID JOB::" + id + ", but it doesn't exist in memory!"); + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java new file mode 100644 index 00000000..e16f1b78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java @@ -0,0 +1,147 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobs; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PlayServicesProblemActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.gcm.FcmUtil; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class FcmRefreshJob extends BaseJob { + + public static final String KEY = "FcmRefreshJob"; + + private static final String TAG = FcmRefreshJob.class.getSimpleName(); + + public FcmRefreshJob() { + this(new Job.Parameters.Builder() + .setQueue("FcmRefreshJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(1) + .setLifespan(TimeUnit.MINUTES.toMillis(5)) + .setMaxInstancesForFactory(1) + .build()); + } + + private FcmRefreshJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws Exception { + if (TextSecurePreferences.isFcmDisabled(context)) return; + + Log.i(TAG, "Reregistering FCM..."); + + int result = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + + if (result != ConnectionResult.SUCCESS) { + notifyFcmFailure(); + } else { + Optional token = FcmUtil.getToken(); + + if (token.isPresent()) { + String oldToken = TextSecurePreferences.getFcmToken(context); + + if (!token.get().equals(oldToken)) { + int oldLength = oldToken != null ? oldToken.length() : -1; + Log.i(TAG, "Token changed. oldLength: " + oldLength + " newLength: " + token.get().length()); + } else { + Log.i(TAG, "Token didn't change."); + } + + ApplicationDependencies.getSignalServiceAccountManager().setGcmId(token); + TextSecurePreferences.setFcmToken(context, token.get()); + TextSecurePreferences.setFcmTokenLastSetTime(context, System.currentTimeMillis()); + TextSecurePreferences.setWebsocketRegistered(context, true); + } else { + throw new RetryLaterException(new IOException("Failed to retrieve a token.")); + } + } + } + + @Override + public void onFailure() { + Log.w(TAG, "GCM reregistration failed after retry attempt exhaustion!"); + } + + @Override + public boolean onShouldRetry(@NonNull Exception throwable) { + if (throwable instanceof NonSuccessfulResponseCodeException) return false; + return true; + } + + private void notifyFcmFailure() { + Intent intent = new Intent(context, PlayServicesProblemActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 1122, intent, PendingIntent.FLAG_CANCEL_CURRENT); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.FAILURES); + + builder.setSmallIcon(R.drawable.ic_notification); + builder.setLargeIcon(BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_action_warning_red)); + builder.setContentTitle(context.getString(R.string.GcmRefreshJob_Permanent_Signal_communication_failure)); + builder.setContentText(context.getString(R.string.GcmRefreshJob_Signal_was_unable_to_register_with_Google_Play_Services)); + builder.setTicker(context.getString(R.string.GcmRefreshJob_Permanent_Signal_communication_failure)); + builder.setVibrate(new long[] {0, 1000}); + builder.setContentIntent(pendingIntent); + + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(NotificationIds.FCM_FAILURE, builder.build()); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull FcmRefreshJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new FcmRefreshJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekJob.java new file mode 100644 index 00000000..7c436962 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekJob.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; +import org.thoughtcrime.securesms.recipients.RecipientId; + +/** + * Allows the enqueueing of one peek operation per group while the web socket is not drained. + */ +public final class GroupCallPeekJob extends BaseJob { + + public static final String KEY = "GroupCallPeekJob"; + + private static final String QUEUE = "__GroupCallPeekJob__"; + + private static final String KEY_GROUP_RECIPIENT_ID = "group_recipient_id"; + + @NonNull private final RecipientId groupRecipientId; + + public static void enqueue(@NonNull RecipientId groupRecipientId) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + String queue = QUEUE + groupRecipientId.serialize(); + Parameters.Builder parameters = new Parameters.Builder() + .setQueue(queue) + .addConstraint(DecryptionsDrainedConstraint.KEY); + + jobManager.cancelAllInQueue(queue); + + jobManager.add(new GroupCallPeekJob(parameters.build(), groupRecipientId)); + } + + private GroupCallPeekJob(@NonNull Parameters parameters, + @NonNull RecipientId groupRecipientId) + { + super(parameters); + this.groupRecipientId = groupRecipientId; + } + + @Override + protected void onRun() { + ApplicationDependencies.getJobManager().add(new GroupCallPeekWorkerJob(groupRecipientId)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putString(KEY_GROUP_RECIPIENT_ID, groupRecipientId.serialize()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull GroupCallPeekJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new GroupCallPeekJob(parameters, RecipientId.from(data.getString(KEY_GROUP_RECIPIENT_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekWorkerJob.java new file mode 100644 index 00000000..8b3cb7e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallPeekWorkerJob.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Intent; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; + +/** + * Runs in the same queue as messages for the group. + */ +final class GroupCallPeekWorkerJob extends BaseJob { + + public static final String KEY = "GroupCallPeekWorkerJob"; + + private static final String KEY_GROUP_RECIPIENT_ID = "group_recipient_id"; + + @NonNull private final RecipientId groupRecipientId; + + public GroupCallPeekWorkerJob(@NonNull RecipientId groupRecipientId) { + this(new Parameters.Builder() + .setQueue(PushProcessMessageJob.getQueueName(groupRecipientId)) + .setMaxInstancesForQueue(2) + .build(), + groupRecipientId); + } + + private GroupCallPeekWorkerJob(@NonNull Parameters parameters, @NonNull RecipientId groupRecipientId) { + super(parameters); + this.groupRecipientId = groupRecipientId; + } + + @Override + protected void onRun() { + Intent intent = new Intent(context, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_GROUP_CALL_PEEK) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(groupRecipientId)); + + context.startService(intent); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putString(KEY_GROUP_RECIPIENT_ID, groupRecipientId.serialize()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull GroupCallPeekWorkerJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new GroupCallPeekWorkerJob(parameters, RecipientId.from(data.getString(KEY_GROUP_RECIPIENT_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java new file mode 100644 index 00000000..ade00459 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Send a group call update message to every one in a V2 group. Used to indicate you + * have joined or left a call. + */ +public class GroupCallUpdateSendJob extends BaseJob { + + public static final String KEY = "GroupCallUpdateSendJob"; + + private static final String TAG = Log.tag(GroupCallUpdateSendJob.class); + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + private static final String KEY_ERA_ID = "era_id"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final RecipientId recipientId; + private final String eraId; + private final List recipients; + private final int initialRecipientCount; + + @WorkerThread + public static @NonNull GroupCallUpdateSendJob create(@NonNull RecipientId recipientId, @Nullable String eraId) { + Recipient conversationRecipient = Recipient.resolved(recipientId); + + if (!conversationRecipient.isPushV2Group()) { + throw new AssertionError("We have a recipient, but it's not a V2 Group"); + } + + List recipients = Stream.of(RecipientUtil.getEligibleForSending(conversationRecipient.getParticipants())) + .filterNot(Recipient::isSelf) + .filterNot(Recipient::isBlocked) + .map(Recipient::getId) + .toList(); + + return new GroupCallUpdateSendJob(recipientId, + eraId, + recipients, + recipients.size(), + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.MINUTES.toMillis(5)) + .setMaxAttempts(3) + .build()); + } + + private GroupCallUpdateSendJob(@NonNull RecipientId recipientId, + @NonNull String eraId, + @NonNull List recipients, + int initialRecipientCount, + @NonNull Parameters parameters) + { + super(parameters); + + this.recipientId = recipientId; + this.eraId = eraId; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .putString(KEY_ERA_ID, eraId) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + Recipient conversationRecipient = Recipient.resolved(recipientId); + + if (!conversationRecipient.isPushV2Group()) { + throw new AssertionError("We have a recipient, but it's not a V2 Group"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + if (recipients.size() < initialRecipientCount) { + Log.w(TAG, "Only sent a group update to " + recipients.size() + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays."); + return; + } + + Log.w(TAG, "Failed to send the group update to all recipients!"); + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations);; + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withGroupCallUpdate(new SignalServiceDataMessage.GroupCallUpdate(eraId)); + + if (conversationRecipient.isGroup()) { + GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull + GroupCallUpdateSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + RecipientId recipientId = RecipientId.from(data.getString(KEY_RECIPIENT_ID)); + String eraId = data.getString(KEY_ERA_ID); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + + return new GroupCallUpdateSendJob(recipientId, eraId, recipients, initialRecipientCount, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupSendJobHelper.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupSendJobHelper.java new file mode 100644 index 00000000..7eeeb757 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupSendJobHelper.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.messages.SendMessageResult; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +final class GroupSendJobHelper { + + private static final String TAG = Log.tag(GroupSendJobHelper.class); + + private GroupSendJobHelper() { + } + + static List getCompletedSends(@NonNull Context context, @NonNull Collection results) { + List completions = new ArrayList<>(results.size()); + + for (SendMessageResult sendMessageResult : results) { + Recipient recipient = Recipient.externalPush(context, sendMessageResult.getAddress()); + + if (sendMessageResult.getIdentityFailure() != null) { + Log.w(TAG, "Identity failure for " + recipient.getId()); + } + + if (sendMessageResult.isUnregisteredFailure()) { + Log.w(TAG, "Unregistered failure for " + recipient.getId()); + } + + if (sendMessageResult.getSuccess() != null || + sendMessageResult.getIdentityFailure() != null || + sendMessageResult.isUnregisteredFailure()) + { + completions.add(recipient); + } + } + + return completions; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java new file mode 100644 index 00000000..836872e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Application; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class GroupV1MigrationJob extends BaseJob { + + private static final String TAG = Log.tag(GroupV1MigrationJob.class); + + public static final String KEY = "GroupV1MigrationJob"; + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + + private static final int ROUTINE_LIMIT = 20; + private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(1); + + private final RecipientId recipientId; + + private GroupV1MigrationJob(@NonNull RecipientId recipientId) { + this(new Parameters.Builder() + .setQueue(recipientId.toQueueKey()) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(7)) + .addConstraint(NetworkConstraint.KEY) + .build(), + recipientId); + } + + private GroupV1MigrationJob(@NonNull Parameters parameters, @NonNull RecipientId recipientId) { + super(parameters); + this.recipientId = recipientId; + } + + public static void enqueuePossibleAutoMigrate(@NonNull RecipientId recipientId) { + SignalExecutors.BOUNDED.execute(() -> { + if (Recipient.resolved(recipientId).isPushV1Group()) { + ApplicationDependencies.getJobManager().add(new GroupV1MigrationJob(recipientId)); + } + }); + } + + public static void enqueueRoutineMigrationsIfNecessary(@NonNull Application application) { + if (!SignalStore.registrationValues().isRegistrationComplete() || + !TextSecurePreferences.isPushRegistered(application) || + TextSecurePreferences.getLocalUuid(application) == null) + { + Log.i(TAG, "Registration not complete. Skipping."); + return; + } + + long timeSinceRefresh = System.currentTimeMillis() - SignalStore.misc().getLastGv1RoutineMigrationTime(); + + if (timeSinceRefresh < REFRESH_INTERVAL) { + Log.i(TAG, "Too soon to refresh. Did the last refresh " + timeSinceRefresh + " ms ago."); + return; + } + + SignalStore.misc().setLastGv1RoutineMigrationTime(System.currentTimeMillis()); + + SignalExecutors.BOUNDED.execute(() -> { + JobManager jobManager = ApplicationDependencies.getJobManager(); + List threads = DatabaseFactory.getThreadDatabase(application).getRecentV1Groups(ROUTINE_LIMIT); + Set needsRefresh = new HashSet<>(); + + if (threads.size() > 0) { + Log.d(TAG, "About to enqueue refreshes for " + threads.size() + " groups."); + } + + for (ThreadRecord thread : threads) { + jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId())); + + needsRefresh.addAll(Stream.of(thread.getRecipient().getParticipants()) + .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED) + .map(Recipient::getId) + .toList()); + } + + if (needsRefresh.size() > 0) { + Log.w(TAG, "Enqueuing profile refreshes for " + needsRefresh.size() + " GV1 participants."); + RetrieveProfileJob.enqueue(needsRefresh); + } + }); + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, GroupChangeBusyException, RetryLaterException { + try { + GroupsV1MigrationUtil.migrate(context, recipientId, false); + } catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) { + Log.w(TAG, "Invalid migration state. Skipping."); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull GroupV1MigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new GroupV1MigrationJob(parameters, RecipientId.from(data.getString(KEY_RECIPIENT_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java new file mode 100644 index 00000000..ce825395 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * When your profile key changes, this job can be used to update it on a single given group. + *

+ * Your membership is confirmed first, so safe to run against any known {@link GroupId.V2} + */ +public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob { + + public static final String KEY = "GroupV2UpdateSelfProfileKeyJob"; + + private static final String QUEUE = "GroupV2UpdateSelfProfileKeyJob"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(GroupV2UpdateSelfProfileKeyJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + + private final GroupId.V2 groupId; + + public GroupV2UpdateSelfProfileKeyJob(@NonNull GroupId.V2 groupId) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue(QUEUE) + .build(), + groupId); + } + + private GroupV2UpdateSelfProfileKeyJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId) { + super(parameters); + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() + throws IOException, GroupNotAMemberException, GroupChangeFailedException, GroupInsufficientRightsException, GroupChangeBusyException + { + Log.i(TAG, "Ensuring profile key up to date on group " + groupId); + GroupManager.updateSelfProfileKeyInGroup(context, groupId); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException|| + e instanceof GroupChangeBusyException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull GroupV2UpdateSelfProfileKeyJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new GroupV2UpdateSelfProfileKeyJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java new file mode 100644 index 00000000..c2e1e151 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Application; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Constraint; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobMigration; +import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.migrations.PushDecryptMessageJobEnvelopeMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.PushProcessMessageQueueJobMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration2; +import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdJobMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.RetrieveProfileJobMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.SendReadReceiptsJobMigration; +import org.thoughtcrime.securesms.migrations.AttributesMigrationJob; +import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob; +import org.thoughtcrime.securesms.migrations.AvatarMigrationJob; +import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob; +import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob; +import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; +import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob; +import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob; +import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob; +import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; +import org.thoughtcrime.securesms.migrations.MigrationCompleteJob; +import org.thoughtcrime.securesms.migrations.PassingMigrationJob; +import org.thoughtcrime.securesms.migrations.PinOptOutMigration; +import org.thoughtcrime.securesms.migrations.PinReminderMigrationJob; +import org.thoughtcrime.securesms.migrations.ProfileMigrationJob; +import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob; +import org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob; +import org.thoughtcrime.securesms.migrations.StickerAdditionMigrationJob; +import org.thoughtcrime.securesms.migrations.StickerDayByDayMigrationJob; +import org.thoughtcrime.securesms.migrations.StickerLaunchMigrationJob; +import org.thoughtcrime.securesms.migrations.StorageCapabilityMigrationJob; +import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob; +import org.thoughtcrime.securesms.migrations.TrimByLengthSettingsMigrationJob; +import org.thoughtcrime.securesms.migrations.UserNotificationMigrationJob; +import org.thoughtcrime.securesms.migrations.UuidMigrationJob; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class JobManagerFactories { + + public static Map getJobFactories(@NonNull Application application) { + return new HashMap() {{ + put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory()); + put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory()); + put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory()); + put(AttachmentMarkUploadedJob.KEY, new AttachmentMarkUploadedJob.Factory()); + put(AttachmentCompressionJob.KEY, new AttachmentCompressionJob.Factory()); + put(AutomaticSessionResetJob.KEY, new AutomaticSessionResetJob.Factory()); + put(AvatarGroupsV1DownloadJob.KEY, new AvatarGroupsV1DownloadJob.Factory()); + put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory()); + put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory()); + put(ClearFallbackKbsEnclaveJob.KEY, new ClearFallbackKbsEnclaveJob.Factory()); + put(ConversationShortcutUpdateJob.KEY, new ConversationShortcutUpdateJob.Factory()); + put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory()); + put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); + put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); + put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); + put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); + put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory()); + put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory()); + put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory()); + put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); + put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); + put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory()); + put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); + put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); + put(MmsSendJob.KEY, new MmsSendJob.Factory()); + put(MultiDeviceBlockedUpdateJob.KEY, new MultiDeviceBlockedUpdateJob.Factory()); + put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory()); + put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory()); + put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory()); + put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory()); + put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory()); + put(MultiDeviceProfileContentUpdateJob.KEY, new MultiDeviceProfileContentUpdateJob.Factory()); + put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory()); + put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory()); + put(MultiDeviceStickerPackOperationJob.KEY, new MultiDeviceStickerPackOperationJob.Factory()); + put(MultiDeviceStickerPackSyncJob.KEY, new MultiDeviceStickerPackSyncJob.Factory()); + put(MultiDeviceStorageSyncRequestJob.KEY, new MultiDeviceStorageSyncRequestJob.Factory()); + put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory()); + put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory()); + put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory()); + put(PushDecryptMessageJob.KEY, new PushDecryptMessageJob.Factory()); + put(PushDecryptDrainedJob.KEY, new PushDecryptDrainedJob.Factory()); + put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); + put(PushGroupSendJob.KEY, new PushGroupSendJob.Factory()); + put(PushGroupSilentUpdateSendJob.KEY, new PushGroupSilentUpdateSendJob.Factory()); + put(PushGroupUpdateJob.KEY, new PushGroupUpdateJob.Factory()); + put(PushMediaSendJob.KEY, new PushMediaSendJob.Factory()); + put(PushNotificationReceiveJob.KEY, new PushNotificationReceiveJob.Factory()); + put(PushTextSendJob.KEY, new PushTextSendJob.Factory()); + put(ReactionSendJob.KEY, new ReactionSendJob.Factory()); + put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory()); + put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory()); + put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); + put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory()); + put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory()); + put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); + put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory()); + put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); + put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory()); + put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory()); + put(GroupV2UpdateSelfProfileKeyJob.KEY, new GroupV2UpdateSelfProfileKeyJob.Factory()); + put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); + put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); + put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); + put(RotateProfileKeyJob.KEY, new RotateProfileKeyJob.Factory()); + put(RotateSignedPreKeyJob.KEY, new RotateSignedPreKeyJob.Factory()); + put(SendDeliveryReceiptJob.KEY, new SendDeliveryReceiptJob.Factory()); + put(SendReadReceiptJob.KEY, new SendReadReceiptJob.Factory(application)); + put(SendViewedReceiptJob.KEY, new SendViewedReceiptJob.Factory(application)); + put(ServiceOutageDetectionJob.KEY, new ServiceOutageDetectionJob.Factory()); + put(SmsReceiveJob.KEY, new SmsReceiveJob.Factory()); + put(SmsSendJob.KEY, new SmsSendJob.Factory()); + put(SmsSentJob.KEY, new SmsSentJob.Factory()); + put(StickerDownloadJob.KEY, new StickerDownloadJob.Factory()); + put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory()); + put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory()); + put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); + put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); + put(TypingSendJob.KEY, new TypingSendJob.Factory()); + put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); + put(MarkerJob.KEY, new MarkerJob.Factory()); + put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); + + // Migrations + put(AttributesMigrationJob.KEY, new AttributesMigrationJob.Factory()); + put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory()); + put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory()); + put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory()); + put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); + put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); + put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); + put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); + put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory()); + put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); + put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory()); + put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory()); + put(PinReminderMigrationJob.KEY, new PinReminderMigrationJob.Factory()); + put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory()); + put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory()); + put(RegistrationPinV2MigrationJob.KEY, new RegistrationPinV2MigrationJob.Factory()); + put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory()); + put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory()); + put(StickerDayByDayMigrationJob.KEY, new StickerDayByDayMigrationJob.Factory()); + put(StorageCapabilityMigrationJob.KEY, new StorageCapabilityMigrationJob.Factory()); + put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory()); + put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory()); + put(UserNotificationMigrationJob.KEY, new UserNotificationMigrationJob.Factory()); + put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory()); + + // Dead jobs + put(FailingJob.KEY, new FailingJob.Factory()); + put(PassingMigrationJob.KEY, new PassingMigrationJob.Factory()); + put("PushContentReceiveJob", new FailingJob.Factory()); + put("AttachmentUploadJob", new FailingJob.Factory()); + put("MmsSendJob", new FailingJob.Factory()); + put("RefreshUnidentifiedDeliveryAbilityJob", new FailingJob.Factory()); + put("Argon2TestJob", new FailingJob.Factory()); + put("Argon2TestMigrationJob", new PassingMigrationJob.Factory()); + put("StorageKeyRotationMigrationJob", new PassingMigrationJob.Factory()); + put("WakeGroupV2Job", new FailingJob.Factory()); + }}; + } + + public static Map getConstraintFactories(@NonNull Application application) { + return new HashMap() {{ + put(ChargingConstraint.KEY, new ChargingConstraint.Factory()); + put(NetworkConstraint.KEY, new NetworkConstraint.Factory(application)); + put(NetworkOrCellServiceConstraint.KEY, new NetworkOrCellServiceConstraint.Factory(application)); + put(NetworkOrCellServiceConstraint.LEGACY_KEY, new NetworkOrCellServiceConstraint.Factory(application)); + put(SqlCipherMigrationConstraint.KEY, new SqlCipherMigrationConstraint.Factory(application)); + put(DecryptionsDrainedConstraint.KEY, new DecryptionsDrainedConstraint.Factory()); + put(NotInCallConstraint.KEY, new NotInCallConstraint.Factory()); + }}; + } + + public static List getConstraintObservers(@NonNull Application application) { + return Arrays.asList(CellServiceConstraintObserver.getInstance(application), + new ChargingConstraintObserver(application), + new NetworkConstraintObserver(application), + new SqlCipherMigrationConstraintObserver(), + new DecryptionsDrainedConstraintObserver(), + new NotInCallConstraintObserver()); + } + + public static List getJobMigrations(@NonNull Application application) { + return Arrays.asList(new RecipientIdJobMigration(application), + new RecipientIdFollowUpJobMigration(), + new RecipientIdFollowUpJobMigration2(), + new SendReadReceiptsJobMigration(DatabaseFactory.getMmsSmsDatabase(application)), + new PushProcessMessageQueueJobMigration(application), + new RetrieveProfileJobMigration(), + new PushDecryptMessageJobEnvelopeMigration(application)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/KbsEnclaveMigrationWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/KbsEnclaveMigrationWorkerJob.java new file mode 100644 index 00000000..927c73fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/KbsEnclaveMigrationWorkerJob.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob; +import org.thoughtcrime.securesms.pin.PinState; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; + +/** + * Should only be enqueued by {@link KbsEnclaveMigrationJob}. Does the actual work of migrating KBS + * data to the new enclave and deleting it from the old enclave(s). + */ +public class KbsEnclaveMigrationWorkerJob extends BaseJob { + + public static final String KEY = "KbsEnclaveMigrationWorkerJob"; + + private static final String TAG = Log.tag(KbsEnclaveMigrationWorkerJob.class); + + public KbsEnclaveMigrationWorkerJob() { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(Parameters.IMMORTAL) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue("KbsEnclaveMigrationWorkerJob") + .setMaxInstancesForFactory(1) + .build()); + } + + private KbsEnclaveMigrationWorkerJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public void onRun() throws IOException, UnauthenticatedResponseException { + String pin = SignalStore.kbsValues().getPin(); + + if (SignalStore.kbsValues().hasOptedOut()) { + Log.w(TAG, "Opted out of KBS! Nothing to migrate."); + return; + } + + if (pin == null) { + Log.w(TAG, "No PIN available! Can't migrate!"); + return; + } + + PinState.onMigrateToNewEnclave(pin); + Log.i(TAG, "Migration successful!"); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || + e instanceof UnauthenticatedResponseException; + } + + @Override + public void onFailure() { + throw new AssertionError("This job should never fail. " + getClass().getSimpleName()); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull KbsEnclaveMigrationWorkerJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new KbsEnclaveMigrationWorkerJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java new file mode 100644 index 00000000..bc5e50a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Normally, we can do group leaves via {@link PushGroupSendJob}. However, that job relies on a + * message being present in the database, which is not true if the user selects a message request + * option that deletes and leaves at the same time. + * + * This job tracks all send state within the job and does not require a message in the database to + * work. + */ +public class LeaveGroupJob extends BaseJob { + + public static final String KEY = "LeaveGroupJob"; + + private static final String TAG = Log.tag(LeaveGroupJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_GROUP_NAME = "name"; + private static final String KEY_MEMBERS = "members"; + private static final String KEY_RECIPIENTS = "recipients"; + + private final GroupId.Push groupId; + private final String name; + private final List members; + private final List recipients; + + public static @NonNull LeaveGroupJob create(@NonNull Recipient group) { + List members = Stream.of(group.resolve().getParticipants()).map(Recipient::getId).toList(); + members.remove(Recipient.self().getId()); + + return new LeaveGroupJob(group.getGroupId().get().requirePush(), + group.resolve().getDisplayName(ApplicationDependencies.getApplication()), + members, + members, + new Parameters.Builder() + .setQueue(group.getId().toQueueKey()) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private LeaveGroupJob(@NonNull GroupId.Push groupId, + @NonNull String name, + @NonNull List members, + @NonNull List recipients, + @NonNull Parameters parameters) + { + super(parameters); + this.groupId = groupId; + this.name = name; + this.members = Collections.unmodifiableList(members); + this.recipients = recipients; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, Base64.encodeBytes(groupId.getDecodedId())) + .putString(KEY_GROUP_NAME, name) + .putString(KEY_MEMBERS, RecipientId.toSerializedList(members)) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + List completions = deliver(context, groupId, name, members, recipients); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private static @NonNull List deliver(@NonNull Context context, + @NonNull GroupId.Push groupId, + @NonNull String name, + @NonNull List members, + @NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddresses(context, destinations); + List memberAddresses = RecipientUtil.toSignalServiceAddresses(context, members); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, Stream.of(destinations).map(Recipient::resolved).toList()); + SignalServiceGroup serviceGroup = new SignalServiceGroup(SignalServiceGroup.Type.QUIT, groupId.getDecodedId(), name, memberAddresses, null); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .asGroupMessage(serviceGroup); + + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull LeaveGroupJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new LeaveGroupJob(GroupId.v1orThrow(Base64.decodeOrThrow(data.getString(KEY_GROUP_ID))), + data.getString(KEY_GROUP_NAME), + RecipientId.fromSerializedList(data.getString(KEY_MEMBERS)), + RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)), + parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java new file mode 100644 index 00000000..53542a12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -0,0 +1,171 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.Manifest; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupFileIOError; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.StorageUtil; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public final class LocalBackupJob extends BaseJob { + + public static final String KEY = "LocalBackupJob"; + + private static final String TAG = Log.tag(LocalBackupJob.class); + + private static final String QUEUE = "__LOCAL_BACKUP__"; + + public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; + public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; + + public static void enqueue(boolean force) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + Parameters.Builder parameters = new Parameters.Builder() + .setQueue(QUEUE) + .setMaxInstancesForFactory(1) + .setMaxAttempts(3); + if (force) { + jobManager.cancelAllInQueue(QUEUE); + } else { + parameters.addConstraint(ChargingConstraint.KEY); + } + + if (BackupUtil.isUserSelectionRequired(ApplicationDependencies.getApplication())) { + jobManager.add(new LocalBackupJobApi29(parameters.build())); + } else { + jobManager.add(new LocalBackupJob(parameters.build())); + } + } + + private LocalBackupJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws NoExternalStorageException, IOException { + Log.i(TAG, "Executing backup job..."); + + BackupFileIOError.clearNotification(context); + + if (!Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + throw new IOException("No external storage permission!"); + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, + context.getString(R.string.LocalBackupJob_creating_backup), + NotificationChannels.BACKUPS, + R.drawable.ic_signal_backup)) + { + notification.setIndeterminateProgress(); + + String backupPassword = BackupPassphrase.get(context); + File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + String fileName = String.format("signal-%s.backup", timestamp); + File backupFile = new File(backupDirectory, fileName); + + deleteOldTemporaryBackups(backupDirectory); + + if (backupFile.exists()) { + throw new IOException("Backup file already exists?"); + } + + if (backupPassword == null) { + throw new IOException("Backup password is null"); + } + + File tempFile = File.createTempFile(TEMP_BACKUP_FILE_PREFIX, TEMP_BACKUP_FILE_SUFFIX, backupDirectory); + + try { + FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + DatabaseFactory.getBackupDatabase(context), + tempFile, + backupPassword); + + if (!tempFile.renameTo(backupFile)) { + Log.w(TAG, "Failed to rename temp file"); + throw new IOException("Renaming temporary backup file failed!"); + } + } catch (IOException e) { + BackupFileIOError.postNotificationForException(context, e, getRunAttempt()); + throw e; + } finally { + if (tempFile.exists()) { + if (tempFile.delete()) { + Log.w(TAG, "Backup failed. Deleted temp file"); + } else { + Log.w(TAG, "Backup failed. Failed to delete temp file " + tempFile); + } + } + } + + BackupUtil.deleteOldBackups(); + } + } + + private static void deleteOldTemporaryBackups(@NonNull File backupDirectory) { + for (File file : backupDirectory.listFiles()) { + if (file.isFile()) { + String name = file.getName(); + if (name.startsWith(TEMP_BACKUP_FILE_PREFIX) && name.endsWith(TEMP_BACKUP_FILE_SUFFIX)) { + if (file.delete()) { + Log.w(TAG, "Deleted old temporary backup file"); + } else { + Log.w(TAG, "Could not delete old temporary backup file"); + } + } + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull LocalBackupJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new LocalBackupJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java new file mode 100644 index 00000000..0fb868da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupFileIOError; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.BackupUtil; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.UUID; + +/** + * Backup Job for installs requiring Scoped Storage. + * + * @see LocalBackupJob#enqueue(boolean) + */ +public final class LocalBackupJobApi29 extends BaseJob { + + public static final String KEY = "LocalBackupJobApi29"; + + private static final String TAG = Log.tag(LocalBackupJobApi29.class); + + public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; + public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; + + LocalBackupJobApi29(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + Log.i(TAG, "Executing backup job..."); + + BackupFileIOError.clearNotification(context); + + if (!BackupUtil.isUserSelectionRequired(context)) { + throw new IOException("Wrong backup job!"); + } + + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null || backupDirectoryUri.getPath() == null) { + throw new IOException("Backup Directory has not been selected!"); + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, + context.getString(R.string.LocalBackupJob_creating_backup), + NotificationChannels.BACKUPS, + R.drawable.ic_signal_backup)) + { + notification.setIndeterminateProgress(); + + String backupPassword = BackupPassphrase.get(context); + DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + String fileName = String.format("signal-%s.backup", timestamp); + + if (backupDirectory == null || !backupDirectory.canWrite()) { + BackupFileIOError.ACCESS_ERROR.postNotification(context); + throw new IOException("Cannot write to backup directory location."); + } + + deleteOldTemporaryBackups(backupDirectory); + + if (backupDirectory.findFile(fileName) != null) { + throw new IOException("Backup file already exists!"); + } + + String temporaryName = String.format(Locale.US, "%s%s%s", TEMP_BACKUP_FILE_PREFIX, UUID.randomUUID(), TEMP_BACKUP_FILE_SUFFIX); + DocumentFile temporaryFile = backupDirectory.createFile("application/octet-stream", temporaryName); + + if (temporaryFile == null) { + throw new IOException("Failed to create temporary backup file."); + } + + if (backupPassword == null) { + throw new IOException("Backup password is null"); + } + + try { + FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + DatabaseFactory.getBackupDatabase(context), + temporaryFile, + backupPassword); + + if (!temporaryFile.renameTo(fileName)) { + Log.w(TAG, "Failed to rename temp file"); + throw new IOException("Renaming temporary backup file failed!"); + } + } catch (IOException e) { + Log.w(TAG, "Error during backup!", e); + BackupFileIOError.postNotificationForException(context, e, getRunAttempt()); + throw e; + } finally { + DocumentFile fileToCleanUp = backupDirectory.findFile(temporaryName); + if (fileToCleanUp != null) { + if (fileToCleanUp.delete()) { + Log.w(TAG, "Backup failed. Deleted temp file"); + } else { + Log.w(TAG, "Backup failed. Failed to delete temp file " + temporaryName); + } + } + } + + BackupUtil.deleteOldBackups(); + } + } + + private static void deleteOldTemporaryBackups(@NonNull DocumentFile backupDirectory) { + for (DocumentFile file : backupDirectory.listFiles()) { + if (file.isFile()) { + String name = file.getName(); + if (name != null && name.startsWith(TEMP_BACKUP_FILE_PREFIX) && name.endsWith(TEMP_BACKUP_FILE_SUFFIX)) { + if (file.delete()) { + Log.w(TAG, "Deleted old temporary backup file"); + } else { + Log.w(TAG, "Could not delete old temporary backup file"); + } + } + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + LocalBackupJobApi29 create(@NonNull Parameters parameters, @NonNull Data data) { + return new LocalBackupJobApi29(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MarkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MarkerJob.java new file mode 100644 index 00000000..cefe4ed7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MarkerJob.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +/** + * Useful for putting in a queue as a marker to know that previously enqueued jobs have been processed. + *

+ * Does no work. + */ +public final class MarkerJob extends BaseJob { + + private static final String TAG = Log.tag(MarkerJob.class); + + public static final String KEY = "MarkerJob"; + + public MarkerJob(@Nullable String queue) { + this(new Parameters.Builder() + .setQueue(queue) + .build()); + } + + private MarkerJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + protected void onRun() { + Log.i(TAG, String.format("Marker reached in %s queue", getParameters().getQueue())); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MarkerJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MarkerJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java new file mode 100644 index 00000000..3f2a1ae9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java @@ -0,0 +1,284 @@ +package org.thoughtcrime.securesms.jobs; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.pdu_alt.CharacterSets; +import com.google.android.mms.pdu_alt.EncodedStringValue; +import com.google.android.mms.pdu_alt.PduBody; +import com.google.android.mms.pdu_alt.PduPart; +import com.google.android.mms.pdu_alt.RetrieveConf; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.VCardUtil; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.mms.ApnUnavailableException; +import org.thoughtcrime.securesms.mms.CompatMmsConnection; +import org.thoughtcrime.securesms.mms.IncomingMediaMessage; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.MmsRadioException; +import org.thoughtcrime.securesms.mms.PartParser; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public class MmsDownloadJob extends BaseJob { + + public static final String KEY = "MmsDownloadJob"; + + private static final String TAG = MmsDownloadJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_THREAD_ID = "thread_id"; + private static final String KEY_AUTOMATIC = "automatic"; + + private long messageId; + private long threadId; + private boolean automatic; + + public MmsDownloadJob(long messageId, long threadId, boolean automatic) { + this(new Job.Parameters.Builder() + .setQueue("mms-operation") + .setMaxAttempts(25) + .build(), + messageId, + threadId, + automatic); + + } + + private MmsDownloadJob(@NonNull Job.Parameters parameters, long messageId, long threadId, boolean automatic) { + super(parameters); + + this.messageId = messageId; + this.threadId = threadId; + this.automatic = automatic; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putLong(KEY_THREAD_ID, threadId) + .putBoolean(KEY_AUTOMATIC, automatic) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + if (automatic && KeyCachingService.isLocked(context)) { + DatabaseFactory.getMmsDatabase(context).markIncomingNotificationReceived(threadId); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + } + } + + @Override + public void onRun() { + if (TextSecurePreferences.getLocalUuid(context) == null && TextSecurePreferences.getLocalNumber(context) == null) { + throw new NotReadyException(); + } + + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Optional notification = database.getNotification(messageId); + + if (!notification.isPresent()) { + Log.w(TAG, "No notification for ID: " + messageId); + return; + } + + try { + if (notification.get().getContentLocation() == null) { + throw new MmsException("Notification content location was null."); + } + + if (!TextSecurePreferences.isPushRegistered(context)) { + throw new MmsException("Not registered"); + } + + database.markDownloadState(messageId, MmsDatabase.Status.DOWNLOAD_CONNECTING); + + String contentLocation = notification.get().getContentLocation(); + byte[] transactionId = new byte[0]; + + try { + if (notification.get().getTransactionId() != null) { + transactionId = notification.get().getTransactionId().getBytes(CharacterSets.MIMENAME_ISO_8859_1); + } else { + Log.w(TAG, "No transaction ID!"); + } + } catch (UnsupportedEncodingException e) { + Log.w(TAG, e); + } + + Log.i(TAG, "Downloading mms at " + Uri.parse(contentLocation).getHost() + ", subscription ID: " + notification.get().getSubscriptionId()); + + RetrieveConf retrieveConf = new CompatMmsConnection(context).retrieve(contentLocation, transactionId, notification.get().getSubscriptionId()); + + if (retrieveConf == null) { + throw new MmsException("RetrieveConf was null"); + } + + storeRetrievedMms(contentLocation, messageId, threadId, retrieveConf, notification.get().getSubscriptionId(), notification.get().getFrom()); + } catch (ApnUnavailableException e) { + Log.w(TAG, e); + handleDownloadError(messageId, threadId, MmsDatabase.Status.DOWNLOAD_APN_UNAVAILABLE, + automatic); + } catch (MmsException e) { + Log.w(TAG, e); + handleDownloadError(messageId, threadId, + MmsDatabase.Status.DOWNLOAD_HARD_FAILURE, + automatic); + } catch (MmsRadioException | IOException e) { + Log.w(TAG, e); + handleDownloadError(messageId, threadId, + MmsDatabase.Status.DOWNLOAD_SOFT_FAILURE, + automatic); + } + } + + @Override + public void onFailure() { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + database.markDownloadState(messageId, MmsDatabase.Status.DOWNLOAD_SOFT_FAILURE); + + if (automatic) { + database.markIncomingNotificationReceived(threadId); + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + private void storeRetrievedMms(String contentLocation, + long messageId, long threadId, RetrieveConf retrieved, + int subscriptionId, @Nullable RecipientId notificationFrom) + throws MmsException + { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Optional group = Optional.absent(); + Set members = new HashSet<>(); + String body = null; + List attachments = new LinkedList<>(); + List sharedContacts = new LinkedList<>(); + + RecipientId from = null; + + if (retrieved.getFrom() != null) { + from = Recipient.external(context, Util.toIsoString(retrieved.getFrom().getTextString())).getId(); + } else if (notificationFrom != null) { + from = notificationFrom; + } + + if (retrieved.getTo() != null) { + for (EncodedStringValue toValue : retrieved.getTo()) { + members.add(Recipient.external(context, Util.toIsoString(toValue.getTextString())).getId()); + } + } + + if (retrieved.getCc() != null) { + for (EncodedStringValue ccValue : retrieved.getCc()) { + members.add(Recipient.external(context, Util.toIsoString(ccValue.getTextString())).getId()); + } + } + + if (from != null) { + members.add(from); + } + members.add(Recipient.self().getId()); + + if (retrieved.getBody() != null) { + body = PartParser.getMessageText(retrieved.getBody()); + PduBody media = PartParser.getSupportedMediaParts(retrieved.getBody()); + + for (int i=0;i 2) { + List recipients = new ArrayList<>(members); + group = Optional.of(DatabaseFactory.getGroupDatabase(context).getOrCreateMmsGroupForMembers(recipients)); + } + + IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, retrieved.getDate() * 1000L, -1, attachments, subscriptionId, 0, false, false, false, Optional.of(sharedContacts)); + Optional insertResult = database.insertMessageInbox(message, contentLocation, threadId); + + if (insertResult.isPresent()) { + database.deleteMessage(messageId); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } + + private void handleDownloadError(long messageId, long threadId, int downloadStatus, boolean automatic) + { + MessageDatabase db = DatabaseFactory.getMmsDatabase(context); + + db.markDownloadState(messageId, downloadStatus); + + if (automatic) { + db.markIncomingNotificationReceived(threadId); + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MmsDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MmsDownloadJob(parameters, + data.getLong(KEY_MESSAGE_ID), + data.getLong(KEY_THREAD_ID), + data.getBoolean(KEY_AUTOMATIC)); + } + } + + private static class NotReadyException extends RuntimeException { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsReceiveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsReceiveJob.java new file mode 100644 index 00000000..a4a9c8be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsReceiveJob.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.google.android.mms.pdu_alt.GenericPdu; +import com.google.android.mms.pdu_alt.NotificationInd; +import com.google.android.mms.pdu_alt.PduHeaders; +import com.google.android.mms.pdu_alt.PduParser; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; + +import java.io.IOException; + +public class MmsReceiveJob extends BaseJob { + + public static final String KEY = "MmsReceiveJob"; + + private static final String TAG = MmsReceiveJob.class.getSimpleName(); + + private static final String KEY_DATA = "data"; + private static final String KEY_SUBSCRIPTION_ID = "subscription_id"; + + private byte[] data; + private int subscriptionId; + + public MmsReceiveJob(byte[] data, int subscriptionId) { + this(new Job.Parameters.Builder().setMaxAttempts(25).build(), data, subscriptionId); + } + + private MmsReceiveJob(@NonNull Job.Parameters parameters, byte[] data, int subscriptionId) { + super(parameters); + + this.data = data; + this.subscriptionId = subscriptionId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_DATA, Base64.encodeBytes(data)) + .putInt(KEY_SUBSCRIPTION_ID, subscriptionId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + if (data == null) { + Log.w(TAG, "Received NULL pdu, ignoring..."); + return; + } + + PduParser parser = new PduParser(data); + GenericPdu pdu = null; + + try { + pdu = parser.parse(); + } catch (RuntimeException e) { + Log.w(TAG, e); + } + + if (isNotification(pdu) && !isBlocked(pdu)) { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Pair messageAndThreadId = database.insertMessageInbox((NotificationInd)pdu, subscriptionId); + + Log.i(TAG, "Inserted received MMS notification..."); + + ApplicationDependencies.getJobManager().add(new MmsDownloadJob(messageAndThreadId.first(), + messageAndThreadId.second(), + true)); + } else if (isNotification(pdu)) { + Log.w(TAG, "*** Received blocked MMS, ignoring..."); + } + } + + @Override + public void onFailure() { + // TODO + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + private boolean isBlocked(GenericPdu pdu) { + if (pdu.getFrom() != null && pdu.getFrom().getTextString() != null) { + Recipient recipients = Recipient.external(context, Util.toIsoString(pdu.getFrom().getTextString())); + return recipients.isBlocked(); + } + + return false; + } + + private boolean isNotification(GenericPdu pdu) { + return pdu != null && pdu.getMessageType() == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MmsReceiveJob create(@NonNull Parameters parameters, @NonNull Data data) { + try { + return new MmsReceiveJob(parameters, Base64.decode(data.getString(KEY_DATA)), data.getInt(KEY_SUBSCRIPTION_ID)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsSendJob.java new file mode 100644 index 00000000..ea04fd58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsSendJob.java @@ -0,0 +1,360 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.android.mms.dom.smil.parser.SmilXmlSerializer; +import com.annimon.stream.Stream; +import com.google.android.mms.ContentType; +import com.google.android.mms.InvalidHeaderValueException; +import com.google.android.mms.pdu_alt.CharacterSets; +import com.google.android.mms.pdu_alt.EncodedStringValue; +import com.google.android.mms.pdu_alt.PduBody; +import com.google.android.mms.pdu_alt.PduComposer; +import com.google.android.mms.pdu_alt.PduHeaders; +import com.google.android.mms.pdu_alt.PduPart; +import com.google.android.mms.pdu_alt.SendConf; +import com.google.android.mms.pdu_alt.SendReq; +import com.google.android.mms.smil.SmilHelper; +import com.klinker.android.send_message.Utils; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobLogger; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.mms.CompatMmsConnection; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.MmsSendResult; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.List; + +public final class MmsSendJob extends SendJob { + + public static final String KEY = "MmsSendJobV2"; + + private static final String TAG = MmsSendJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private final long messageId; + + private MmsSendJob(long messageId) { + this(new Job.Parameters.Builder() + .setQueue("mms-operation") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(15) + .build(), + messageId); + } + + /** Enqueues compression jobs for attachments and finally the MMS send job. */ + @WorkerThread + public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId) { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message; + + try { + message = database.getOutgoingMessage(messageId); + } catch (MmsException | NoSuchMessageException e) { + throw new AssertionError(e); + } + + List compressionJobs = Stream.of(message.getAttachments()) + .map(a -> (Job) AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, true, message.getSubscriptionId())) + .toList(); + + MmsSendJob sendJob = new MmsSendJob(messageId); + + jobManager.startChain(compressionJobs) + .then(sendJob) + .enqueue(); + } + + private MmsSendJob(@NonNull Job.Parameters parameters, long messageId) { + super(parameters); + this.messageId = messageId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + DatabaseFactory.getMmsDatabase(context).markAsSending(messageId); + } + + @Override + public void onSend() throws MmsException, NoSuchMessageException, IOException { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + + if (database.isSent(messageId)) { + Log.w(TAG, "Message " + messageId + " was already sent. Ignoring."); + return; + } + + try { + Log.i(TAG, "Sending message: " + messageId); + + SendReq pdu = constructSendPdu(message); + + validateDestinations(message, pdu); + + final byte[] pduBytes = getPduBytes(pdu); + final SendConf sendConf = new CompatMmsConnection(context).send(pduBytes, message.getSubscriptionId()); + final MmsSendResult result = getSendResult(sendConf, pdu); + + database.markAsSent(messageId, false); + markAttachmentsUploaded(messageId, message); + + Log.i(TAG, "Sent message: " + messageId); + } catch (UndeliverableMessageException | IOException e) { + Log.w(TAG, e); + database.markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } catch (InsecureFallbackApprovalException e) { + Log.w(TAG, e); + database.markAsPendingInsecureSmsFallback(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + @Override + public void onFailure() { + Log.i(TAG, JobLogger.format(this, "onFailure() messageId: " + messageId)); + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + + private byte[] getPduBytes(SendReq message) + throws IOException, UndeliverableMessageException, InsecureFallbackApprovalException + { + byte[] pduBytes = new PduComposer(context, message).make(); + + if (pduBytes == null) { + throw new UndeliverableMessageException("PDU composition failed, null payload"); + } + + return pduBytes; + } + + private MmsSendResult getSendResult(SendConf conf, SendReq message) + throws UndeliverableMessageException + { + if (conf == null) { + throw new UndeliverableMessageException("No M-Send.conf received in response to send."); + } else if (conf.getResponseStatus() != PduHeaders.RESPONSE_STATUS_OK) { + throw new UndeliverableMessageException("Got bad response: " + conf.getResponseStatus()); + } else if (isInconsistentResponse(message, conf)) { + throw new UndeliverableMessageException("Mismatched response!"); + } else { + return new MmsSendResult(conf.getMessageId(), conf.getResponseStatus()); + } + } + + private boolean isInconsistentResponse(SendReq message, SendConf response) { + Log.i(TAG, "Comparing: " + Hex.toString(message.getTransactionId())); + Log.i(TAG, "With: " + Hex.toString(response.getTransactionId())); + return !Arrays.equals(message.getTransactionId(), response.getTransactionId()); + } + + private void validateDestinations(EncodedStringValue[] destinations) throws UndeliverableMessageException { + if (destinations == null) return; + + for (EncodedStringValue destination : destinations) { + if (destination == null || !NumberUtil.isValidSmsOrEmail(destination.getString())) { + throw new UndeliverableMessageException("Invalid destination: " + + (destination == null ? null : destination.getString())); + } + } + } + + private void validateDestinations(OutgoingMediaMessage media, SendReq message) throws UndeliverableMessageException { + validateDestinations(message.getTo()); + validateDestinations(message.getCc()); + validateDestinations(message.getBcc()); + + if (message.getTo() == null && message.getCc() == null && message.getBcc() == null) { + throw new UndeliverableMessageException("No to, cc, or bcc specified!"); + } + + if (media.isSecure()) { + throw new UndeliverableMessageException("Attempt to send encrypted MMS?"); + } + } + + private SendReq constructSendPdu(OutgoingMediaMessage message) + throws UndeliverableMessageException + { + SendReq req = new SendReq(); + String lineNumber = getMyNumber(context); + MediaConstraints mediaConstraints = MediaConstraints.getMmsMediaConstraints(message.getSubscriptionId()); + List scaledAttachments = message.getAttachments(); + + if (!TextUtils.isEmpty(lineNumber)) { + req.setFrom(new EncodedStringValue(lineNumber)); + } else { + req.setFrom(new EncodedStringValue(TextSecurePreferences.getLocalNumber(context))); + } + + if (message.getRecipient().isMmsGroup()) { + List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + for (Recipient member : members) { + if (message.getDistributionType() == ThreadDatabase.DistributionTypes.BROADCAST) { + req.addBcc(new EncodedStringValue(member.requireSmsAddress())); + } else { + req.addTo(new EncodedStringValue(member.requireSmsAddress())); + } + } + } else { + req.addTo(new EncodedStringValue(message.getRecipient().requireSmsAddress())); + } + + req.setDate(System.currentTimeMillis() / 1000); + + PduBody body = new PduBody(); + int size = 0; + + if (!TextUtils.isEmpty(message.getBody())) { + PduPart part = new PduPart(); + String name = String.valueOf(System.currentTimeMillis()); + part.setData(Util.toUtf8Bytes(message.getBody())); + part.setCharset(CharacterSets.UTF_8); + part.setContentType(ContentType.TEXT_PLAIN.getBytes()); + part.setContentId(name.getBytes()); + part.setContentLocation((name + ".txt").getBytes()); + part.setName((name + ".txt").getBytes()); + + body.addPart(part); + size += getPartSize(part); + } + + for (Attachment attachment : scaledAttachments) { + try { + if (attachment.getUri() == null) throw new IOException("Assertion failed, attachment for outgoing MMS has no data!"); + + String fileName = attachment.getFileName(); + PduPart part = new PduPart(); + + if (fileName == null) { + fileName = String.valueOf(Math.abs(new SecureRandom().nextLong())); + String fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(attachment.getContentType()); + + if (fileExtension != null) fileName = fileName + "." + fileExtension; + } + + if (attachment.getContentType().startsWith("text")) { + part.setCharset(CharacterSets.UTF_8); + } + + part.setContentType(attachment.getContentType().getBytes()); + part.setContentLocation(fileName.getBytes()); + part.setName(fileName.getBytes()); + + int index = fileName.lastIndexOf("."); + String contentId = (index == -1) ? fileName : fileName.substring(0, index); + part.setContentId(contentId.getBytes()); + part.setData(StreamUtil.readFully(PartAuthority.getAttachmentStream(context, attachment.getUri()))); + + body.addPart(part); + size += getPartSize(part); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SmilXmlSerializer.serialize(SmilHelper.createSmilDocument(body), out); + PduPart smilPart = new PduPart(); + smilPart.setContentId("smil".getBytes()); + smilPart.setContentLocation("smil.xml".getBytes()); + smilPart.setContentType(ContentType.APP_SMIL.getBytes()); + smilPart.setData(out.toByteArray()); + body.addPart(0, smilPart); + + req.setBody(body); + req.setMessageSize(size); + req.setMessageClass(PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes()); + req.setExpiry(7 * 24 * 60 * 60); + + try { + req.setPriority(PduHeaders.PRIORITY_NORMAL); + req.setDeliveryReport(PduHeaders.VALUE_NO); + req.setReadReport(PduHeaders.VALUE_NO); + } catch (InvalidHeaderValueException e) {} + + return req; + } + + private long getPartSize(PduPart part) { + return part.getName().length + part.getContentLocation().length + + part.getContentType().length + part.getData().length + + part.getContentId().length; + } + + private void notifyMediaMessageDeliveryFailed(Context context, long messageId) { + long threadId = DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId); + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (recipient != null) { + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, recipient, threadId); + } + } + + private String getMyNumber(Context context) throws UndeliverableMessageException { + try { + return Utils.getMyPhoneNumber(context); + } catch (SecurityException e) { + throw new UndeliverableMessageException(e); + } + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull MmsSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MmsSendJob(parameters, data.getLong(KEY_MESSAGE_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java new file mode 100644 index 00000000..28e9a61b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientReader; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceBlockedUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceBlockedUpdateJob"; + + @SuppressWarnings("unused") + private static final String TAG = MultiDeviceBlockedUpdateJob.class.getSimpleName(); + + public MultiDeviceBlockedUpdateJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("MultiDeviceBlockedUpdateJob") + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private MultiDeviceBlockedUpdateJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() + throws IOException, UntrustedIdentityException + { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + + try (RecipientReader reader = database.readerForBlocked(database.getBlocked())) { + List blockedIndividuals = new LinkedList<>(); + List blockedGroups = new LinkedList<>(); + + Recipient recipient; + + while ((recipient = reader.getNext()) != null) { + if (recipient.isPushGroup()) { + blockedGroups.add(recipient.requireGroupId().getDecodedId()); + } else if (recipient.hasServiceIdentifier()) { + blockedIndividuals.add(RecipientUtil.toSignalServiceAddress(context, recipient)); + } + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + messageSender.sendMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(blockedIndividuals, blockedGroups)), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceBlockedUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceBlockedUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java new file mode 100644 index 00000000..9d1368b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceConfigurationUpdateJob.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + +public class MultiDeviceConfigurationUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceConfigurationUpdateJob"; + + private static final String TAG = MultiDeviceConfigurationUpdateJob.class.getSimpleName(); + + private static final String KEY_READ_RECEIPTS_ENABLED = "read_receipts_enabled"; + private static final String KEY_TYPING_INDICATORS_ENABLED = "typing_indicators_enabled"; + private static final String KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED = "unidentified_delivery_indicators_enabled"; + private static final String KEY_LINK_PREVIEWS_ENABLED = "link_previews_enabled"; + + private boolean readReceiptsEnabled; + private boolean typingIndicatorsEnabled; + private boolean unidentifiedDeliveryIndicatorsEnabled; + private boolean linkPreviewsEnabled; + + public MultiDeviceConfigurationUpdateJob(boolean readReceiptsEnabled, + boolean typingIndicatorsEnabled, + boolean unidentifiedDeliveryIndicatorsEnabled, + boolean linkPreviewsEnabled) + { + this(new Job.Parameters.Builder() + .setQueue("__MULTI_DEVICE_CONFIGURATION_UPDATE_JOB__") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build(), + readReceiptsEnabled, + typingIndicatorsEnabled, + unidentifiedDeliveryIndicatorsEnabled, + linkPreviewsEnabled); + + } + + private MultiDeviceConfigurationUpdateJob(@NonNull Job.Parameters parameters, + boolean readReceiptsEnabled, + boolean typingIndicatorsEnabled, + boolean unidentifiedDeliveryIndicatorsEnabled, + boolean linkPreviewsEnabled) + { + super(parameters); + + this.readReceiptsEnabled = readReceiptsEnabled; + this.typingIndicatorsEnabled = typingIndicatorsEnabled; + this.unidentifiedDeliveryIndicatorsEnabled = unidentifiedDeliveryIndicatorsEnabled; + this.linkPreviewsEnabled = linkPreviewsEnabled; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putBoolean(KEY_READ_RECEIPTS_ENABLED, readReceiptsEnabled) + .putBoolean(KEY_TYPING_INDICATORS_ENABLED, typingIndicatorsEnabled) + .putBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, unidentifiedDeliveryIndicatorsEnabled) + .putBoolean(KEY_LINK_PREVIEWS_ENABLED, linkPreviewsEnabled) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled), + Optional.of(unidentifiedDeliveryIndicatorsEnabled), + Optional.of(typingIndicatorsEnabled), + Optional.of(linkPreviewsEnabled))), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "**** Failed to synchronize read receipts state!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceConfigurationUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceConfigurationUpdateJob(parameters, + data.getBooleanOrDefault(KEY_READ_RECEIPTS_ENABLED, false), + data.getBooleanOrDefault(KEY_TYPING_INDICATORS_ENABLED, false), + data.getBooleanOrDefault(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, false), + data.getBooleanOrDefault(KEY_LINK_PREVIEWS_ENABLED, false)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java new file mode 100644 index 00000000..9fe83a54 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -0,0 +1,422 @@ +package org.thoughtcrime.securesms.jobs; + +import android.Manifest; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.ContactsContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsOutputStream; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.util.InvalidNumberException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceContactUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceContactUpdateJob"; + + private static final String TAG = MultiDeviceContactUpdateJob.class.getSimpleName(); + + private static final long FULL_SYNC_TIME = TimeUnit.HOURS.toMillis(6); + + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_FORCE_SYNC = "force_sync"; + + private @Nullable RecipientId recipientId; + + private boolean forceSync; + + public MultiDeviceContactUpdateJob() { + this(false); + } + + public MultiDeviceContactUpdateJob(boolean forceSync) { + this(null, forceSync); + } + + public MultiDeviceContactUpdateJob(@Nullable RecipientId recipientId) { + this(recipientId, true); + } + + public MultiDeviceContactUpdateJob(@Nullable RecipientId recipientId, boolean forceSync) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("MultiDeviceContactUpdateJob") + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + recipientId, + forceSync); + } + + private MultiDeviceContactUpdateJob(@NonNull Job.Parameters parameters, @Nullable RecipientId recipientId, boolean forceSync) { + super(parameters); + + this.recipientId = recipientId; + this.forceSync = forceSync; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT, recipientId != null ? recipientId.serialize() : null) + .putBoolean(KEY_FORCE_SYNC, forceSync) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() + throws IOException, UntrustedIdentityException, NetworkException + { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + if (recipientId == null) generateFullContactUpdate(); + else generateSingleContactUpdate(recipientId); + } + + private void generateSingleContactUpdate(@NonNull RecipientId recipientId) + throws IOException, UntrustedIdentityException, NetworkException + { + WriteDetails writeDetails = createTempFile(); + + try { + DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream); + Recipient recipient = Recipient.resolved(recipientId); + Optional identityRecord = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()); + Optional verifiedMessage = getVerifiedMessage(recipient, identityRecord); + Map inboxPositions = DatabaseFactory.getThreadDatabase(context).getInboxPositions(); + Set archived = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + + out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, recipient), + Optional.fromNullable(recipient.getName(context)), + getAvatar(recipient.getId(), recipient.getContactUri()), + Optional.fromNullable(recipient.getColor().serialize()), + verifiedMessage, + ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()), + recipient.isBlocked(), + recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) + : Optional.absent(), + Optional.fromNullable(inboxPositions.get(recipientId)), + archived.contains(recipientId))); + + out.close(); + + long length = BlobProvider.getInstance().calculateFileSize(context, writeDetails.uri); + + sendUpdate(ApplicationDependencies.getSignalServiceMessageSender(), + BlobProvider.getInstance().getStream(context, writeDetails.uri), + length, + false); + + } catch(InvalidNumberException e) { + Log.w(TAG, e); + } finally { + BlobProvider.getInstance().delete(context, writeDetails.uri); + } + } + + private void generateFullContactUpdate() + throws IOException, UntrustedIdentityException, NetworkException + { + boolean isAppVisible = ApplicationDependencies.getAppForegroundObserver().isForegrounded(); + long timeSinceLastSync = System.currentTimeMillis() - TextSecurePreferences.getLastFullContactSyncTime(context); + + Log.d(TAG, "Requesting a full contact sync. forced = " + forceSync + ", appVisible = " + isAppVisible + ", timeSinceLastSync = " + timeSinceLastSync + " ms"); + + if (!forceSync && !isAppVisible && timeSinceLastSync < FULL_SYNC_TIME) { + Log.i(TAG, "App is backgrounded and the last contact sync was too soon (" + timeSinceLastSync + " ms ago). Marking that we need a sync. Skipping multi-device contact update..."); + TextSecurePreferences.setNeedsFullContactSync(context, true); + return; + } + + TextSecurePreferences.setLastFullContactSyncTime(context, System.currentTimeMillis()); + TextSecurePreferences.setNeedsFullContactSync(context, false); + + WriteDetails writeDetails = createTempFile(); + + try { + DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream); + List recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsForMultiDeviceSync(); + Map inboxPositions = DatabaseFactory.getThreadDatabase(context).getInboxPositions(); + Set archived = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + + for (Recipient recipient : recipients) { + Optional identity = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()); + Optional verified = getVerifiedMessage(recipient, identity); + Optional name = Optional.fromNullable(recipient.getName(context)); + Optional color = Optional.of(recipient.getColor().serialize()); + Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); + boolean blocked = recipient.isBlocked(); + Optional expireTimer = recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) : Optional.absent(); + Optional inboxPosition = Optional.fromNullable(inboxPositions.get(recipient.getId())); + + out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, recipient), + name, + getAvatar(recipient.getId(), recipient.getContactUri()), + color, + verified, + profileKey, + blocked, + expireTimer, + inboxPosition, + archived.contains(recipient.getId()))); + } + + + Recipient self = Recipient.self(); + byte[] profileKey = self.getProfileKey(); + + if (profileKey != null) { + out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, self), + Optional.absent(), + Optional.absent(), + Optional.of(self.getColor().serialize()), + Optional.absent(), + ProfileKeyUtil.profileKeyOptionalOrThrow(self.getProfileKey()), + false, + self.getExpireMessages() > 0 ? Optional.of(self.getExpireMessages()) : Optional.absent(), + Optional.fromNullable(inboxPositions.get(self.getId())), + archived.contains(self.getId()))); + } + + out.close(); + + long length = BlobProvider.getInstance().calculateFileSize(context, writeDetails.uri); + + sendUpdate(ApplicationDependencies.getSignalServiceMessageSender(), + BlobProvider.getInstance().getStream(context, writeDetails.uri), + length, + true); + } catch(InvalidNumberException e) { + Log.w(TAG, e); + } finally { + BlobProvider.getInstance().delete(context, writeDetails.uri); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + + } + + private void sendUpdate(SignalServiceMessageSender messageSender, InputStream stream, long length, boolean complete) + throws UntrustedIdentityException, NetworkException + { + if (length > 0) { + try { + SignalServiceAttachmentStream.Builder attachmentStream = SignalServiceAttachment.newStreamBuilder() + .withStream(stream) + .withContentType("application/octet-stream") + .withLength(length) + .withResumableUploadSpec(messageSender.getResumableUploadSpec()); + + messageSender.sendMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete)), + UnidentifiedAccessUtil.getAccessForSync(context)); + } catch (IOException ioe) { + throw new NetworkException(ioe); + } + } else { + Log.w(TAG, "Nothing to write!"); + } + } + + private Optional getAvatar(@NonNull RecipientId recipientId, @Nullable Uri uri) { + Optional stream = getSystemAvatar(uri); + + if (!stream.isPresent()) { + return getProfileAvatar(recipientId); + } + + return stream; + } + + private Optional getProfileAvatar(@NonNull RecipientId recipientId) { + if (AvatarHelper.hasAvatar(context, recipientId)) { + try { + long length = AvatarHelper.getAvatarLength(context, recipientId); + return Optional.of(SignalServiceAttachmentStream.newStreamBuilder() + .withStream(AvatarHelper.getAvatar(context, recipientId)) + .withContentType("image/*") + .withLength(length) + .build()); + } catch (IOException e) { + Log.w(TAG, "Failed to read profile avatar!", e); + return Optional.absent(); + } + } + + return Optional.absent(); + } + + private Optional getSystemAvatar(@Nullable Uri uri) { + if (uri == null) { + return Optional.absent(); + } + + if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + return Optional.absent(); + } + + Uri displayPhotoUri = Uri.withAppendedPath(uri, ContactsContract.Contacts.Photo.DISPLAY_PHOTO); + + try { + AssetFileDescriptor fd = context.getContentResolver().openAssetFileDescriptor(displayPhotoUri, "r"); + + if (fd == null) { + return Optional.absent(); + } + + return Optional.of(SignalServiceAttachment.newStreamBuilder() + .withStream(fd.createInputStream()) + .withContentType("image/*") + .withLength(fd.getLength()) + .build()); + } catch (IOException e) { + // Ignored + } + + Uri photoUri = Uri.withAppendedPath(uri, ContactsContract.Contacts.Photo.CONTENT_DIRECTORY); + + if (photoUri == null) { + return Optional.absent(); + } + + Cursor cursor = context.getContentResolver().query(photoUri, + new String[] { + ContactsContract.CommonDataKinds.Photo.PHOTO, + ContactsContract.CommonDataKinds.Phone.MIMETYPE + }, null, null, null); + + try { + if (cursor != null && cursor.moveToNext()) { + byte[] data = cursor.getBlob(0); + + if (data != null) { + return Optional.of(SignalServiceAttachment.newStreamBuilder() + .withStream(new ByteArrayInputStream(data)) + .withContentType("image/*") + .withLength(data.length) + .build()); + } + } + + return Optional.absent(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private Optional getVerifiedMessage(Recipient recipient, Optional identity) throws InvalidNumberException { + if (!identity.isPresent()) return Optional.absent(); + + SignalServiceAddress destination = RecipientUtil.toSignalServiceAddressBestEffort(context, recipient); + IdentityKey identityKey = identity.get().getIdentityKey(); + + VerifiedMessage.VerifiedState state; + + switch (identity.get().getVerifiedStatus()) { + case VERIFIED: state = VerifiedMessage.VerifiedState.VERIFIED; break; + case UNVERIFIED: state = VerifiedMessage.VerifiedState.UNVERIFIED; break; + case DEFAULT: state = VerifiedMessage.VerifiedState.DEFAULT; break; + default: throw new AssertionError("Unknown state: " + identity.get().getVerifiedStatus()); + } + + return Optional.of(new VerifiedMessage(destination, identityKey, state, System.currentTimeMillis())); + } + + private @NonNull WriteDetails createTempFile() throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pipe[0]); + Uri uri = BlobProvider.getInstance() + .forData(inputStream, 0) + .withFileName("multidevice-contact-update") + .createForSingleSessionOnDiskAsync(context, + () -> Log.i(TAG, "Write successful."), + e -> Log.w(TAG, "Error during write.", e)); + + return new WriteDetails(uri, new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])); + } + + private static class NetworkException extends Exception { + + public NetworkException(Exception ioe) { + super(ioe); + } + } + + private static class WriteDetails { + private final Uri uri; + private final OutputStream outputStream; + + private WriteDetails(@NonNull Uri uri, @NonNull OutputStream outputStream) { + this.uri = uri; + this.outputStream = outputStream; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceContactUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + String serialized = data.getString(KEY_RECIPIENT); + RecipientId address = serialized != null ? RecipientId.from(serialized) : null; + + return new MultiDeviceContactUpdateJob(parameters, address, data.getBoolean(KEY_FORCE_SYNC)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java new file mode 100644 index 00000000..aacd50aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java @@ -0,0 +1,186 @@ +package org.thoughtcrime.securesms.jobs; + +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroup; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsOutputStream; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceGroupUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceGroupUpdateJob"; + + private static final String TAG = MultiDeviceGroupUpdateJob.class.getSimpleName(); + + public MultiDeviceGroupUpdateJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("MultiDeviceGroupUpdateJob") + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private MultiDeviceGroupUpdateJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public void onRun() throws Exception { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pipe[0]); + Uri uri = BlobProvider.getInstance() + .forData(inputStream, 0) + .withFileName("multidevice-group-update") + .createForSingleSessionOnDiskAsync(context, + () -> Log.i(TAG, "Write successful."), + e -> Log.w(TAG, "Error during write.", e)); + + try (GroupDatabase.Reader reader = DatabaseFactory.getGroupDatabase(context).getGroups()) { + DeviceGroupsOutputStream out = new DeviceGroupsOutputStream(new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])); + boolean hasData = false; + + GroupDatabase.GroupRecord record; + + while ((record = reader.getNext()) != null) { + if (record.isV1Group()) { + List members = new LinkedList<>(); + + for (RecipientId member : record.getMembers()) { + members.add(RecipientUtil.toSignalServiceAddress(context, Recipient.resolved(member))); + } + + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(record.getId()); + Recipient recipient = Recipient.resolved(recipientId); + Optional expirationTimer = recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) : Optional.absent(); + Map inboxPositions = DatabaseFactory.getThreadDatabase(context).getInboxPositions(); + Set archived = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + + out.write(new DeviceGroup(record.getId().getDecodedId(), + Optional.fromNullable(record.getTitle()), + members, + getAvatar(record.getRecipientId()), + record.isActive(), + expirationTimer, + Optional.of(recipient.getColor().serialize()), + recipient.isBlocked(), + Optional.fromNullable(inboxPositions.get(recipientId)), + archived.contains(recipientId))); + + hasData = true; + } + } + + out.close(); + + if (hasData) { + long length = BlobProvider.getInstance().calculateFileSize(context, uri); + + sendUpdate(ApplicationDependencies.getSignalServiceMessageSender(), + BlobProvider.getInstance().getStream(context, uri), + length); + } else { + Log.w(TAG, "No groups present for sync message..."); + } + } finally { + BlobProvider.getInstance().delete(context, uri); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + + } + + private void sendUpdate(SignalServiceMessageSender messageSender, InputStream stream, long length) + throws IOException, UntrustedIdentityException + { + SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder() + .withStream(stream) + .withContentType("application/octet-stream") + .withLength(length) + .build(); + + messageSender.sendMessage(SignalServiceSyncMessage.forGroups(attachmentStream), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + + private Optional getAvatar(@NonNull RecipientId recipientId) throws IOException { + if (!AvatarHelper.hasAvatar(context, recipientId)) return Optional.absent(); + + return Optional.of(SignalServiceAttachment.newStreamBuilder() + .withStream(AvatarHelper.getAvatar(context, recipientId)) + .withContentType("image/*") + .withLength(AvatarHelper.getAvatarLength(context, recipientId)) + .build()); + } + + private File createTempFile(String prefix) throws IOException { + File file = File.createTempFile(prefix, "tmp", context.getCacheDir()); + file.deleteOnExit(); + + return file; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceGroupUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceGroupUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java new file mode 100644 index 00000000..a1b6ee45 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.StorageKey; + +import java.io.IOException; + +public class MultiDeviceKeysUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceKeysUpdateJob"; + + private static final String TAG = MultiDeviceKeysUpdateJob.class.getSimpleName(); + + public MultiDeviceKeysUpdateJob() { + this(new Parameters.Builder() + .setQueue("MultiDeviceKeysUpdateJob") + .setMaxInstancesForFactory(2) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build()); + + } + + private MultiDeviceKeysUpdateJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageKey(); + + messageSender.sendMessage(SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.fromNullable(storageServiceKey))), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceKeysUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceKeysUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java new file mode 100644 index 00000000..23652615 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceMessageRequestResponseJob extends BaseJob { + + public static final String KEY = "MultiDeviceMessageRequestResponseJob"; + + private static final String TAG = MultiDeviceMessageRequestResponseJob.class.getSimpleName(); + + private static final String KEY_THREAD_RECIPIENT = "thread_recipient"; + private static final String KEY_TYPE = "type"; + + private final RecipientId threadRecipient; + private final Type type; + + public static @NonNull MultiDeviceMessageRequestResponseJob forAccept(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.ACCEPT); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forDelete(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.DELETE); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forBlock(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forBlockAndDelete(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK_AND_DELETE); + } + + private MultiDeviceMessageRequestResponseJob(@NonNull RecipientId threadRecipient, @NonNull Type type) { + this(new Parameters.Builder() + .setQueue("MultiDeviceMessageRequestResponseJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build(), threadRecipient, type); + + } + + private MultiDeviceMessageRequestResponseJob(@NonNull Parameters parameters, + @NonNull RecipientId threadRecipient, + @NonNull Type type) + { + super(parameters); + this.threadRecipient = threadRecipient; + this.type = type; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_THREAD_RECIPIENT, threadRecipient.serialize()) + .putInt(KEY_TYPE, type.serialize()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(threadRecipient); + + if (!recipient.hasServiceIdentifier()) { + Log.i(TAG, "Queued for recipient without service identifier"); + return; + } + + MessageRequestResponseMessage response; + + if (recipient.isGroup()) { + response = MessageRequestResponseMessage.forGroup(recipient.getGroupId().get().getDecodedId(), localToRemoteType(type)); + } else { + response = MessageRequestResponseMessage.forIndividual(RecipientUtil.toSignalServiceAddress(context, recipient), localToRemoteType(type)); + } + + messageSender.sendMessage(SignalServiceSyncMessage.forMessageRequestResponse(response), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + private static MessageRequestResponseMessage.Type localToRemoteType(@NonNull Type type) { + switch (type) { + case ACCEPT: return MessageRequestResponseMessage.Type.ACCEPT; + case DELETE: return MessageRequestResponseMessage.Type.DELETE; + case BLOCK: return MessageRequestResponseMessage.Type.BLOCK; + case BLOCK_AND_DELETE: return MessageRequestResponseMessage.Type.BLOCK_AND_DELETE; + default: return MessageRequestResponseMessage.Type.UNKNOWN; + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + private enum Type { + UNKNOWN(0), ACCEPT(1), DELETE(2), BLOCK(3), BLOCK_AND_DELETE(4); + + private final int value; + + Type(int value) { + this.value = value; + } + + int serialize() { + return value; + } + + static @NonNull Type deserialize(int value) { + for (Type type : Type.values()) { + if (type.value == value) { + return type; + } + } + throw new AssertionError("Unknown type: " + value); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull + MultiDeviceMessageRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) { + RecipientId threadRecipient = RecipientId.from(data.getString(KEY_THREAD_RECIPIENT)); + Type type = Type.deserialize(data.getInt(KEY_TYPE)); + + return new MultiDeviceMessageRequestResponseJob(parameters, threadRecipient, type); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileContentUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileContentUpdateJob.java new file mode 100644 index 00000000..5b7ba2a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileContentUpdateJob.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +public class MultiDeviceProfileContentUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceProfileContentUpdateJob"; + + private static final String TAG = Log.tag(MultiDeviceProfileContentUpdateJob.class); + + public MultiDeviceProfileContentUpdateJob() { + this(new Parameters.Builder() + .setQueue("MultiDeviceProfileUpdateJob") + .setMaxInstancesForFactory(2) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build()); + } + + private MultiDeviceProfileContentUpdateJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + + messageSender.sendMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.LOCAL_PROFILE), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Did not succeed!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceProfileContentUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceProfileContentUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java new file mode 100644 index 00000000..e0526143 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsOutputStream; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceProfileKeyUpdateJob extends BaseJob { + + public static String KEY = "MultiDeviceProfileKeyUpdateJob"; + + private static final String TAG = MultiDeviceProfileKeyUpdateJob.class.getSimpleName(); + + public MultiDeviceProfileKeyUpdateJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("MultiDeviceProfileKeyUpdateJob") + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private MultiDeviceProfileKeyUpdateJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + Optional profileKey = Optional.of(ProfileKeyUtil.getSelfProfileKey()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DeviceContactsOutputStream out = new DeviceContactsOutputStream(baos); + + out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, Recipient.self()), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + profileKey, + false, + Optional.absent(), + Optional.absent(), + false)); + + out.close(); + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder() + .withStream(new ByteArrayInputStream(baos.toByteArray())) + .withContentType("application/octet-stream") + .withLength(baos.toByteArray().length) + .build(); + + SignalServiceSyncMessage syncMessage = SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, false)); + + messageSender.sendMessage(syncMessage, UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Profile key sync failed!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceProfileKeyUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceProfileKeyUpdateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceReadUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceReadUpdateJob.java new file mode 100644 index 00000000..2fcda12f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceReadUpdateJob.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.io.Serializable; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceReadUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceReadUpdateJob"; + + private static final String TAG = MultiDeviceReadUpdateJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_IDS = "message_ids"; + + private List messageIds; + + private MultiDeviceReadUpdateJob(List messageIds) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + SendReadReceiptJob.ensureSize(messageIds, SendReadReceiptJob.MAX_TIMESTAMPS)); + } + + private MultiDeviceReadUpdateJob(@NonNull Job.Parameters parameters, @NonNull List messageIds) { + super(parameters); + + this.messageIds = new LinkedList<>(); + + for (SyncMessageId messageId : messageIds) { + this.messageIds.add(new SerializableSyncMessageId(messageId.getRecipientId().serialize(), messageId.getTimetamp())); + } + } + + /** + * Enqueues all the necessary jobs for read receipts, ensuring that they're all within the + * maximum size. + */ + public static void enqueue(@NonNull List messageIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + List> messageIdChunks = Util.chunk(messageIds, SendReadReceiptJob.MAX_TIMESTAMPS); + + if (messageIdChunks.size() > 1) { + Log.w(TAG, "Large receipt count! Had to break into multiple chunks. Total count: " + messageIds.size()); + } + + for (List chunk : messageIdChunks) { + jobManager.add(new MultiDeviceReadUpdateJob(chunk)); + } + } + + @Override + public @NonNull Data serialize() { + String[] ids = new String[messageIds.size()]; + + for (int i = 0; i < ids.length; i++) { + try { + ids[i] = JsonUtils.toJson(messageIds.get(i)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + return new Data.Builder().putStringArray(KEY_MESSAGE_IDS, ids).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + List readMessages = new LinkedList<>(); + + for (SerializableSyncMessageId messageId : messageIds) { + Recipient recipient = Recipient.resolved(RecipientId.from(messageId.recipientId)); + if (!recipient.isGroup()) { + readMessages.add(new ReadMessage(RecipientUtil.toSignalServiceAddress(context, recipient), messageId.timestamp)); + } + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + messageSender.sendMessage(SignalServiceSyncMessage.forRead(readMessages), UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onFailure() { + + } + + private static class SerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String recipientId; + + @JsonProperty + private final long timestamp; + + private SerializableSyncMessageId(@JsonProperty("recipientId") String recipientId, @JsonProperty("timestamp") long timestamp) { + this.recipientId = recipientId; + this.timestamp = timestamp; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceReadUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + List ids = Stream.of(data.getStringArray(KEY_MESSAGE_IDS)) + .map(id -> { + try { + return JsonUtils.fromJson(id, SerializableSyncMessageId.class); + } catch (IOException e) { + throw new AssertionError(e); + } + }) + .map(id -> new SyncMessageId(RecipientId.from(id.recipientId), id.timestamp)) + .toList(); + + return new MultiDeviceReadUpdateJob(parameters, ids); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackOperationJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackOperationJob.java new file mode 100644 index 00000000..e6697c17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackOperationJob.java @@ -0,0 +1,123 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceStickerPackOperationJob extends BaseJob { + + private static final String TAG = Log.tag(MultiDeviceStickerPackOperationJob.class); + + public static final String KEY = "MultiDeviceStickerPackOperationJob"; + + private static final String KEY_PACK_ID = "pack_id"; + private static final String KEY_PACK_KEY = "pack_key"; + private static final String KEY_TYPE = "type"; + + private final String packId; + private final String packKey; + private final Type type; + + public MultiDeviceStickerPackOperationJob(@NonNull String packId, + @NonNull String packKey, + @NonNull Type type) + { + this(new Job.Parameters.Builder() + .setQueue("MultiDeviceStickerPackOperationJob") + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build(), + packId, + packKey, + type); + } + + public MultiDeviceStickerPackOperationJob(@NonNull Parameters parameters, + @NonNull String packId, + @NonNull String packKey, + @NonNull Type type) + { + super(parameters); + this.packId = packId; + this.packKey = packKey; + this.type = type; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_PACK_ID, packId) + .putString(KEY_PACK_KEY, packKey) + .putString(KEY_TYPE, type.name()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + byte[] packIdBytes = Hex.fromStringCondensed(packId); + byte[] packKeyBytes = Hex.fromStringCondensed(packKey); + + StickerPackOperationMessage.Type remoteType; + + switch (type) { + case INSTALL: remoteType = StickerPackOperationMessage.Type.INSTALL; break; + case REMOVE: remoteType = StickerPackOperationMessage.Type.REMOVE; break; + default: throw new AssertionError("No matching type?"); + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + StickerPackOperationMessage stickerPackOperation = new StickerPackOperationMessage(packIdBytes, packKeyBytes, remoteType); + + messageSender.sendMessage(SignalServiceSyncMessage.forStickerPackOperations(Collections.singletonList(stickerPackOperation)), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to sync sticker pack operation!"); + } + + // NEVER rename these -- they're persisted by name + public enum Type { + INSTALL, REMOVE + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull MultiDeviceStickerPackOperationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceStickerPackOperationJob(parameters, + data.getString(KEY_PACK_ID), + data.getString(KEY_PACK_KEY), + Type.valueOf(data.getString(KEY_TYPE))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackSyncJob.java new file mode 100644 index 00000000..d089ebfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStickerPackSyncJob.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase.StickerPackRecordReader; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Tells a linked desktop about all installed sticker packs. + */ +public class MultiDeviceStickerPackSyncJob extends BaseJob { + + private static final String TAG = Log.tag(MultiDeviceStickerPackSyncJob.class); + + public static final String KEY = "MultiDeviceStickerPackSyncJob"; + + public MultiDeviceStickerPackSyncJob() { + this(new Parameters.Builder() + .setQueue("MultiDeviceStickerPackSyncJob") + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); + } + + public MultiDeviceStickerPackSyncJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + List operations = new LinkedList<>(); + + try (StickerPackRecordReader reader = new StickerPackRecordReader(DatabaseFactory.getStickerDatabase(context).getInstalledStickerPacks())) { + StickerPackRecord pack; + while ((pack = reader.getNext()) != null) { + byte[] packIdBytes = Hex.fromStringCondensed(pack.getPackId()); + byte[] packKeyBytes = Hex.fromStringCondensed(pack.getPackKey()); + + operations.add(new StickerPackOperationMessage(packIdBytes, packKeyBytes, StickerPackOperationMessage.Type.INSTALL)); + } + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + messageSender.sendMessage(SignalServiceSyncMessage.forStickerPackOperations(operations), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to sync sticker pack operation!"); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull + MultiDeviceStickerPackSyncJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceStickerPackSyncJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorageSyncRequestJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorageSyncRequestJob.java new file mode 100644 index 00000000..321e2d6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorageSyncRequestJob.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +public class MultiDeviceStorageSyncRequestJob extends BaseJob { + + public static final String KEY = "MultiDeviceStorageSyncRequestJob"; + + private static final String TAG = Log.tag(MultiDeviceStorageSyncRequestJob.class); + + public MultiDeviceStorageSyncRequestJob() { + this(new Parameters.Builder() + .setQueue("MultiDeviceStorageSyncRequestJob") + .setMaxInstancesForFactory(2) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build()); + } + + private MultiDeviceStorageSyncRequestJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + + messageSender.sendMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.STORAGE_MANIFEST), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Did not succeed!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceStorageSyncRequestJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceStorageSyncRequestJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceVerifiedUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceVerifiedUpdateJob.java new file mode 100644 index 00000000..7aec2019 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceVerifiedUpdateJob.java @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceVerifiedUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceVerifiedUpdateJob"; + + private static final String TAG = MultiDeviceVerifiedUpdateJob.class.getSimpleName(); + + private static final String KEY_DESTINATION = "destination"; + private static final String KEY_IDENTITY_KEY = "identity_key"; + private static final String KEY_VERIFIED_STATUS = "verified_status"; + private static final String KEY_TIMESTAMP = "timestamp"; + + private RecipientId destination; + private byte[] identityKey; + private VerifiedStatus verifiedStatus; + private long timestamp; + + public MultiDeviceVerifiedUpdateJob(@NonNull RecipientId destination, IdentityKey identityKey, VerifiedStatus verifiedStatus) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("__MULTI_DEVICE_VERIFIED_UPDATE__") + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + destination, + identityKey.serialize(), + verifiedStatus, + System.currentTimeMillis()); + } + + private MultiDeviceVerifiedUpdateJob(@NonNull Job.Parameters parameters, + @NonNull RecipientId destination, + @NonNull byte[] identityKey, + @NonNull VerifiedStatus verifiedStatus, + long timestamp) + { + super(parameters); + + this.destination = destination; + this.identityKey = identityKey; + this.verifiedStatus = verifiedStatus; + this.timestamp = timestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_DESTINATION, destination.serialize()) + .putString(KEY_IDENTITY_KEY, Base64.encodeBytes(identityKey)) + .putInt(KEY_VERIFIED_STATUS, verifiedStatus.toInt()) + .putLong(KEY_TIMESTAMP, timestamp) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + try { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + if (destination == null) { + Log.w(TAG, "No destination..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(destination); + VerifiedMessage.VerifiedState verifiedState = getVerifiedState(verifiedStatus); + SignalServiceAddress verifiedAddress = RecipientUtil.toSignalServiceAddress(context, recipient); + VerifiedMessage verifiedMessage = new VerifiedMessage(verifiedAddress, new IdentityKey(identityKey, 0), verifiedState, timestamp); + + messageSender.sendMessage(SignalServiceSyncMessage.forVerified(verifiedMessage), + UnidentifiedAccessUtil.getAccessFor(context, recipient)); + } catch (InvalidKeyException e) { + throw new IOException(e); + } + } + + private VerifiedMessage.VerifiedState getVerifiedState(VerifiedStatus status) { + VerifiedMessage.VerifiedState verifiedState; + + switch (status) { + case DEFAULT: verifiedState = VerifiedMessage.VerifiedState.DEFAULT; break; + case VERIFIED: verifiedState = VerifiedMessage.VerifiedState.VERIFIED; break; + case UNVERIFIED: verifiedState = VerifiedMessage.VerifiedState.UNVERIFIED; break; + default: throw new AssertionError("Unknown status: " + verifiedStatus); + } + + return verifiedState; + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onFailure() { + + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceVerifiedUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + try { + RecipientId destination = RecipientId.from(data.getString(KEY_DESTINATION)); + VerifiedStatus verifiedStatus = VerifiedStatus.forState(data.getInt(KEY_VERIFIED_STATUS)); + long timestamp = data.getLong(KEY_TIMESTAMP); + byte[] identityKey = Base64.decode(data.getString(KEY_IDENTITY_KEY)); + + return new MultiDeviceVerifiedUpdateJob(parameters, destination, identityKey, verifiedStatus, timestamp); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewOnceOpenJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewOnceOpenJob.java new file mode 100644 index 00000000..3f602499 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewOnceOpenJob.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.io.Serializable; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceViewOnceOpenJob extends BaseJob { + + public static final String KEY = "MultiDeviceRevealUpdateJob"; + + private static final String TAG = Log.tag(MultiDeviceViewOnceOpenJob.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private SerializableSyncMessageId messageId; + + public MultiDeviceViewOnceOpenJob(SyncMessageId messageId) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + messageId); + } + + private MultiDeviceViewOnceOpenJob(@NonNull Parameters parameters, @NonNull SyncMessageId syncMessageId) { + super(parameters); + this.messageId = new SerializableSyncMessageId(syncMessageId.getRecipientId().serialize(), syncMessageId.getTimetamp()); + } + + @Override + public @NonNull Data serialize() { + String serialized; + + try { + serialized = JsonUtils.toJson(messageId); + } catch (IOException e) { + throw new AssertionError(e); + } + + return new Data.Builder().putString(KEY_MESSAGE_ID, serialized).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(RecipientId.from(messageId.recipientId)); + ViewOnceOpenMessage openMessage = new ViewOnceOpenMessage(RecipientUtil.toSignalServiceAddress(context, recipient), messageId.timestamp); + + messageSender.sendMessage(SignalServiceSyncMessage.forViewOnceOpen(openMessage), UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onFailure() { + + } + + private static class SerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String recipientId; + + @JsonProperty + private final long timestamp; + + private SerializableSyncMessageId(@JsonProperty("recipientId") String recipientId, @JsonProperty("timestamp") long timestamp) { + this.recipientId = recipientId; + this.timestamp = timestamp; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceViewOnceOpenJob create(@NonNull Parameters parameters, @NonNull Data data) { + SerializableSyncMessageId messageId; + + try { + messageId = JsonUtils.fromJson(data.getString(KEY_MESSAGE_ID), SerializableSyncMessageId.class); + } catch (IOException e) { + throw new AssertionError(e); + } + + SyncMessageId syncMessageId = new SyncMessageId(RecipientId.from(messageId.recipientId), messageId.timestamp); + + return new MultiDeviceViewOnceOpenJob(parameters, syncMessageId); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileKeySendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileKeySendJob.java new file mode 100644 index 00000000..3d18ab80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileKeySendJob.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class ProfileKeySendJob extends BaseJob { + + private static final String TAG = Log.tag(ProfileKeySendJob.class); + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_THREAD = "thread"; + + public static final String KEY = "ProfileKeySendJob"; + + private final long threadId; + private final List recipients; + + /** + * Suitable for a 1:1 conversation or a GV1 group only. + */ + @WorkerThread + public static ProfileKeySendJob create(@NonNull Context context, long threadId) { + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (conversationRecipient == null) { + throw new AssertionError("We have a thread but no recipient!"); + } + + if (conversationRecipient.isPushV2Group()) { + throw new AssertionError("Do not send profile keys directly for GV2"); + } + + List recipients = conversationRecipient.isGroup() ? Stream.of(RecipientUtil.getEligibleForSending(conversationRecipient.getParticipants())).map(Recipient::getId).toList() + : Stream.of(conversationRecipient.getId()).toList(); + + recipients.remove(Recipient.self().getId()); + + return new ProfileKeySendJob(new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), threadId, recipients); + } + + private ProfileKeySendJob(@NonNull Parameters parameters, long threadId, @NonNull List recipients) { + super(parameters); + this.threadId = threadId; + this.recipients = recipients; + } + + @Override + protected void onRun() throws Exception { + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (conversationRecipient == null) { + Log.w(TAG, "Thread no longer present"); + return; + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putLong(KEY_THREAD, threadId) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + + } + + private List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations) throws IOException, UntrustedIdentityException { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .asProfileKeyUpdate(true) + .withTimestamp(System.currentTimeMillis()) + .withProfileKey(Recipient.self().resolve().getProfileKey()); + + if (conversationRecipient.isGroup()) { + dataMessage.asGroupMessage(new SignalServiceGroup(conversationRecipient.requireGroupId().getDecodedId())); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull ProfileKeySendJob create(@NonNull Parameters parameters, @NonNull Data data) { + long threadId = data.getLong(KEY_THREAD); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + + return new ProfileKeySendJob(parameters, threadId, recipients); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java new file mode 100644 index 00000000..670527e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public final class ProfileUploadJob extends BaseJob { + + private static final String TAG = Log.tag(ProfileUploadJob.class); + + public static final String KEY = "ProfileUploadJob"; + + public static final String QUEUE = "ProfileAlteration"; + + public ProfileUploadJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(QUEUE) + .setLifespan(TimeUnit.DAYS.toMillis(30)) + .setMaxAttempts(Parameters.UNLIMITED) + .setMaxInstancesForFactory(2) + .build()); + } + + private ProfileUploadJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.w(TAG, "Not registered. Skipping."); + return; + } + + ProfileUtil.uploadProfile(context); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return true; + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull ProfileUploadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ProfileUploadJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptDrainedJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptDrainedJob.java new file mode 100644 index 00000000..7aad9e63 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptDrainedJob.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +/** + * A job that has the same queue as {@link PushDecryptMessageJob} that we enqueue so we can notify + * the {@link org.thoughtcrime.securesms.messages.IncomingMessageObserver} when decryptions have + * finished. This lets us know not just when the websocket is drained, but when all the decryptions + * for the messages we pulled down from the websocket have been finished. + */ +public class PushDecryptDrainedJob extends BaseJob { + + public static final String KEY = "PushDecryptDrainedJob"; + + private static final String TAG = Log.tag(PushDecryptDrainedJob.class); + + public PushDecryptDrainedJob() { + this(new Parameters.Builder() + .setQueue(PushDecryptMessageJob.QUEUE) + .build()); + } + + private PushDecryptDrainedJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + protected void onRun() throws Exception { + Log.i(TAG, "Decryptions are caught-up."); + ApplicationDependencies.getIncomingMessageObserver().notifyDecryptionsDrained(); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushDecryptDrainedJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushDecryptDrainedJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java new file mode 100644 index 00000000..d39bd90b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java @@ -0,0 +1,170 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.PendingIntent; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.signal.core.util.logging.Log; +import org.signal.libsignal.metadata.InvalidMetadataMessageException; +import org.signal.libsignal.metadata.InvalidMetadataVersionException; +import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; +import org.signal.libsignal.metadata.ProtocolException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; +import org.signal.libsignal.metadata.ProtocolInvalidMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidVersionException; +import org.signal.libsignal.metadata.ProtocolLegacyMessageException; +import org.signal.libsignal.metadata.ProtocolNoSessionException; +import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; +import org.signal.libsignal.metadata.SelfSendException; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.messages.MessageContentProcessor; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState; +import org.thoughtcrime.securesms.messages.MessageDecryptionUtil; +import org.thoughtcrime.securesms.messages.MessageDecryptionUtil.DecryptionResult; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.state.SignalProtocolStore; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Decrypts an envelope. Enqueues a separate job, {@link PushProcessMessageJob}, to actually insert + * the result into our database. + */ +public final class PushDecryptMessageJob extends BaseJob { + + public static final String KEY = "PushDecryptJob"; + public static final String QUEUE = "__PUSH_DECRYPT_JOB__"; + + public static final String TAG = Log.tag(PushDecryptMessageJob.class); + + private static final String KEY_SMS_MESSAGE_ID = "sms_message_id"; + private static final String KEY_ENVELOPE = "envelope"; + + private final long smsMessageId; + private final SignalServiceEnvelope envelope; + + public PushDecryptMessageJob(Context context, @NonNull SignalServiceEnvelope envelope) { + this(context, envelope, -1); + } + + public PushDecryptMessageJob(Context context, @NonNull SignalServiceEnvelope envelope, long smsMessageId) { + this(new Parameters.Builder() + .setQueue(QUEUE) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + envelope, + smsMessageId); + setContext(context); + } + + private PushDecryptMessageJob(@NonNull Parameters parameters, @NonNull SignalServiceEnvelope envelope, long smsMessageId) { + super(parameters); + + this.envelope = envelope; + this.smsMessageId = smsMessageId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putBlobAsString(KEY_ENVELOPE, envelope.serialize()) + .putLong(KEY_SMS_MESSAGE_ID, smsMessageId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws RetryLaterException { + if (needsMigration()) { + Log.w(TAG, "Migration is still needed."); + postMigrationNotification(); + throw new RetryLaterException(); + } + + List jobs = new LinkedList<>(); + DecryptionResult result = MessageDecryptionUtil.decrypt(context, envelope); + + if (result.getContent() != null) { + jobs.add(new PushProcessMessageJob(result.getContent(), smsMessageId, envelope.getTimestamp())); + } else if (result.getException() != null && result.getState() != MessageState.NOOP) { + jobs.add(new PushProcessMessageJob(result.getState(), result.getException(), smsMessageId, envelope.getTimestamp())); + } + + jobs.addAll(result.getJobs()); + + for (Job job: jobs) { + ApplicationDependencies.getJobManager().add(job); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private boolean needsMigration() { + return !IdentityKeyUtil.hasIdentityKey(context) || TextSecurePreferences.getNeedsSqlCipherMigration(context); + } + + private void postMigrationNotification() { + NotificationManagerCompat.from(context).notify(494949, + new NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setContentTitle(context.getString(R.string.PushDecryptJob_new_locked_message)) + .setContentText(context.getString(R.string.PushDecryptJob_unlock_to_view_pending_messages)) + .setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)) + .setDefaults(NotificationCompat.DEFAULT_SOUND | NotificationCompat.DEFAULT_VIBRATE) + .build()); + + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushDecryptMessageJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushDecryptMessageJob(parameters, + SignalServiceEnvelope.deserialize(data.getStringAsBlob(KEY_ENVELOPE)), + data.getLong(KEY_SMS_MESSAGE_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java new file mode 100644 index 00000000..f905db34 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -0,0 +1,404 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobLogger; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Quote; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class PushGroupSendJob extends PushSendJob { + + public static final String KEY = "PushGroupSendJob"; + + private static final String TAG = PushGroupSendJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_FILTER_RECIPIENT = "filter_recipient"; + + private final long messageId; + private final RecipientId filterRecipient; + + public PushGroupSendJob(long messageId, @NonNull RecipientId destination, @Nullable RecipientId filterRecipient, boolean hasMedia) { + this(new Job.Parameters.Builder() + .setQueue(destination.toQueueKey(hasMedia)) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + messageId, filterRecipient); + + } + + private PushGroupSendJob(@NonNull Job.Parameters parameters, long messageId, @Nullable RecipientId filterRecipient) { + super(parameters); + + this.messageId = messageId; + this.filterRecipient = filterRecipient; + } + + @WorkerThread + public static void enqueue(@NonNull Context context, + @NonNull JobManager jobManager, + long messageId, + @NonNull RecipientId destination, + @Nullable RecipientId filterAddress) + { + try { + Recipient group = Recipient.resolved(destination); + if (!group.isPushGroup()) { + throw new AssertionError("Not a group!"); + } + + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); + + if (!DatabaseFactory.getGroupDatabase(context).isActive(group.requireGroupId()) && !isGv2UpdateMessage(message)) { + throw new MmsException("Inactive group!"); + } + + jobManager.add(new PushGroupSendJob(messageId, destination, filterAddress, !attachmentUploadIds.isEmpty()), attachmentUploadIds, attachmentUploadIds.isEmpty() ? null : destination.toQueueKey()); + + } catch (NoSuchMessageException | MmsException e) { + Log.w(TAG, "Failed to enqueue message.", e); + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putString(KEY_FILTER_RECIPIENT, filterRecipient != null ? filterRecipient.serialize() : null) + .build(); + } + + private static boolean isGv2UpdateMessage(@NonNull OutgoingMediaMessage message) { + return (message instanceof OutgoingGroupUpdateMessage && ((OutgoingGroupUpdateMessage) message).isV2Group()); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + DatabaseFactory.getMmsDatabase(context).markAsSending(messageId); + } + + @Override + public void onPushSend() + throws IOException, MmsException, NoSuchMessageException, RetryLaterException + { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + List existingNetworkFailures = message.getNetworkFailures(); + List existingIdentityMismatches = message.getIdentityKeyMismatches(); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(message.getRecipient()); + ApplicationDependencies.getJobManager().cancelAllInQueue(TypingSendJob.getQueue(threadId)); + + if (database.isSent(messageId)) { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring."); + return; + } + + Recipient groupRecipient = message.getRecipient().fresh(); + + if (!groupRecipient.isPushGroup()) { + throw new MmsException("Message recipient isn't a group!"); + } + + if (groupRecipient.isPushV1Group() && FeatureFlags.groupsV1ForcedMigration()) { + throw new MmsException("No GV1 messages can be sent anymore!"); + } + + try { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Sending message: " + messageId); + + if (!groupRecipient.resolve().isProfileSharing() && !database.isGroupQuitMessage(messageId)) { + RecipientUtil.shareProfileIfFirstSecureMessage(context, groupRecipient); + } + + List target; + + if (filterRecipient != null) target = Collections.singletonList(Recipient.resolved(filterRecipient)); + else if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> Recipient.resolved(nf.getRecipientId(context))).toList(); + else target = getGroupMessageRecipients(groupRecipient.requireGroupId(), messageId); + + Map idByE164 = Stream.of(target).filter(Recipient::hasE164).collect(Collectors.toMap(Recipient::requireE164, r -> r)); + Map idByUuid = Stream.of(target).filter(Recipient::hasUuid).collect(Collectors.toMap(Recipient::requireUuid, r -> r)); + + List results = deliver(message, groupRecipient, target); + Log.i(TAG, JobLogger.format(this, "Finished send.")); + + List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(findId(result.getAddress(), idByE164, idByUuid))).toList(); + List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(findId(result.getAddress(), idByE164, idByUuid), result.getIdentityFailure().getIdentityKey())).toList(); + List successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); + List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(findId(result.getAddress(), idByE164, idByUuid), result.getSuccess().isUnidentified())).toList(); + Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet()); + List resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); + List resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); + List unregisteredRecipients = Stream.of(results).filter(SendMessageResult::isUnregisteredFailure).map(result -> Recipient.externalPush(context, result.getAddress())).toList(); + + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + for (Recipient unregistered : unregisteredRecipients) { + recipientDatabase.markUnregistered(unregistered.getId()); + } + + for (NetworkFailure resolvedFailure : resolvedNetworkFailures) { + database.removeFailure(messageId, resolvedFailure); + existingNetworkFailures.remove(resolvedFailure); + } + + for (IdentityKeyMismatch resolvedIdentity : resolvedIdentityFailures) { + database.removeMismatchedIdentity(messageId, resolvedIdentity.getRecipientId(context), resolvedIdentity.getIdentityKey()); + existingIdentityMismatches.remove(resolvedIdentity); + } + + if (!networkFailures.isEmpty()) { + database.addFailures(messageId, networkFailures); + } + + for (IdentityKeyMismatch mismatch : identityMismatches) { + database.addMismatchedIdentity(messageId, mismatch.getRecipientId(context), mismatch.getIdentityKey()); + } + + DatabaseFactory.getGroupReceiptDatabase(context).setUnidentified(successUnidentifiedStatus, messageId); + + if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) { + database.markAsSent(messageId, true); + + markAttachmentsUploaded(messageId, message); + + if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) { + database.markExpireStarted(messageId); + ApplicationContext.getInstance(context) + .getExpiringMessageManager() + .scheduleDeletion(messageId, true, message.getExpiresIn()); + } + + if (message.isViewOnce()) { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentFilesForViewOnceMessage(messageId); + } + } else if (!networkFailures.isEmpty()) { + throw new RetryLaterException(); + } else if (!identityMismatches.isEmpty()) { + database.markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + + Set mismatchRecipientIds = Stream.of(identityMismatches) + .map(mismatch -> mismatch.getRecipientId(context)) + .collect(Collectors.toSet()); + + RetrieveProfileJob.enqueue(mismatchRecipientIds); + } + } catch (UntrustedIdentityException | UndeliverableMessageException e) { + warn(TAG, String.valueOf(message.getSentTimeMillis()), e); + database.markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + } + + @Override + public void onFailure() { + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); + } + + private static @NonNull RecipientId findId(@NonNull SignalServiceAddress address, + @NonNull Map byE164, + @NonNull Map byUuid) + { + if (address.getNumber().isPresent() && byE164.containsKey(address.getNumber().get())) { + return Objects.requireNonNull(byE164.get(address.getNumber().get())).getId(); + } else if (address.getUuid().isPresent() && byUuid.containsKey(address.getUuid().get())) { + return Objects.requireNonNull(byUuid.get(address.getUuid().get())).getId(); + } else { + throw new IllegalStateException("Found an address that was never provided!"); + } + } + + private List deliver(OutgoingMediaMessage message, @NonNull Recipient groupRecipient, @NonNull List destinations) + throws IOException, UntrustedIdentityException, UndeliverableMessageException + { + try { + rotateSenderCertificateIfNecessary(); + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + GroupId.Push groupId = groupRecipient.requireGroupId().requirePush(); + Optional profileKey = getProfileKey(groupRecipient); + Optional quote = getQuoteFor(message); + Optional sticker = getStickerFor(message); + List sharedContacts = getSharedContactsFor(message); + List previews = getPreviewsFor(message); + List mentions = getMentionsFor(message.getMentions()); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); + List attachmentPointers = getAttachmentPointersFor(attachments); + boolean isRecipientUpdate = Stream.of(DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageId)) + .anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED); + + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations); + + if (message.isGroup()) { + OutgoingGroupUpdateMessage groupMessage = (OutgoingGroupUpdateMessage) message; + + if (groupMessage.isV2Group()) { + MessageGroupContext.GroupV2Properties properties = groupMessage.requireGroupV2Properties(); + GroupContextV2 groupContext = properties.getGroupContext(); + SignalServiceGroupV2.Builder builder = SignalServiceGroupV2.newBuilder(properties.getGroupMasterKey()) + .withRevision(groupContext.getRevision()); + + ByteString groupChange = groupContext.getGroupChange(); + if (groupChange != null) { + builder.withSignedGroupChange(groupChange.toByteArray()); + } + + SignalServiceGroupV2 group = builder.build(); + SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(message.getSentTimeMillis()) + .withExpiration(groupRecipient.getExpireMessages()) + .asGroupMessage(group) + .build(); + return messageSender.sendMessage(addresses, unidentifiedAccess, isRecipientUpdate, groupDataMessage); + } else { + MessageGroupContext.GroupV1Properties properties = groupMessage.requireGroupV1Properties(); + + GroupContext groupContext = properties.getGroupContext(); + SignalServiceAttachment avatar = attachmentPointers.isEmpty() ? null : attachmentPointers.get(0); + SignalServiceGroup.Type type = properties.isQuit() ? SignalServiceGroup.Type.QUIT : SignalServiceGroup.Type.UPDATE; + List members = Stream.of(groupContext.getMembersE164List()) + .map(e164 -> new SignalServiceAddress(null, e164)) + .toList(); + SignalServiceGroup group = new SignalServiceGroup(type, groupId.getDecodedId(), groupContext.getName(), members, avatar); + SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(message.getSentTimeMillis()) + .withExpiration(message.getRecipient().getExpireMessages()) + .asGroupMessage(group) + .build(); + + Log.i(TAG, JobLogger.format(this, "Beginning update send.")); + return messageSender.sendMessage(addresses, unidentifiedAccess, isRecipientUpdate, groupDataMessage); + } + } else { + SignalServiceDataMessage.Builder builder = SignalServiceDataMessage.newBuilder() + .withTimestamp(message.getSentTimeMillis()); + + GroupUtil.setDataMessageGroupContext(context, builder, groupId); + + SignalServiceDataMessage groupMessage = builder.withAttachments(attachmentPointers) + .withBody(message.getBody()) + .withExpiration((int)(message.getExpiresIn() / 1000)) + .withViewOnce(message.isViewOnce()) + .asExpirationUpdate(message.isExpirationUpdate()) + .withProfileKey(profileKey.orNull()) + .withQuote(quote.orNull()) + .withSticker(sticker.orNull()) + .withSharedContacts(sharedContacts) + .withPreviews(previews) + .withMentions(mentions) + .build(); + + Log.i(TAG, JobLogger.format(this, "Beginning message send.")); + return messageSender.sendMessage(addresses, unidentifiedAccess, isRecipientUpdate, groupMessage); + } + } catch (ServerRejectedException e) { + throw new UndeliverableMessageException(e); + } + } + + private @NonNull List getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) { + List destinations = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageId); + + if (!destinations.isEmpty()) { + return RecipientUtil.getEligibleForSending(Stream.of(destinations) + .map(GroupReceiptInfo::getRecipientId) + .map(Recipient::resolved) + .toList()); + } + + List members = Stream.of(DatabaseFactory.getGroupDatabase(context) + .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF)) + .map(Recipient::resolve) + .toList(); + + if (members.size() > 0) { + Log.w(TAG, "No destinations found for group message " + groupId + " using current group membership"); + } + + return RecipientUtil.getEligibleForSending(members); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { + String raw = data.getString(KEY_FILTER_RECIPIENT); + RecipientId filter = raw != null ? RecipientId.from(raw) : null; + + return new PushGroupSendJob(parameters, data.getLong(KEY_MESSAGE_ID), filter); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java new file mode 100644 index 00000000..928785cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Sends an update to a group without inserting a change message locally. + *

+ * An example usage would be to update a group with a profile key change. + */ +public final class PushGroupSilentUpdateSendJob extends BaseJob { + + public static final String KEY = "PushGroupSilentSendJob"; + + private static final String TAG = Log.tag(PushGroupSilentUpdateSendJob.class); + + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + private static final String KEY_TIMESTAMP = "timestamp"; + private static final String KEY_GROUP_CONTEXT_V2 = "group_context_v2"; + + private final List recipients; + private final int initialRecipientCount; + private final SignalServiceProtos.GroupContextV2 groupContextV2; + private final long timestamp; + + @WorkerThread + public static @NonNull Job create(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull DecryptedGroup decryptedGroup, + @NonNull OutgoingGroupUpdateMessage groupMessage) + { + List memberUuids = DecryptedGroupUtil.toUuidList(decryptedGroup.getMembersList()); + List pendingUuids = DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList()); + + Set recipients = Stream.concat(Stream.of(memberUuids), Stream.of(pendingUuids)) + .filter(uuid -> !UuidUtil.UNKNOWN_UUID.equals(uuid)) + .filter(uuid -> !Recipient.self().getUuid().get().equals(uuid)) + .map(uuid -> Recipient.externalPush(context, uuid, null, false)) + .filter(recipient -> recipient.getRegistered() != RecipientDatabase.RegisteredState.NOT_REGISTERED) + .map(Recipient::getId) + .collect(Collectors.toSet()); + + MessageGroupContext.GroupV2Properties properties = groupMessage.requireGroupV2Properties(); + SignalServiceProtos.GroupContextV2 groupContext = properties.getGroupContext(); + + String queue = Recipient.externalGroupExact(context, groupId).getId().toQueueKey(); + + return new PushGroupSilentUpdateSendJob(new ArrayList<>(recipients), + recipients.size(), + groupMessage.getSentTimeMillis(), + groupContext, + new Parameters.Builder() + .setQueue(queue) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private PushGroupSilentUpdateSendJob(@NonNull List recipients, + int initialRecipientCount, + long timestamp, + @NonNull SignalServiceProtos.GroupContextV2 groupContextV2, + @NonNull Parameters parameters) + { + super(parameters); + + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + this.groupContextV2 = groupContextV2; + this.timestamp = timestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .putLong(KEY_TIMESTAMP, timestamp) + .putString(KEY_GROUP_CONTEXT_V2, Base64.encodeBytes(groupContextV2.toByteArray())) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(destinations); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send remote delete to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") ); + } + + private @NonNull List deliver(@NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations);; + + SignalServiceGroupV2 group = SignalServiceGroupV2.fromProtobuf(groupContextV2); + SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(timestamp) + .asGroupMessage(group) + .build(); + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, groupDataMessage); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull PushGroupSilentUpdateSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + long timestamp = data.getLong(KEY_TIMESTAMP); + byte[] contextBytes = Base64.decodeOrThrow(data.getString(KEY_GROUP_CONTEXT_V2)); + + SignalServiceProtos.GroupContextV2 groupContextV2; + try { + groupContextV2 = SignalServiceProtos.GroupContextV2.parseFrom(contextBytes); + } catch (InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + + return new PushGroupSilentUpdateSendJob(recipients, initialRecipientCount, timestamp, groupContextV2, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java new file mode 100644 index 00000000..4a30ac13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java @@ -0,0 +1,145 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup.Type; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class PushGroupUpdateJob extends BaseJob { + + public static final String KEY = "PushGroupUpdateJob"; + + private static final String TAG = PushGroupUpdateJob.class.getSimpleName(); + + private static final String KEY_SOURCE = "source"; + private static final String KEY_GROUP_ID = "group_id"; + + private final RecipientId source; + private final GroupId groupId; + + public PushGroupUpdateJob(@NonNull RecipientId source, @NonNull GroupId groupId) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + source, + groupId); + } + + private PushGroupUpdateJob(@NonNull Job.Parameters parameters, RecipientId source, @NonNull GroupId groupId) { + super(parameters); + + this.source = source; + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_SOURCE, source.serialize()) + .putString(KEY_GROUP_ID, groupId.toString()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional record = groupDatabase.getGroup(groupId); + SignalServiceAttachment avatar = null; + + if (record == null || !record.isPresent()) { + Log.w(TAG, "No information for group record info request: " + groupId.toString()); + return; + } + + if (AvatarHelper.hasAvatar(context, record.get().getRecipientId())) { + avatar = SignalServiceAttachmentStream.newStreamBuilder() + .withContentType("image/jpeg") + .withStream(AvatarHelper.getAvatar(context, record.get().getRecipientId())) + .withLength(AvatarHelper.getAvatarLength(context, record.get().getRecipientId())) + .build(); + } + + List members = new LinkedList<>(); + + for (RecipientId member : record.get().getMembers()) { + Recipient recipient = Recipient.resolved(member); + members.add(RecipientUtil.toSignalServiceAddress(context, recipient)); + } + + SignalServiceGroup groupContext = SignalServiceGroup.newBuilder(Type.UPDATE) + .withAvatar(avatar) + .withId(groupId.getDecodedId()) + .withMembers(members) + .withName(record.get().getTitle()) + .build(); + + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + SignalServiceDataMessage message = SignalServiceDataMessage.newBuilder() + .asGroupMessage(groupContext) + .withTimestamp(System.currentTimeMillis()) + .withExpiration(groupRecipient.getExpireMessages()) + .build(); + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(source); + + messageSender.sendMessage(RecipientUtil.toSignalServiceAddress(context, recipient), + UnidentifiedAccessUtil.getAccessFor(context, recipient), + message); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + Log.w(TAG, e); + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushGroupUpdateJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { + return new PushGroupUpdateJob(parameters, + RecipientId.from(data.getString(KEY_SOURCE)), + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java new file mode 100644 index 00000000..2783ca0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -0,0 +1,243 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +public class PushMediaSendJob extends PushSendJob { + + public static final String KEY = "PushMediaSendJob"; + + private static final String TAG = PushMediaSendJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private long messageId; + + public PushMediaSendJob(long messageId, @NonNull Recipient recipient) { + this(constructParameters(recipient, true), messageId); + } + + private PushMediaSendJob(Job.Parameters parameters, long messageId) { + super(parameters); + this.messageId = messageId; + } + + @WorkerThread + public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Recipient recipient) { + try { + if (!recipient.hasServiceIdentifier()) { + throw new AssertionError(); + } + + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); + + jobManager.add(new PushMediaSendJob(messageId, recipient), attachmentUploadIds, recipient.getId().toQueueKey()); + + } catch (NoSuchMessageException | MmsException e) { + Log.w(TAG, "Failed to enqueue message.", e); + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + DatabaseFactory.getMmsDatabase(context).markAsSending(messageId); + } + + @Override + public void onPushSend() + throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException + { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + + if (database.isSent(messageId)) { + warn(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring."); + return; + } + + try { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Sending message: " + messageId); + + RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient()); + + Recipient recipient = message.getRecipient().fresh(); + byte[] profileKey = recipient.getProfileKey(); + UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); + + boolean unidentified = deliver(message); + + database.markAsSent(messageId, true); + markAttachmentsUploaded(messageId, message); + database.markUnidentified(messageId, unidentified); + + if (recipient.isSelf()) { + SyncMessageId id = new SyncMessageId(recipient.getId(), message.getSentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCount(id, System.currentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementReadReceiptCount(id, System.currentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementViewedReceiptCount(id, System.currentTimeMillis()); + } + + if (unidentified && accessMode == UnidentifiedAccessMode.UNKNOWN && profileKey == null) { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Marking recipient as UD-unrestricted following a UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.UNRESTRICTED); + } else if (unidentified && accessMode == UnidentifiedAccessMode.UNKNOWN) { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Marking recipient as UD-enabled following a UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.ENABLED); + } else if (!unidentified && accessMode != UnidentifiedAccessMode.DISABLED) { + log(TAG, String.valueOf(message.getSentTimeMillis()), "Marking recipient as UD-disabled following a non-UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.DISABLED); + } + + if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) { + database.markExpireStarted(messageId); + expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn()); + } + + if (message.isViewOnce()) { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentFilesForViewOnceMessage(messageId); + } + + log(TAG, String.valueOf(message.getSentTimeMillis()), "Sent message: " + messageId); + + } catch (InsecureFallbackApprovalException ifae) { + warn(TAG, "Failure", ifae); + database.markAsPendingInsecureSmsFallback(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } catch (UntrustedIdentityException uie) { + warn(TAG, "Failure", uie); + RecipientId recipientId = Recipient.external(context, uie.getIdentifier()).getId(); + database.addMismatchedIdentity(messageId, recipientId, uie.getIdentityKey()); + database.markAsSentFailed(messageId); + RetrieveProfileJob.enqueue(recipientId); + } + } + + @Override + public void onFailure() { + DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); + notifyMediaMessageDeliveryFailed(context, messageId); + } + + private boolean deliver(OutgoingMediaMessage message) + throws IOException, InsecureFallbackApprovalException, UntrustedIdentityException, UndeliverableMessageException + { + if (message.getRecipient() == null) { + throw new UndeliverableMessageException("No destination address."); + } + + try { + rotateSenderCertificateIfNecessary(); + + Recipient messageRecipient = message.getRecipient().fresh(); + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, messageRecipient); + List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); + List serviceAttachments = getAttachmentPointersFor(attachments); + Optional profileKey = getProfileKey(messageRecipient); + Optional quote = getQuoteFor(message); + Optional sticker = getStickerFor(message); + List sharedContacts = getSharedContactsFor(message); + List previews = getPreviewsFor(message); + SignalServiceDataMessage mediaMessage = SignalServiceDataMessage.newBuilder() + .withBody(message.getBody()) + .withAttachments(serviceAttachments) + .withTimestamp(message.getSentTimeMillis()) + .withExpiration((int)(message.getExpiresIn() / 1000)) + .withViewOnce(message.isViewOnce()) + .withProfileKey(profileKey.orNull()) + .withQuote(quote.orNull()) + .withSticker(sticker.orNull()) + .withSharedContacts(sharedContacts) + .withPreviews(previews) + .asExpirationUpdate(message.isExpirationUpdate()) + .build(); + + if (Util.equals(TextSecurePreferences.getLocalUuid(context), address.getUuid().orNull())) { + Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); + SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, mediaMessage, syncAccess); + + messageSender.sendMessage(syncMessage, syncAccess); + return syncAccess.isPresent(); + } else { + return messageSender.sendMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), mediaMessage).getSuccess().isUnidentified(); + } + } catch (UnregisteredUserException e) { + warn(TAG, String.valueOf(message.getSentTimeMillis()), e); + throw new InsecureFallbackApprovalException(e); + } catch (FileNotFoundException e) { + warn(TAG, String.valueOf(message.getSentTimeMillis()), e); + throw new UndeliverableMessageException(e); + } catch (ServerRejectedException e) { + throw new UndeliverableMessageException(e); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushMediaSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushMediaSendJob(parameters, data.getLong(KEY_MESSAGE_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushNotificationReceiveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushNotificationReceiveJob.java new file mode 100644 index 00000000..20a4a2a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushNotificationReceiveJob.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever; +import org.thoughtcrime.securesms.messages.RestStrategy; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + +public final class PushNotificationReceiveJob extends BaseJob { + + public static final String KEY = "PushNotificationReceiveJob"; + + private static final String TAG = Log.tag(PushNotificationReceiveJob.class); + + private static final String KEY_FOREGROUND_SERVICE_DELAY = "foreground_delay"; + + private final long foregroundServiceDelayMs; + + public PushNotificationReceiveJob() { + this(BackgroundMessageRetriever.DO_NOT_SHOW_IN_FOREGROUND); + } + + private PushNotificationReceiveJob(long foregroundServiceDelayMs) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("__notification_received") + .setMaxAttempts(3) + .setMaxInstancesForFactory(1) + .build(), + foregroundServiceDelayMs); + } + + private PushNotificationReceiveJob(@NonNull Job.Parameters parameters, long foregroundServiceDelayMs) { + super(parameters); + this.foregroundServiceDelayMs = foregroundServiceDelayMs; + } + + public static Job withDelayedForegroundService(long foregroundServiceAfterMs) { + return new PushNotificationReceiveJob(foregroundServiceAfterMs); + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_FOREGROUND_SERVICE_DELAY, foregroundServiceDelayMs) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + BackgroundMessageRetriever retriever = ApplicationDependencies.getBackgroundMessageRetriever(); + boolean result = retriever.retrieveMessages(context, foregroundServiceDelayMs, new RestStrategy()); + + if (result) { + Log.i(TAG, "Successfully pulled messages."); + } else { + throw new PushNetworkException("Failed to pull messages."); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + Log.w(TAG, e); + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "***** Failed to download pending message!"); +// MessageNotifier.notifyMessagesPending(getContext()); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushNotificationReceiveJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushNotificationReceiveJob(parameters, + data.getLongOrDefault(KEY_FOREGROUND_SERVICE_DELAY, BackgroundMessageRetriever.DO_NOT_SHOW_IN_FOREGROUND)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java new file mode 100644 index 00000000..8a203522 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -0,0 +1,231 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.messages.MessageContentProcessor; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class PushProcessMessageJob extends BaseJob { + + public static final String KEY = "PushProcessJob"; + public static final String QUEUE_PREFIX = "__PUSH_PROCESS_JOB__"; + + public static final String TAG = Log.tag(PushProcessMessageJob.class); + + private static final String KEY_MESSAGE_STATE = "message_state"; + private static final String KEY_MESSAGE_PLAINTEXT = "message_content"; + private static final String KEY_SMS_MESSAGE_ID = "sms_message_id"; + private static final String KEY_TIMESTAMP = "timestamp"; + private static final String KEY_EXCEPTION_SENDER = "exception_sender"; + private static final String KEY_EXCEPTION_DEVICE = "exception_device"; + private static final String KEY_EXCEPTION_GROUP_ID = "exception_groupId"; + + @NonNull private final MessageState messageState; + @Nullable private final SignalServiceContent content; + @Nullable private final ExceptionMetadata exceptionMetadata; + private final long smsMessageId; + private final long timestamp; + + @WorkerThread + PushProcessMessageJob(@NonNull SignalServiceContent content, + long smsMessageId, + long timestamp) + { + this(MessageState.DECRYPTED_OK, + content, + null, + smsMessageId, + timestamp); + } + + @WorkerThread + PushProcessMessageJob(@NonNull MessageState messageState, + @NonNull ExceptionMetadata exceptionMetadata, + long smsMessageId, + long timestamp) + { + this(messageState, + null, + exceptionMetadata, + smsMessageId, + timestamp); + } + + @WorkerThread + public PushProcessMessageJob(@NonNull MessageState messageState, + @Nullable SignalServiceContent content, + @Nullable ExceptionMetadata exceptionMetadata, + long smsMessageId, + long timestamp) + { + this(createParameters(content, exceptionMetadata), + messageState, + content, + exceptionMetadata, + smsMessageId, + timestamp); + } + + private PushProcessMessageJob(@NonNull Parameters parameters, + @NonNull MessageState messageState, + @Nullable SignalServiceContent content, + @Nullable ExceptionMetadata exceptionMetadata, + long smsMessageId, + long timestamp) + { + super(parameters); + + this.messageState = messageState; + this.exceptionMetadata = exceptionMetadata; + this.content = content; + this.smsMessageId = smsMessageId; + this.timestamp = timestamp; + } + + public static @NonNull String getQueueName(@NonNull RecipientId recipientId) { + return QUEUE_PREFIX + recipientId.toQueueKey(); + } + + @WorkerThread + private static @NonNull Parameters createParameters(@Nullable SignalServiceContent content, @Nullable ExceptionMetadata exceptionMetadata) { + Context context = ApplicationDependencies.getApplication(); + String queueName = QUEUE_PREFIX; + Parameters.Builder builder = new Parameters.Builder() + .setMaxAttempts(Parameters.UNLIMITED); + + if (content != null) { + SignalServiceGroupContext signalServiceGroupContext = GroupUtil.getGroupContextIfPresent(content); + + if (signalServiceGroupContext != null) { + try { + GroupId groupId = GroupUtil.idFromGroupContext(signalServiceGroupContext); + + queueName = getQueueName(Recipient.externalPossiblyMigratedGroup(context, groupId).getId()); + + if (groupId.isV2()) { + int localRevision = DatabaseFactory.getGroupDatabase(context) + .getGroupV2Revision(groupId.requireV2()); + + if (signalServiceGroupContext.getGroupV2().get().getRevision() > localRevision || + DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) + { + Log.i(TAG, "Adding network constraint to group-related job."); + builder.addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(30)); + } + } + } catch (BadGroupIdException e) { + Log.w(TAG, "Bad groupId! Using default queue. ID: " + content.getTimestamp()); + } + } else { + queueName = getQueueName(RecipientId.fromHighTrust(content.getSender())); + } + } else if (exceptionMetadata != null) { + Recipient recipient = exceptionMetadata.getGroupId() != null ? Recipient.externalPossiblyMigratedGroup(context, exceptionMetadata.getGroupId()) + : Recipient.external(context, exceptionMetadata.getSender()); + queueName = getQueueName(recipient.getId()); + } + + builder.setQueue(queueName); + + return builder.build(); + } + + @Override + public @NonNull Data serialize() { + Data.Builder dataBuilder = new Data.Builder() + .putInt(KEY_MESSAGE_STATE, messageState.ordinal()) + .putLong(KEY_SMS_MESSAGE_ID, smsMessageId) + .putLong(KEY_TIMESTAMP, timestamp); + + if (messageState == MessageState.DECRYPTED_OK) { + dataBuilder.putString(KEY_MESSAGE_PLAINTEXT, Base64.encodeBytes(Objects.requireNonNull(content).serialize())); + } else { + Objects.requireNonNull(exceptionMetadata); + dataBuilder.putString(KEY_EXCEPTION_SENDER, exceptionMetadata.getSender()) + .putInt(KEY_EXCEPTION_DEVICE, exceptionMetadata.getSenderDevice()) + .putString(KEY_EXCEPTION_GROUP_ID, exceptionMetadata.getGroupId() == null ? null : exceptionMetadata.getGroupId().toString()); + } + + return dataBuilder.build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws Exception { + MessageContentProcessor processor = new MessageContentProcessor(context); + processor.process(messageState, content, exceptionMetadata, timestamp, smsMessageId); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PushProcessMessageJob create(@NonNull Parameters parameters, @NonNull Data data) { + try { + MessageState state = MessageState.values()[data.getInt(KEY_MESSAGE_STATE)]; + + if (state == MessageState.DECRYPTED_OK) { + return new PushProcessMessageJob(parameters, + state, + SignalServiceContent.deserialize(Base64.decode(data.getString(KEY_MESSAGE_PLAINTEXT))), + null, + data.getLong(KEY_SMS_MESSAGE_ID), + data.getLong(KEY_TIMESTAMP)); + } else { + ExceptionMetadata exceptionMetadata = new ExceptionMetadata(data.getString(KEY_EXCEPTION_SENDER), + data.getInt(KEY_EXCEPTION_DEVICE), + GroupId.parseNullableOrThrow(data.getStringOrDefault(KEY_EXCEPTION_GROUP_ID, null))); + + return new PushProcessMessageJob(parameters, + state, + null, + exceptionMetadata, + data.getLong(KEY_SMS_MESSAGE_ID), + data.getLong(KEY_TIMESTAMP)); + } + } catch (IOException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushReceivedJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushReceivedJob.java new file mode 100644 index 00000000..6b2cd193 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushReceivedJob.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.jobs; + +import org.thoughtcrime.securesms.jobmanager.Job; + +public abstract class PushReceivedJob extends BaseJob { + + private static final String TAG = PushReceivedJob.class.getSimpleName(); + + + protected PushReceivedJob(Job.Parameters parameters) { + super(parameters); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java new file mode 100644 index 00000000..bddc0237 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -0,0 +1,425 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; +import android.graphics.Bitmap; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.signal.libsignal.metadata.certificate.InvalidCertificateException; +import org.signal.libsignal.metadata.certificate.SenderCertificate; +import org.thoughtcrime.securesms.TextSecureExpiredException; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactModelMapper; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.CertificateType; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview; +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public abstract class PushSendJob extends SendJob { + + private static final String TAG = PushSendJob.class.getSimpleName(); + private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1); + + protected PushSendJob(Job.Parameters parameters) { + super(parameters); + } + + protected static Job.Parameters constructParameters(@NonNull Recipient recipient, boolean hasMedia) { + return new Parameters.Builder() + .setQueue(recipient.getId().toQueueKey(hasMedia)) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(); + } + + @Override + protected final void onSend() throws Exception { + if (TextSecurePreferences.getSignedPreKeyFailureCount(context) > 5) { + ApplicationDependencies.getJobManager().add(new RotateSignedPreKeyJob()); + throw new TextSecureExpiredException("Too many signed prekey rotation failures"); + } + + onPushSend(); + } + + @Override + public void onRetry() { + super.onRetry(); + Log.i(TAG, "onRetry()"); + + if (getRunAttempt() > 1) { + Log.i(TAG, "Scheduling service outage detection job."); + ApplicationDependencies.getJobManager().add(new ServiceOutageDetectionJob()); + } + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof ServerRejectedException) { + return false; + } + + return exception instanceof IOException || + exception instanceof RetryLaterException; + } + + @Override + public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) { + if (exception instanceof NonSuccessfulResponseCodeException) { + if (((NonSuccessfulResponseCodeException) exception).is5xx()) { + return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getServerErrorMaxBackoff()); + } + } + + return super.getNextRunAttemptBackoff(pastAttemptCount, exception); + } + + protected Optional getProfileKey(@NonNull Recipient recipient) { + if (!recipient.resolve().isSystemContact() && !recipient.resolve().isProfileSharing()) { + return Optional.absent(); + } + + return Optional.of(ProfileKeyUtil.getProfileKey(context)); + } + + protected SignalServiceAttachment getAttachmentFor(Attachment attachment) { + try { + if (attachment.getUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!"); + InputStream is = PartAuthority.getAttachmentStream(context, attachment.getUri()); + return SignalServiceAttachment.newStreamBuilder() + .withStream(is) + .withContentType(attachment.getContentType()) + .withLength(attachment.getSize()) + .withFileName(attachment.getFileName()) + .withVoiceNote(attachment.isVoiceNote()) + .withBorderless(attachment.isBorderless()) + .withWidth(attachment.getWidth()) + .withHeight(attachment.getHeight()) + .withCaption(attachment.getCaption()) + .withListener((total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress))) + .build(); + } catch (IOException ioe) { + Log.w(TAG, "Couldn't open attachment", ioe); + } + return null; + } + + protected static Set enqueueCompressingAndUploadAttachmentsChains(@NonNull JobManager jobManager, OutgoingMediaMessage message) { + List attachments = new LinkedList<>(); + + attachments.addAll(message.getAttachments()); + + attachments.addAll(Stream.of(message.getLinkPreviews()) + .map(LinkPreview::getThumbnail) + .filter(Optional::isPresent) + .map(Optional::get) + .toList()); + + attachments.addAll(Stream.of(message.getSharedContacts()) + .map(Contact::getAvatar).withoutNulls() + .map(Contact.Avatar::getAttachment).withoutNulls() + .toList()); + + return new HashSet<>(Stream.of(attachments).map(a -> { + AttachmentUploadJob attachmentUploadJob = new AttachmentUploadJob(((DatabaseAttachment) a).getAttachmentId()); + + if (message.isGroup()) { + jobManager.startChain(AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1)) + .then(attachmentUploadJob) + .enqueue(); + } else { + jobManager.startChain(AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1)) + .then(new ResumableUploadSpecJob()) + .then(attachmentUploadJob) + .enqueue(); + } + + return attachmentUploadJob.getId(); + }) + .toList()); + } + + protected @NonNull List getAttachmentPointersFor(List attachments) { + return Stream.of(attachments).map(this::getAttachmentPointerFor).filter(a -> a != null).toList(); + } + + protected @Nullable SignalServiceAttachment getAttachmentPointerFor(Attachment attachment) { + if (TextUtils.isEmpty(attachment.getLocation())) { + Log.w(TAG, "empty content id"); + return null; + } + + if (TextUtils.isEmpty(attachment.getKey())) { + Log.w(TAG, "empty encrypted key"); + return null; + } + + try { + final SignalServiceAttachmentRemoteId remoteId = SignalServiceAttachmentRemoteId.from(attachment.getLocation()); + final byte[] key = Base64.decode(attachment.getKey()); + + int width = attachment.getWidth(); + int height = attachment.getHeight(); + + if ((width == 0 || height == 0) && MediaUtil.hasVideoThumbnail(context, attachment.getUri())) { + Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, attachment.getUri(), 1000); + + if (thumbnail != null) { + width = thumbnail.getWidth(); + height = thumbnail.getHeight(); + } + } + + return new SignalServiceAttachmentPointer(attachment.getCdnNumber(), + remoteId, + attachment.getContentType(), + key, + Optional.of(Util.toIntExact(attachment.getSize())), + Optional.absent(), + width, + height, + Optional.fromNullable(attachment.getDigest()), + Optional.fromNullable(attachment.getFileName()), + attachment.isVoiceNote(), + attachment.isBorderless(), + Optional.fromNullable(attachment.getCaption()), + Optional.fromNullable(attachment.getBlurHash()).transform(BlurHash::getHash), + attachment.getUploadTimestamp()); + } catch (IOException | ArithmeticException e) { + Log.w(TAG, e); + return null; + } + } + + protected static void notifyMediaMessageDeliveryFailed(Context context, long messageId) { + long threadId = DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId); + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (threadId != -1 && recipient != null) { + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, recipient, threadId); + } + } + + protected Optional getQuoteFor(OutgoingMediaMessage message) throws IOException { + if (message.getOutgoingQuote() == null) return Optional.absent(); + + long quoteId = message.getOutgoingQuote().getId(); + String quoteBody = message.getOutgoingQuote().getText(); + RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor(); + List quoteMentions = getMentionsFor(message.getOutgoingQuote().getMentions()); + List quoteAttachments = new LinkedList<>(); + List filteredAttachments = Stream.of(message.getOutgoingQuote().getAttachments()) + .filterNot(a -> MediaUtil.isViewOnceType(a.getContentType())) + .toList(); + + for (Attachment attachment : filteredAttachments) { + BitmapUtil.ScaleResult thumbnailData = null; + SignalServiceAttachment thumbnail = null; + String thumbnailType = MediaUtil.IMAGE_JPEG; + + try { + if (MediaUtil.isImageType(attachment.getContentType()) && attachment.getUri() != null) { + Bitmap.CompressFormat format = BitmapUtil.getCompressFormatForContentType(attachment.getContentType()); + + thumbnailData = BitmapUtil.createScaledBytes(context, new DecryptableStreamUriLoader.DecryptableUri(attachment.getUri()), 100, 100, 500 * 1024, format); + thumbnailType = attachment.getContentType(); + } else if (Build.VERSION.SDK_INT >= 23 && MediaUtil.isVideoType(attachment.getContentType()) && attachment.getUri() != null) { + Bitmap bitmap = MediaUtil.getVideoThumbnail(context, attachment.getUri(), 1000); + + if (bitmap != null) { + thumbnailData = BitmapUtil.createScaledBytes(context, bitmap, 100, 100, 500 * 1024); + } + } + + if (thumbnailData != null) { + SignalServiceAttachment.Builder builder = SignalServiceAttachment.newStreamBuilder() + .withContentType(thumbnailType) + .withWidth(thumbnailData.getWidth()) + .withHeight(thumbnailData.getHeight()) + .withLength(thumbnailData.getBitmap().length) + .withStream(new ByteArrayInputStream(thumbnailData.getBitmap())) + .withResumableUploadSpec(ApplicationDependencies.getSignalServiceMessageSender().getResumableUploadSpec()); + + thumbnail = builder.build(); + } + + quoteAttachments.add(new SignalServiceDataMessage.Quote.QuotedAttachment(attachment.getContentType(), + attachment.getFileName(), + thumbnail)); + } catch (BitmapDecodingException e) { + Log.w(TAG, e); + } + } + + Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor); + SignalServiceAddress quoteAddress = RecipientUtil.toSignalServiceAddress(context, quoteAuthorRecipient); + return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAddress, quoteBody, quoteAttachments, quoteMentions)); + } + + protected Optional getStickerFor(OutgoingMediaMessage message) { + Attachment stickerAttachment = Stream.of(message.getAttachments()).filter(Attachment::isSticker).findFirst().orElse(null); + + if (stickerAttachment == null) { + return Optional.absent(); + } + + try { + byte[] packId = Hex.fromStringCondensed(stickerAttachment.getSticker().getPackId()); + byte[] packKey = Hex.fromStringCondensed(stickerAttachment.getSticker().getPackKey()); + int stickerId = stickerAttachment.getSticker().getStickerId(); + StickerRecord record = DatabaseFactory.getStickerDatabase(context).getSticker(stickerAttachment.getSticker().getPackId(), stickerId, false); + String emoji = record != null ? record.getEmoji() : null; + SignalServiceAttachment attachment = getAttachmentPointerFor(stickerAttachment); + + return Optional.of(new SignalServiceDataMessage.Sticker(packId, packKey, stickerId, emoji, attachment)); + } catch (IOException e) { + Log.w(TAG, "Failed to decode sticker id/key", e); + return Optional.absent(); + } + } + + List getSharedContactsFor(OutgoingMediaMessage mediaMessage) { + List sharedContacts = new LinkedList<>(); + + for (Contact contact : mediaMessage.getSharedContacts()) { + SharedContact.Builder builder = ContactModelMapper.localToRemoteBuilder(contact); + SharedContact.Avatar avatar = null; + + if (contact.getAvatar() != null && contact.getAvatar().getAttachment() != null) { + avatar = SharedContact.Avatar.newBuilder().withAttachment(getAttachmentFor(contact.getAvatarAttachment())) + .withProfileFlag(contact.getAvatar().isProfile()) + .build(); + } + + builder.setAvatar(avatar); + sharedContacts.add(builder.build()); + } + + return sharedContacts; + } + + List getPreviewsFor(OutgoingMediaMessage mediaMessage) { + return Stream.of(mediaMessage.getLinkPreviews()).map(lp -> { + SignalServiceAttachment attachment = lp.getThumbnail().isPresent() ? getAttachmentPointerFor(lp.getThumbnail().get()) : null; + return new Preview(lp.getUrl(), lp.getTitle(), lp.getDescription(), lp.getDate(), Optional.fromNullable(attachment)); + }).toList(); + } + + List getMentionsFor(@NonNull List mentions) { + return Stream.of(mentions) + .map(m -> new SignalServiceDataMessage.Mention(Recipient.resolved(m.getRecipientId()).requireUuid(), m.getStart(), m.getLength())) + .toList(); + } + + protected void rotateSenderCertificateIfNecessary() throws IOException { + try { + Collection requiredCertificateTypes = SignalStore.phoneNumberPrivacy() + .getRequiredCertificateTypes(); + + Log.i(TAG, "Ensuring we have these certificates " + requiredCertificateTypes); + + for (CertificateType certificateType : requiredCertificateTypes) { + + byte[] certificateBytes = SignalStore.certificateValues() + .getUnidentifiedAccessCertificate(certificateType); + + if (certificateBytes == null) { + throw new InvalidCertificateException(String.format("No certificate %s was present.", certificateType)); + } + + SenderCertificate certificate = new SenderCertificate(certificateBytes); + + if (System.currentTimeMillis() > (certificate.getExpiration() - CERTIFICATE_EXPIRATION_BUFFER)) { + throw new InvalidCertificateException(String.format(Locale.US, "Certificate %s is expired, or close to it. Expires on: %d, currently: %d", certificateType, certificate.getExpiration(), System.currentTimeMillis())); + } + Log.d(TAG, String.format("Certificate %s is valid", certificateType)); + } + + Log.d(TAG, "All certificates are valid."); + } catch (InvalidCertificateException e) { + Log.w(TAG, "A certificate was invalid at send time. Fetching new ones.", e); + if (!ApplicationDependencies.getJobManager().runSynchronously(new RotateCertificateJob(), 5000).isPresent()) { + throw new IOException("Timeout rotating certificate"); + } + } + } + + protected SignalServiceSyncMessage buildSelfSendSyncMessage(@NonNull Context context, @NonNull SignalServiceDataMessage message, Optional syncAccess) { + SignalServiceAddress localAddress = new SignalServiceAddress(TextSecurePreferences.getLocalUuid(context), TextSecurePreferences.getLocalNumber(context)); + SentTranscriptMessage transcript = new SentTranscriptMessage(Optional.of(localAddress), + message.getTimestamp(), + message, + message.getExpiresInSeconds(), + Collections.singletonMap(localAddress, syncAccess.isPresent()), + false); + return SignalServiceSyncMessage.forSentTranscript(transcript); + } + + + protected abstract void onPushSend() throws Exception; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java new file mode 100644 index 00000000..86a16248 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class PushTextSendJob extends PushSendJob { + + public static final String KEY = "PushTextSendJob"; + + private static final String TAG = PushTextSendJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + + private long messageId; + + public PushTextSendJob(long messageId, @NonNull Recipient recipient) { + this(constructParameters(recipient, false), messageId); + } + + private PushTextSendJob(@NonNull Job.Parameters parameters, long messageId) { + super(parameters); + this.messageId = messageId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + DatabaseFactory.getSmsDatabase(context).markAsSending(messageId); + } + + @Override + public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + SmsMessageRecord record = database.getSmsMessage(messageId); + + if (!record.isPending() && !record.isFailed()) { + warn(TAG, String.valueOf(record.getDateSent()), "Message " + messageId + " was already sent. Ignoring."); + return; + } + + try { + log(TAG, String.valueOf(record.getDateSent()), "Sending message: " + messageId); + + RecipientUtil.shareProfileIfFirstSecureMessage(context, record.getRecipient()); + + Recipient recipient = record.getRecipient().fresh(); + byte[] profileKey = recipient.getProfileKey(); + UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); + + boolean unidentified = deliver(record); + + database.markAsSent(messageId, true); + database.markUnidentified(messageId, unidentified); + + if (recipient.isSelf()) { + SyncMessageId id = new SyncMessageId(recipient.getId(), record.getDateSent()); + DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCount(id, System.currentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementReadReceiptCount(id, System.currentTimeMillis()); + } + + if (unidentified && accessMode == UnidentifiedAccessMode.UNKNOWN && profileKey == null) { + log(TAG, String.valueOf(record.getDateSent()), "Marking recipient as UD-unrestricted following a UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.UNRESTRICTED); + } else if (unidentified && accessMode == UnidentifiedAccessMode.UNKNOWN) { + log(TAG, String.valueOf(record.getDateSent()), "Marking recipient as UD-enabled following a UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.ENABLED); + } else if (!unidentified && accessMode != UnidentifiedAccessMode.DISABLED) { + log(TAG, String.valueOf(record.getDateSent()), "Marking recipient as UD-disabled following a non-UD send."); + DatabaseFactory.getRecipientDatabase(context).setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.DISABLED); + } + + if (record.getExpiresIn() > 0) { + database.markExpireStarted(messageId); + expirationManager.scheduleDeletion(record.getId(), record.isMms(), record.getExpiresIn()); + } + + log(TAG, String.valueOf(record.getDateSent()), "Sent message: " + messageId); + + } catch (InsecureFallbackApprovalException e) { + warn(TAG, String.valueOf(record.getDateSent()), "Failure", e); + database.markAsPendingInsecureSmsFallback(record.getId()); + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, record.getRecipient(), record.getThreadId()); + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } catch (UntrustedIdentityException e) { + warn(TAG, String.valueOf(record.getDateSent()), "Failure", e); + RecipientId recipientId = Recipient.external(context, e.getIdentifier()).getId(); + database.addMismatchedIdentity(record.getId(), recipientId, e.getIdentityKey()); + database.markAsSentFailed(record.getId()); + database.markAsPush(record.getId()); + RetrieveProfileJob.enqueue(recipientId); + } + } + + @Override + public void onFailure() { + DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageId); + + long threadId = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (threadId != -1 && recipient != null) { + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, recipient, threadId); + } + } + + private boolean deliver(SmsMessageRecord message) + throws UntrustedIdentityException, InsecureFallbackApprovalException, UndeliverableMessageException, IOException + { + try { + rotateSenderCertificateIfNecessary(); + + Recipient messageRecipient = message.getIndividualRecipient().fresh(); + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, messageRecipient); + Optional profileKey = getProfileKey(messageRecipient); + Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, messageRecipient); + + log(TAG, String.valueOf(message.getDateSent()), "Have access key to use: " + unidentifiedAccess.isPresent()); + + SignalServiceDataMessage textSecureMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(message.getDateSent()) + .withBody(message.getBody()) + .withExpiration((int)(message.getExpiresIn() / 1000)) + .withProfileKey(profileKey.orNull()) + .asEndSessionMessage(message.isEndSession()) + .build(); + + if (Util.equals(TextSecurePreferences.getLocalUuid(context), address.getUuid().orNull())) { + Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); + SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, textSecureMessage, syncAccess); + + messageSender.sendMessage(syncMessage, syncAccess); + return syncAccess.isPresent(); + } else { + return messageSender.sendMessage(address, unidentifiedAccess, textSecureMessage).getSuccess().isUnidentified(); + } + } catch (UnregisteredUserException e) { + warn(TAG, "Failure", e); + throw new InsecureFallbackApprovalException(e); + } catch (ServerRejectedException e) { + throw new UndeliverableMessageException(e); + } + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull PushTextSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushTextSendJob(parameters, data.getLong(KEY_MESSAGE_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java new file mode 100644 index 00000000..bec6170c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -0,0 +1,260 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class ReactionSendJob extends BaseJob { + + public static final String KEY = "ReactionSendJob"; + + private static final String TAG = Log.tag(ReactionSendJob.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_IS_MMS = "is_mms"; + private static final String KEY_REACTION_EMOJI = "reaction_emoji"; + private static final String KEY_REACTION_AUTHOR = "reaction_author"; + private static final String KEY_REACTION_DATE_SENT = "reaction_date_sent"; + private static final String KEY_REACTION_DATE_RECEIVED = "reaction_date_received"; + private static final String KEY_REMOVE = "remove"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final long messageId; + private final boolean isMms; + private final List recipients; + private final int initialRecipientCount; + private final ReactionRecord reaction; + private final boolean remove; + + + @WorkerThread + public static @NonNull ReactionSendJob create(@NonNull Context context, + long messageId, + boolean isMms, + @NonNull ReactionRecord reaction, + boolean remove) + throws NoSuchMessageException + { + MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId) + : DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId); + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List recipients = conversationRecipient.isGroup() ? Stream.of(RecipientUtil.getEligibleForSending(conversationRecipient.getParticipants())).map(Recipient::getId).toList() + : Stream.of(conversationRecipient.getId()).toList(); + + recipients.remove(Recipient.self().getId()); + + return new ReactionSendJob(messageId, + isMms, + recipients, + recipients.size(), + reaction, + remove, + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private ReactionSendJob(long messageId, + boolean isMms, + @NonNull List recipients, + int initialRecipientCount, + @NonNull ReactionRecord reaction, + boolean remove, + @NonNull Parameters parameters) + { + super(parameters); + + this.messageId = messageId; + this.isMms = isMms; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + this.reaction = reaction; + this.remove = remove; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putBoolean(KEY_IS_MMS, isMms) + .putString(KEY_REACTION_EMOJI, reaction.getEmoji()) + .putString(KEY_REACTION_AUTHOR, reaction.getAuthor().serialize()) + .putLong(KEY_REACTION_DATE_SENT, reaction.getDateSent()) + .putLong(KEY_REACTION_DATE_RECEIVED, reaction.getDateReceived()) + .putBoolean(KEY_REMOVE, remove) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + MessageDatabase db; + MessageRecord message; + + if (isMms) { + db = DatabaseFactory.getMmsDatabase(context); + message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } else { + db = DatabaseFactory.getSmsDatabase(context); + message = DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId); + } + + Recipient targetAuthor = message.isOutgoing() ? Recipient.self() : message.getIndividualRecipient(); + long targetSentTimestamp = message.getDateSent(); + + if (!remove && !db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Went to add a reaction, but it's no longer present on the message!"); + return; + } + + if (remove && db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Went to remove a reaction, but it's still there!"); + return; + } + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations, targetAuthor, targetSentTimestamp); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + if (recipients.size() < initialRecipientCount) { + Log.w(TAG, "Only sent a reaction to " + recipients.size() + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays."); + return; + } + + Log.w(TAG, "Failed to send the reaction to all recipients!"); + + MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + if (remove && !db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Reaction removal failed, so adding the reaction back."); + db.addReaction(messageId, reaction); + } else if (!remove && db.hasReaction(messageId, reaction)){ + Log.w(TAG, "Reaction addition failed, so removing the reaction."); + db.deleteReaction(messageId, reaction.getAuthor()); + } else { + Log.w(TAG, "Reaction state didn't match what we'd expect to revert it, so we're just leaving it alone."); + } + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations);; + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp)); + + if (conversationRecipient.isGroup()) { + GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + private static SignalServiceDataMessage.Reaction buildReaction(@NonNull Context context, + @NonNull ReactionRecord reaction, + boolean remove, + @NonNull Recipient targetAuthor, + long targetSentTimestamp) + throws IOException + { + return new SignalServiceDataMessage.Reaction(reaction.getEmoji(), + remove, + RecipientUtil.toSignalServiceAddress(context, targetAuthor), + targetSentTimestamp); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull + ReactionSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + long messageId = data.getLong(KEY_MESSAGE_ID); + boolean isMms = data.getBoolean(KEY_IS_MMS); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + ReactionRecord reaction = new ReactionRecord(data.getString(KEY_REACTION_EMOJI), + RecipientId.from(data.getString(KEY_REACTION_AUTHOR)), + data.getLong(KEY_REACTION_DATE_SENT), + data.getLong(KEY_REACTION_DATE_RECEIVED)); + boolean remove = data.getBoolean(KEY_REMOVE); + + return new ReactionSendJob(messageId, isMms, recipients, initialRecipientCount, reaction, remove, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java new file mode 100644 index 00000000..a5900d39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.jobs; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.AppCapabilities; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.KbsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.account.AccountAttributes; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.push.exceptions.NetworkFailureException; + +import java.io.IOException; + +public class RefreshAttributesJob extends BaseJob { + + public static final String KEY = "RefreshAttributesJob"; + + private static final String TAG = RefreshAttributesJob.class.getSimpleName(); + + public RefreshAttributesJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("RefreshAttributesJob") + .setMaxInstancesForFactory(2) + .build()); + } + + private RefreshAttributesJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + if (!TextSecurePreferences.isPushRegistered(context) || TextSecurePreferences.getLocalNumber(context) == null) { + Log.w(TAG, "Not yet registered. Skipping."); + return; + } + + int registrationId = TextSecurePreferences.getLocalRegistrationId(context); + boolean fetchesMessages = TextSecurePreferences.isFcmDisabled(context); + byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); + boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context); + String registrationLockV1 = null; + String registrationLockV2 = null; + KbsValues kbsValues = SignalStore.kbsValues(); + + if (kbsValues.isV2RegistrationLockEnabled()) { + registrationLockV2 = kbsValues.getRegistrationLockToken(); + } else if (TextSecurePreferences.isV1RegistrationLockEnabled(context)) { + //noinspection deprecation Ok to read here as they have not migrated + registrationLockV1 = TextSecurePreferences.getDeprecatedV1RegistrationLockPin(context); + } + + boolean phoneNumberDiscoverable = SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode().isDiscoverable(); + + AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(kbsValues.hasPin() && !kbsValues.hasOptedOut()); + Log.i(TAG, "Calling setAccountAttributes() reglockV1? " + !TextUtils.isEmpty(registrationLockV1) + ", reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + kbsValues.hasPin() + + "\n Phone number discoverable : " + phoneNumberDiscoverable + + "\n Capabilities:" + + "\n Storage? " + capabilities.isStorage() + + "\n GV2? " + capabilities.isGv2() + + "\n GV1 Migration? " + capabilities.isGv1Migration() + + "\n UUID? " + capabilities.isUuid()); + + SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager(); + signalAccountManager.setAccountAttributes(null, registrationId, fetchesMessages, + registrationLockV1, registrationLockV2, + unidentifiedAccessKey, universalUnidentifiedAccess, + capabilities, + phoneNumberDiscoverable); + + ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof NetworkFailureException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to update account attributes!"); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull RefreshAttributesJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { + return new RefreshAttributesJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java new file mode 100644 index 00000000..0504c4a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.jobs; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + + +/** + * Refreshes the profile of the local user. Different from {@link RetrieveProfileJob} in that we + * have to sometimes look at/set different data stores, and we will *always* do the fetch regardless + * of caching. + */ +public class RefreshOwnProfileJob extends BaseJob { + + public static final String KEY = "RefreshOwnProfileJob"; + + private static final String TAG = Log.tag(RefreshOwnProfileJob.class); + + public RefreshOwnProfileJob() { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(ProfileUploadJob.QUEUE) + .setMaxInstancesForFactory(1) + .setMaxAttempts(10) + .build()); + } + + + private RefreshOwnProfileJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isPushRegistered(context) || TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) { + Log.w(TAG, "Not yet registered!"); + return; + } + + Recipient self = Recipient.self(); + ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self)); + SignalServiceProfile profile = profileAndCredential.getProfile(); + + setProfileName(profile.getName()); + setProfileAbout(profile.getAbout(), profile.getAboutEmoji()); + setProfileAvatar(profile.getAvatar()); + setProfileCapabilities(profile.getCapabilities()); + Optional profileKeyCredential = profileAndCredential.getProfileKeyCredential(); + if (profileKeyCredential.isPresent()) { + setProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), profileKeyCredential.get()); + } + } + + private void setProfileKeyCredential(@NonNull Recipient recipient, + @NonNull ProfileKey recipientProfileKey, + @NonNull ProfileKeyCredential credential) + { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setProfileKeyCredential(recipient.getId(), recipientProfileKey, credential); + } + + private static SignalServiceProfile.RequestType getRequestType(@NonNull Recipient recipient) { + return !recipient.hasProfileKeyCredential() + ? SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL + : SignalServiceProfile.RequestType.PROFILE; + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { } + + private void setProfileName(@Nullable String encryptedName) { + try { + ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); + String plaintextName = ProfileUtil.decryptName(profileKey, encryptedName); + ProfileName profileName = ProfileName.fromSerialized(plaintextName); + + DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); + } catch (InvalidCiphertextException | IOException e) { + Log.w(TAG, e); + } + } + + private void setProfileAbout(@Nullable String encryptedAbout, @Nullable String encryptedEmoji) { + try { + ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); + String plaintextAbout = ProfileUtil.decryptName(profileKey, encryptedAbout); + String plaintextEmoji = ProfileUtil.decryptName(profileKey, encryptedEmoji); + + Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextAbout) ? "non-" : "") + "empty about."); + Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextEmoji) ? "non-" : "") + "empty emoji."); + + DatabaseFactory.getRecipientDatabase(context).setAbout(Recipient.self().getId(), plaintextAbout, plaintextEmoji); + } catch (InvalidCiphertextException | IOException e) { + Log.w(TAG, e); + } + } + + private static void setProfileAvatar(@Nullable String avatar) { + ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), avatar)); + } + + private void setProfileCapabilities(@Nullable SignalServiceProfile.Capabilities capabilities) { + if (capabilities == null) { + return; + } + + DatabaseFactory.getRecipientDatabase(context).setCapabilities(Recipient.self().getId(), capabilities); + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RefreshOwnProfileJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RefreshOwnProfileJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshPreKeysJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshPreKeysJob.java new file mode 100644 index 00000000..9276c5f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshPreKeysJob.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.PreKeyUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class RefreshPreKeysJob extends BaseJob { + + public static final String KEY = "RefreshPreKeysJob"; + + private static final String TAG = RefreshPreKeysJob.class.getSimpleName(); + + private static final int PREKEY_MINIMUM = 10; + + private static final long REFRESH_INTERVAL = TimeUnit.DAYS.toMillis(3); + + public RefreshPreKeysJob() { + this(new Job.Parameters.Builder() + .setQueue("RefreshPreKeysJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(30)) + .build()); + } + + public static void scheduleIfNecessary() { + long timeSinceLastRefresh = System.currentTimeMillis() - SignalStore.misc().getLastPrekeyRefreshTime(); + + if (timeSinceLastRefresh > REFRESH_INTERVAL) { + Log.i(TAG, "Scheduling a prekey refresh. Time since last schedule: " + timeSinceLastRefresh + " ms"); + ApplicationDependencies.getJobManager().add(new RefreshPreKeysJob()); + } + } + + private RefreshPreKeysJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.w(TAG, "Not registered. Skipping."); + return; + } + + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + + int availableKeys = accountManager.getPreKeysCount(); + + Log.i(TAG, "Available keys: " + availableKeys); + + if (availableKeys >= PREKEY_MINIMUM && TextSecurePreferences.isSignedPreKeyRegistered(context)) { + Log.i(TAG, "Available keys sufficient."); + SignalStore.misc().setLastPrekeyRefreshTime(System.currentTimeMillis()); + return; + } + + List preKeyRecords = PreKeyUtil.generatePreKeys(context); + IdentityKeyPair identityKey = IdentityKeyUtil.getIdentityKeyPair(context); + SignedPreKeyRecord signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(context, identityKey, false); + + Log.i(TAG, "Registering new prekeys..."); + + accountManager.setPreKeys(identityKey.getPublicKey(), signedPreKeyRecord, preKeyRecords); + + PreKeyUtil.setActiveSignedPreKeyId(context, signedPreKeyRecord.getId()); + TextSecurePreferences.setSignedPreKeyRegistered(context, true); + + ApplicationDependencies.getJobManager().add(new CleanPreKeysJob()); + SignalStore.misc().setLastPrekeyRefreshTime(System.currentTimeMillis()); + Log.i(TAG, "Successfully refreshed prekeys."); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + if (exception instanceof NonSuccessfulResponseCodeException) return false; + if (exception instanceof PushNetworkException) return true; + + return false; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RefreshPreKeysJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RefreshPreKeysJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java new file mode 100644 index 00000000..8b9a6cb5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class RemoteConfigRefreshJob extends BaseJob { + + private static final String TAG = Log.tag(RemoteConfigRefreshJob.class); + + public static final String KEY = "RemoteConfigRefreshJob"; + + public RemoteConfigRefreshJob() { + this(new Job.Parameters.Builder() + .setQueue("RemoteConfigRefreshJob") + .setMaxInstancesForFactory(1) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); + } + + private RemoteConfigRefreshJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.w(TAG, "Not registered. Skipping."); + return; + } + + Map config = ApplicationDependencies.getSignalServiceAccountManager().getRemoteConfig(); + FeatureFlags.update(config); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RemoteConfigRefreshJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RemoteConfigRefreshJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java new file mode 100644 index 00000000..234cc84d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -0,0 +1,197 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class RemoteDeleteSendJob extends BaseJob { + + public static final String KEY = "RemoteDeleteSendJob"; + + private static final String TAG = Log.tag(RemoteDeleteSendJob.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_IS_MMS = "is_mms"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final long messageId; + private final boolean isMms; + private final List recipients; + private final int initialRecipientCount; + + + @WorkerThread + public static @NonNull RemoteDeleteSendJob create(@NonNull Context context, + long messageId, + boolean isMms) + throws NoSuchMessageException + { + MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId) + : DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId); + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List recipients = conversationRecipient.isGroup() ? Stream.of(RecipientUtil.getEligibleForSending(conversationRecipient.getParticipants())).map(Recipient::getId).toList() + : Stream.of(conversationRecipient.getId()).toList(); + + recipients.remove(Recipient.self().getId()); + + return new RemoteDeleteSendJob(messageId, + isMms, + recipients, + recipients.size(), + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private RemoteDeleteSendJob(long messageId, + boolean isMms, + @NonNull List recipients, + int initialRecipientCount, + @NonNull Parameters parameters) + { + super(parameters); + + this.messageId = messageId; + this.isMms = isMms; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putBoolean(KEY_IS_MMS, isMms) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + MessageDatabase db; + MessageRecord message; + + if (isMms) { + db = DatabaseFactory.getMmsDatabase(context); + message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } else { + db = DatabaseFactory.getSmsDatabase(context); + message = DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId); + } + + long targetSentTimestamp = message.getDateSent(); + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + if (!message.isOutgoing()) { + throw new IllegalStateException("Cannot delete a message that isn't yours!"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations, targetSentTimestamp); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (recipients.isEmpty()) { + db.markAsSent(messageId, true); + } else { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send remote delete to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") ); + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, long targetSentTimestamp) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withRemoteDelete(new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp)); + + if (conversationRecipient.isGroup()) { + GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull RemoteDeleteSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + long messageId = data.getLong(KEY_MESSAGE_ID); + boolean isMms = data.getBoolean(KEY_IS_MMS); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + + return new RemoteDeleteSendJob(messageId, isMms, recipients, initialRecipientCount, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java new file mode 100644 index 00000000..2f281bad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup.Type; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class RequestGroupInfoJob extends BaseJob { + + public static final String KEY = "RequestGroupInfoJob"; + + @SuppressWarnings("unused") + private static final String TAG = RequestGroupInfoJob.class.getSimpleName(); + + private static final String KEY_SOURCE = "source"; + private static final String KEY_GROUP_ID = "group_id"; + + private final RecipientId source; + private final GroupId groupId; + + public RequestGroupInfoJob(@NonNull RecipientId source, @NonNull GroupId groupId) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + source, + groupId); + + } + + private RequestGroupInfoJob(@NonNull Job.Parameters parameters, @NonNull RecipientId source, @NonNull GroupId groupId) { + super(parameters); + + this.source = source; + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_SOURCE, source.serialize()) + .putString(KEY_GROUP_ID, groupId.toString()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + SignalServiceGroup group = SignalServiceGroup.newBuilder(Type.REQUEST_INFO) + .withId(groupId.getDecodedId()) + .build(); + + SignalServiceDataMessage message = SignalServiceDataMessage.newBuilder() + .asGroupMessage(group) + .withTimestamp(System.currentTimeMillis()) + .build(); + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(source); + + messageSender.sendMessage(RecipientUtil.toSignalServiceAddress(context, recipient), + UnidentifiedAccessUtil.getAccessFor(context, recipient), + message); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RequestGroupInfoJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RequestGroupInfoJob(parameters, + RecipientId.from(data.getString(KEY_SOURCE)), + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java new file mode 100644 index 00000000..00ee2a69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; + +/** + * Schedules a {@link RequestGroupV2InfoWorkerJob} to happen after message queues are drained. + */ +public final class RequestGroupV2InfoJob extends BaseJob { + + public static final String KEY = "RequestGroupV2InfoJob"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(RequestGroupV2InfoJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_TO_REVISION = "to_revision"; + + private final GroupId.V2 groupId; + private final int toRevision; + + /** + * Get a particular group state revision for group after message queues are drained. + */ + public RequestGroupV2InfoJob(@NonNull GroupId.V2 groupId, int toRevision) { + this(new Parameters.Builder() + .setQueue("RequestGroupV2InfoSyncJob") + .addConstraint(DecryptionsDrainedConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + groupId, + toRevision); + } + + /** + * Get latest group state for group after message queues are drained. + */ + public RequestGroupV2InfoJob(@NonNull GroupId.V2 groupId) { + this(groupId, GroupsV2StateProcessor.LATEST); + } + + private RequestGroupV2InfoJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, int toRevision) { + super(parameters); + + this.groupId = groupId; + this.toRevision = toRevision; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()) + .putInt(KEY_TO_REVISION, toRevision) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoWorkerJob(groupId, toRevision)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RequestGroupV2InfoJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RequestGroupV2InfoJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2(), + data.getInt(KEY_TO_REVISION)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java new file mode 100644 index 00000000..6c0c0d10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Scheduled by {@link RequestGroupV2InfoJob} after message queues are drained. + */ +final class RequestGroupV2InfoWorkerJob extends BaseJob { + + public static final String KEY = "RequestGroupV2InfoWorkerJob"; + + private static final String TAG = Log.tag(RequestGroupV2InfoWorkerJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_TO_REVISION = "to_revision"; + + private final GroupId.V2 groupId; + private final int toRevision; + + @WorkerThread + RequestGroupV2InfoWorkerJob(@NonNull GroupId.V2 groupId, int toRevision) { + this(new Parameters.Builder() + .setQueue(PushProcessMessageJob.getQueueName(Recipient.externalGroupExact(ApplicationDependencies.getApplication(), groupId).getId())) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + groupId, + toRevision); + } + + private RequestGroupV2InfoWorkerJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, int toRevision) { + super(parameters); + + this.groupId = groupId; + this.toRevision = toRevision; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()) + .putInt(KEY_TO_REVISION, toRevision) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, GroupNotAMemberException, GroupChangeBusyException { + if (toRevision == GroupsV2StateProcessor.LATEST) { + Log.i(TAG, "Updating group to latest revision"); + } else { + Log.i(TAG, "Updating group to revision " + toRevision); + } + + Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(groupId); + + if (!group.isPresent()) { + Log.w(TAG, "Group not found"); + return; + } + + GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis(), null); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RequestGroupV2InfoWorkerJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RequestGroupV2InfoWorkerJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2(), + data.getInt(KEY_TO_REVISION)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResumableUploadSpecJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResumableUploadSpecJob.java new file mode 100644 index 00000000..8fa98621 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResumableUploadSpecJob.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class ResumableUploadSpecJob extends BaseJob { + + private static final String TAG = Log.tag(ResumableUploadSpecJob.class); + + static final String KEY_RESUME_SPEC = "resume_spec"; + + public static final String KEY = "ResumableUploadSpecJob"; + + public ResumableUploadSpecJob() { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private ResumableUploadSpecJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + protected void onRun() throws Exception { + ResumableUploadSpec resumableUploadSpec = ApplicationDependencies.getSignalServiceMessageSender() + .getResumableUploadSpec(); + + setOutputData(new Data.Builder() + .putString(KEY_RESUME_SPEC, resumableUploadSpec.serialize()) + .build()); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull ResumableUploadSpecJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ResumableUploadSpecJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java new file mode 100644 index 00000000..ff3c5ef3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; + +public class RetrieveProfileAvatarJob extends BaseJob { + + public static final String KEY = "RetrieveProfileAvatarJob"; + + private static final String TAG = RetrieveProfileAvatarJob.class.getSimpleName(); + + private static final int MAX_PROFILE_SIZE_BYTES = 20 * 1024 * 1024; + + private static final String KEY_PROFILE_AVATAR = "profile_avatar"; + private static final String KEY_RECIPIENT = "recipient"; + + private final String profileAvatar; + private final Recipient recipient; + + public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar) { + this(new Job.Parameters.Builder() + .setQueue("RetrieveProfileAvatarJob::" + recipient.getId().toQueueKey()) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.HOURS.toMillis(1)) + .build(), + recipient, + profileAvatar); + } + + private RetrieveProfileAvatarJob(@NonNull Job.Parameters parameters, @NonNull Recipient recipient, String profileAvatar) { + super(parameters); + + this.recipient = recipient; + this.profileAvatar = profileAvatar; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_PROFILE_AVATAR, profileAvatar) + .putString(KEY_RECIPIENT, recipient.getId().serialize()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.resolve().getProfileKey()); + + if (profileKey == null) { + Log.w(TAG, "Recipient profile key is gone!"); + return; + } + + if (Util.equals(profileAvatar, recipient.resolve().getProfileAvatar())) { + Log.w(TAG, "Already retrieved profile avatar: " + profileAvatar); + return; + } + + if (TextUtils.isEmpty(profileAvatar)) { + Log.w(TAG, "Removing profile avatar (no url) for: " + recipient.getId().serialize()); + AvatarHelper.delete(context, recipient.getId()); + database.setProfileAvatar(recipient.getId(), profileAvatar); + return; + } + + File downloadDestination = File.createTempFile("avatar", "jpg", context.getCacheDir()); + + try { + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + InputStream avatarStream = receiver.retrieveProfileAvatar(profileAvatar, downloadDestination, profileKey, AvatarHelper.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); + + try { + AvatarHelper.setAvatar(context, recipient.getId(), avatarStream); + } catch (AssertionError e) { + throw new IOException("Failed to copy stream. Likely a Conscrypt issue.", e); + } + } catch (PushNetworkException e) { + if (e.getCause() instanceof NonSuccessfulResponseCodeException) { + Log.w(TAG, "Removing profile avatar (no image available) for: " + recipient.getId().serialize()); + AvatarHelper.delete(context, recipient.getId()); + } else { + throw e; + } + } finally { + if (downloadDestination != null) downloadDestination.delete(); + } + + database.setProfileAvatar(recipient.getId(), profileAvatar); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RetrieveProfileAvatarJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RetrieveProfileAvatarJob(parameters, + Recipient.resolved(RecipientId.from(data.getString(KEY_RECIPIENT))), + data.getString(KEY_PROFILE_AVATAR)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java new file mode 100644 index 00000000..b0aa1d0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -0,0 +1,502 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Application; +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Retrieves a users profile and sets the appropriate local fields. + */ +public class RetrieveProfileJob extends BaseJob { + + public static final String KEY = "RetrieveProfileJob"; + + private static final String TAG = RetrieveProfileJob.class.getSimpleName(); + + private static final String KEY_RECIPIENTS = "recipients"; + + private final Set recipientIds; + + /** + * Identical to {@link #enqueue(Set)})}, but run on a background thread for convenience. + */ + public static void enqueueAsync(@NonNull RecipientId recipientId) { + SignalExecutors.BOUNDED.execute(() -> ApplicationDependencies.getJobManager().add(forRecipient(recipientId))); + } + + /** + * Submits the necessary job to refresh the profile of the requested recipient. Works for any + * RecipientId, including individuals, groups, or yourself. + * + * Identical to {@link #enqueue(Set)})} + */ + @WorkerThread + public static void enqueue(@NonNull RecipientId recipientId) { + ApplicationDependencies.getJobManager().add(forRecipient(recipientId)); + } + + /** + * Submits the necessary jobs to refresh the profiles of the requested recipients. Works for any + * RecipientIds, including individuals, groups, or yourself. + */ + @WorkerThread + public static void enqueue(@NonNull Set recipientIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + for (Job job : forRecipients(recipientIds)) { + jobManager.add(job); + } + } + + /** + * Works for any RecipientId, whether it's an individual, group, or yourself. + */ + @WorkerThread + public static @NonNull Job forRecipient(@NonNull RecipientId recipientId) { + Recipient recipient = Recipient.resolved(recipientId); + + if (recipient.isSelf()) { + return new RefreshOwnProfileJob(); + } else if (recipient.isGroup()) { + Context context = ApplicationDependencies.getApplication(); + List recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + return new RetrieveProfileJob(Stream.of(recipients).map(Recipient::getId).collect(Collectors.toSet())); + } else { + return new RetrieveProfileJob(Collections.singleton(recipientId)); + } + } + + /** + * Works for any RecipientId, whether it's an individual, group, or yourself. + * + * @return A list of length 2 or less. Two iff you are in the recipients. + */ + @WorkerThread + public static @NonNull List forRecipients(@NonNull Set recipientIds) { + Context context = ApplicationDependencies.getApplication(); + Set combined = new HashSet<>(recipientIds.size()); + boolean includeSelf = false; + + for (RecipientId recipientId : recipientIds) { + Recipient recipient = Recipient.resolved(recipientId); + + if (recipient.isSelf()) { + includeSelf = true; + } else if (recipient.isGroup()) { + List recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + combined.addAll(Stream.of(recipients).map(Recipient::getId).toList()); + } else { + combined.add(recipientId); + } + } + + List jobs = new ArrayList<>(2); + + if (includeSelf) { + jobs.add(new RefreshOwnProfileJob()); + } + + if (combined.size() > 0) { + jobs.add(new RetrieveProfileJob(combined)); + } + + return jobs; + } + + /** + * Will fetch some profiles to ensure we're decently up-to-date if we haven't done so within a + * certain time period. + */ + public static void enqueueRoutineFetchIfNecessary(Application application) { + if (!SignalStore.registrationValues().isRegistrationComplete() || + !TextSecurePreferences.isPushRegistered(application) || + TextSecurePreferences.getLocalUuid(application) == null) + { + Log.i(TAG, "Registration not complete. Skipping."); + return; + } + + long timeSinceRefresh = System.currentTimeMillis() - SignalStore.misc().getLastProfileRefreshTime(); + if (timeSinceRefresh < TimeUnit.HOURS.toMillis(12)) { + Log.i(TAG, "Too soon to refresh. Did the last refresh " + timeSinceRefresh + " ms ago."); + return; + } + + SignalExecutors.BOUNDED.execute(() -> { + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(application); + long current = System.currentTimeMillis(); + + List ids = db.getRecipientsForRoutineProfileFetch(current - TimeUnit.DAYS.toMillis(30), + current - TimeUnit.DAYS.toMillis(1), + 50); + + ids.add(Recipient.self().getId()); + + if (ids.size() > 0) { + Log.i(TAG, "Optimistically refreshing " + ids.size() + " eligible recipient(s)."); + enqueue(new HashSet<>(ids)); + } else { + Log.i(TAG, "No recipients to refresh."); + } + + SignalStore.misc().setLastProfileRefreshTime(System.currentTimeMillis()); + }); + } + + private RetrieveProfileJob(@NonNull Set recipientIds) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(3) + .build(), + recipientIds); + } + + private RetrieveProfileJob(@NonNull Job.Parameters parameters, @NonNull Set recipientIds) { + super(parameters); + this.recipientIds = recipientIds; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putStringListAsArray(KEY_RECIPIENTS, Stream.of(recipientIds) + .map(RecipientId::serialize) + .toList()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected boolean shouldTrace() { + return true; + } + + @Override + public void onRun() throws IOException, RetryLaterException { + Stopwatch stopwatch = new Stopwatch("RetrieveProfile"); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + Set retries = new HashSet<>(); + Set unregistered = new HashSet<>(); + + RecipientUtil.ensureUuidsAreAvailable(context, Stream.of(Recipient.resolvedList(recipientIds)) + .filter(r -> r.getRegistered() != RecipientDatabase.RegisteredState.NOT_REGISTERED) + .toList()); + + List recipients = Recipient.resolvedList(recipientIds); + stopwatch.split("resolve-ensure"); + + List>> futures = Stream.of(recipients) + .filter(Recipient::hasServiceIdentifier) + .map(r -> new Pair<>(r, ProfileUtil.retrieveProfile(context, r, getRequestType(r)))) + .toList(); + stopwatch.split("futures"); + + List> profiles = Stream.of(futures) + .map(pair -> { + Recipient recipient = pair.first(); + + try { + ProfileAndCredential profile = pair.second().get(10, TimeUnit.SECONDS); + return new Pair<>(recipient, profile); + } catch (InterruptedException | TimeoutException e) { + retries.add(recipient.getId()); + } catch (ExecutionException e) { + if (e.getCause() instanceof PushNetworkException) { + retries.add(recipient.getId()); + } else if (e.getCause() instanceof NotFoundException) { + Log.w(TAG, "Failed to find a profile for " + recipient.getId()); + if (recipient.isRegistered()) { + unregistered.add(recipient.getId()); + } + } else { + Log.w(TAG, "Failed to retrieve profile for " + recipient.getId()); + } + } + return null; + }) + .withoutNulls() + .toList(); + stopwatch.split("network"); + + for (Pair profile : profiles) { + process(profile.first(), profile.second()); + } + + Set success = SetUtil.difference(recipientIds, retries); + recipientDatabase.markProfilesFetched(success, System.currentTimeMillis()); + + Map newlyRegistered = Stream.of(profiles) + .map(Pair::first) + .filterNot(Recipient::isRegistered) + .collect(Collectors.toMap(Recipient::getId, + r -> r.getUuid().transform(UUID::toString).orNull())); + + if (unregistered.size() > 0 || newlyRegistered.size() > 0) { + Log.i(TAG, "Marking " + newlyRegistered.size() + " users as registered and " + unregistered.size() + " users as unregistered."); + recipientDatabase.bulkUpdatedRegisteredStatus(newlyRegistered, unregistered); + } + + stopwatch.split("process"); + + long keyCount = Stream.of(profiles).map(Pair::first).map(Recipient::getProfileKey).withoutNulls().count(); + Log.d(TAG, String.format(Locale.US, "Started with %d recipient(s). Found %d profile(s), and had keys for %d of them. Will retry %d.", recipients.size(), profiles.size(), keyCount, retries.size())); + + stopwatch.stop(TAG); + + recipientIds.clear(); + recipientIds.addAll(retries); + + if (recipientIds.size() > 0) { + throw new RetryLaterException(); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof RetryLaterException; + } + + @Override + public void onFailure() {} + + private void process(Recipient recipient, ProfileAndCredential profileAndCredential) { + SignalServiceProfile profile = profileAndCredential.getProfile(); + ProfileKey recipientProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + + setProfileName(recipient, profile.getName()); + setProfileAbout(recipient, profile.getAbout(), profile.getAboutEmoji()); + setProfileAvatar(recipient, profile.getAvatar()); + clearUsername(recipient); + setProfileCapabilities(recipient, profile.getCapabilities()); + setIdentityKey(recipient, profile.getIdentityKey()); + setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); + + if (recipientProfileKey != null) { + Optional profileKeyCredential = profileAndCredential.getProfileKeyCredential(); + if (profileKeyCredential.isPresent()) { + setProfileKeyCredential(recipient, recipientProfileKey, profileKeyCredential.get()); + } + } + } + + private void setProfileKeyCredential(@NonNull Recipient recipient, + @NonNull ProfileKey recipientProfileKey, + @NonNull ProfileKeyCredential credential) + { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setProfileKeyCredential(recipient.getId(), recipientProfileKey, credential); + } + + private static SignalServiceProfile.RequestType getRequestType(@NonNull Recipient recipient) { + return !recipient.hasProfileKeyCredential() + ? SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL + : SignalServiceProfile.RequestType.PROFILE; + } + + private void setIdentityKey(Recipient recipient, String identityKeyValue) { + try { + if (TextUtils.isEmpty(identityKeyValue)) { + Log.w(TAG, "Identity key is missing on profile!"); + return; + } + + IdentityKey identityKey = new IdentityKey(Base64.decode(identityKeyValue), 0); + + if (!DatabaseFactory.getIdentityDatabase(context) + .getIdentity(recipient.getId()) + .isPresent()) + { + Log.w(TAG, "Still first use..."); + return; + } + + IdentityUtil.saveIdentity(context, recipient.requireServiceId(), identityKey); + } catch (InvalidKeyException | IOException e) { + Log.w(TAG, e); + } + } + + private void setUnidentifiedAccessMode(Recipient recipient, String unidentifiedAccessVerifier, boolean unrestrictedUnidentifiedAccess) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + + if (unrestrictedUnidentifiedAccess && unidentifiedAccessVerifier != null) { + if (recipient.getUnidentifiedAccessMode() != UnidentifiedAccessMode.UNRESTRICTED) { + Log.i(TAG, "Marking recipient UD status as unrestricted."); + recipientDatabase.setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.UNRESTRICTED); + } + } else if (profileKey == null || unidentifiedAccessVerifier == null) { + if (recipient.getUnidentifiedAccessMode() != UnidentifiedAccessMode.DISABLED) { + Log.i(TAG, "Marking recipient UD status as disabled."); + recipientDatabase.setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.DISABLED); + } + } else { + ProfileCipher profileCipher = new ProfileCipher(profileKey); + boolean verifiedUnidentifiedAccess; + + try { + verifiedUnidentifiedAccess = profileCipher.verifyUnidentifiedAccess(Base64.decode(unidentifiedAccessVerifier)); + } catch (IOException e) { + Log.w(TAG, e); + verifiedUnidentifiedAccess = false; + } + + UnidentifiedAccessMode mode = verifiedUnidentifiedAccess ? UnidentifiedAccessMode.ENABLED : UnidentifiedAccessMode.DISABLED; + + if (recipient.getUnidentifiedAccessMode() != mode) { + Log.i(TAG, "Marking recipient UD status as " + mode.name() + " after verification."); + recipientDatabase.setUnidentifiedAccessMode(recipient.getId(), mode); + } + } + } + + private void setProfileName(Recipient recipient, String profileName) { + try { + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + if (profileKey == null) return; + + String plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptName(profileKey, profileName)); + + ProfileName remoteProfileName = ProfileName.fromSerialized(plaintextProfileName); + ProfileName localProfileName = recipient.getProfileName(); + + if (!remoteProfileName.equals(localProfileName)) { + Log.i(TAG, "Profile name updated. Writing new value."); + DatabaseFactory.getRecipientDatabase(context).setProfileName(recipient.getId(), remoteProfileName); + + String remoteDisplayName = remoteProfileName.toString(); + String localDisplayName = localProfileName.toString(); + + if (!recipient.isBlocked() && + !recipient.isGroup() && + !recipient.isSelf() && + !localDisplayName.isEmpty() && + !remoteDisplayName.equals(localDisplayName)) + { + Log.i(TAG, "Writing a profile name change event."); + DatabaseFactory.getSmsDatabase(context).insertProfileNameChangeMessages(recipient, remoteDisplayName, localDisplayName); + } else { + Log.i(TAG, String.format(Locale.US, "Name changed, but wasn't relevant to write an event. blocked: %s, group: %s, self: %s, firstSet: %s, displayChange: %s", + recipient.isBlocked(), recipient.isGroup(), recipient.isSelf(), localDisplayName.isEmpty(), !remoteDisplayName.equals(localDisplayName))); + } + } + + if (TextUtils.isEmpty(plaintextProfileName)) { + Log.i(TAG, "No profile name set."); + } + } catch (InvalidCiphertextException e) { + Log.w(TAG, "Bad profile key for " + recipient.getId()); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + private void setProfileAbout(@NonNull Recipient recipient, @Nullable String encryptedAbout, @Nullable String encryptedEmoji) { + try { + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + if (profileKey == null) return; + + String plaintextAbout = ProfileUtil.decryptName(profileKey, encryptedAbout); + String plaintextEmoji = ProfileUtil.decryptName(profileKey, encryptedEmoji); + + DatabaseFactory.getRecipientDatabase(context).setAbout(recipient.getId(), plaintextAbout, plaintextEmoji); + } catch (InvalidCiphertextException | IOException e) { + Log.w(TAG, e); + } + } + + private static void setProfileAvatar(Recipient recipient, String profileAvatar) { + if (recipient.getProfileKey() == null) return; + + if (!Util.equals(profileAvatar, recipient.getProfileAvatar())) { + ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(recipient, profileAvatar)); + } + } + + private void clearUsername(Recipient recipient) { + DatabaseFactory.getRecipientDatabase(context).setUsername(recipient.getId(), null); + } + + private void setProfileCapabilities(@NonNull Recipient recipient, @Nullable SignalServiceProfile.Capabilities capabilities) { + if (capabilities == null) { + return; + } + + DatabaseFactory.getRecipientDatabase(context).setCapabilities(recipient.getId(), capabilities); + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RetrieveProfileJob create(@NonNull Parameters parameters, @NonNull Data data) { + String[] ids = data.getStringArray(KEY_RECIPIENTS); + Set recipientIds = Stream.of(ids).map(RecipientId::from).collect(Collectors.toSet()); + + return new RetrieveProfileJob(parameters, recipientIds); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java new file mode 100644 index 00000000..1c5ad791 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.CertificateType; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +public final class RotateCertificateJob extends BaseJob { + + public static final String KEY = "RotateCertificateJob"; + + private static final String TAG = Log.tag(RotateCertificateJob.class); + + public RotateCertificateJob() { + this(new Job.Parameters.Builder() + .setQueue("__ROTATE_SENDER_CERTIFICATE__") + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private RotateCertificateJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() {} + + @Override + public void onRun() throws IOException { + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.w(TAG, "Not yet registered. Ignoring."); + return; + } + + synchronized (RotateCertificateJob.class) { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + Collection certificateTypes = SignalStore.phoneNumberPrivacy() + .getAllCertificateTypes(); + + Log.i(TAG, "Rotating these certificates " + certificateTypes); + + for (CertificateType certificateType: certificateTypes) { + byte[] certificate; + + switch (certificateType) { + case UUID_AND_E164: certificate = accountManager.getSenderCertificate(); break; + case UUID_ONLY : certificate = accountManager.getSenderCertificateForPhoneNumberPrivacy(); break; + default : throw new AssertionError(); + } + + Log.i(TAG, String.format("Successfully got %s certificate", certificateType)); + SignalStore.certificateValues() + .setUnidentifiedAccessCertificate(certificateType, certificate); + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to rotate sender certificate!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RotateCertificateJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RotateCertificateJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java new file mode 100644 index 00000000..45ff1d9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +public class RotateProfileKeyJob extends BaseJob { + + public static String KEY = "RotateProfileKeyJob"; + + public RotateProfileKeyJob() { + this(new Job.Parameters.Builder() + .setQueue("__ROTATE_PROFILE_KEY__") + .setMaxInstancesForFactory(2) + .build()); + } + + private RotateProfileKeyJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + ProfileKey newProfileKey = ProfileKeyUtil.createNew(); + Recipient self = Recipient.self(); + + DatabaseFactory.getRecipientDatabase(context).setProfileKey(self.getId(), newProfileKey); + + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + + updateProfileKeyOnAllV2Groups(); + } + + private void updateProfileKeyOnAllV2Groups() { + List allGv2Groups = DatabaseFactory.getGroupDatabase(context).getAllGroupV2Ids(); + + for (GroupId.V2 groupId : allGv2Groups) { + ApplicationDependencies.getJobManager().add(new GroupV2UpdateSelfProfileKeyJob(groupId)); + } + } + + @Override + public void onFailure() { + } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RotateProfileKeyJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RotateProfileKeyJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateSignedPreKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateSignedPreKeyJob.java new file mode 100644 index 00000000..8bb1ed2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateSignedPreKeyJob.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.PreKeyUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.concurrent.TimeUnit; + +public class RotateSignedPreKeyJob extends BaseJob { + + public static final String KEY = "RotateSignedPreKeyJob"; + + private static final String TAG = RotateSignedPreKeyJob.class.getSimpleName(); + + public RotateSignedPreKeyJob() { + this(new Job.Parameters.Builder() + .setQueue("RotateSignedPreKeyJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(2)) + .build()); + } + + private RotateSignedPreKeyJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws Exception { + Log.i(TAG, "Rotating signed prekey..."); + + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + IdentityKeyPair identityKey = IdentityKeyUtil.getIdentityKeyPair(context); + SignedPreKeyRecord signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(context, identityKey, false); + + accountManager.setSignedPreKey(signedPreKeyRecord); + + PreKeyUtil.setActiveSignedPreKeyId(context, signedPreKeyRecord.getId()); + TextSecurePreferences.setSignedPreKeyRegistered(context, true); + TextSecurePreferences.setSignedPreKeyFailureCount(context, 0); + + ApplicationDependencies.getJobManager().add(new CleanPreKeysJob()); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onFailure() { + TextSecurePreferences.setSignedPreKeyFailureCount(context, TextSecurePreferences.getSignedPreKeyFailureCount(context) + 1); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull RotateSignedPreKeyJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RotateSignedPreKeyJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendDeliveryReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendDeliveryReceiptJob.java new file mode 100644 index 00000000..d42ae44a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendDeliveryReceiptJob.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +public class SendDeliveryReceiptJob extends BaseJob { + + public static final String KEY = "SendDeliveryReceiptJob"; + + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_TIMESTAMP = "timestamp"; + + private static final String TAG = SendReadReceiptJob.class.getSimpleName(); + + private RecipientId recipientId; + private long messageId; + private long timestamp; + + public SendDeliveryReceiptJob(@NonNull RecipientId recipientId, long messageId) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue(recipientId.toQueueKey()) + .build(), + recipientId, + messageId, + System.currentTimeMillis()); + } + + private SendDeliveryReceiptJob(@NonNull Job.Parameters parameters, + @NonNull RecipientId recipientId, + long messageId, + long timestamp) + { + super(parameters); + + this.recipientId = recipientId; + this.messageId = messageId; + this.timestamp = timestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT, recipientId.serialize()) + .putLong(KEY_MESSAGE_ID, messageId) + .putLong(KEY_TIMESTAMP, timestamp) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException, UndeliverableMessageException { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(recipientId); + SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient); + SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.DELIVERY, + Collections.singletonList(messageId), + timestamp); + + messageSender.sendReceipt(remoteAddress, + UnidentifiedAccessUtil.getAccessFor(context, recipient), + receiptMessage); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + if (e instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send delivery receipt to: " + recipientId); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull SendDeliveryReceiptJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new SendDeliveryReceiptJob(parameters, + RecipientId.from(data.getString(KEY_RECIPIENT)), + data.getLong(KEY_MESSAGE_ID), + data.getLong(KEY_TIMESTAMP)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java new file mode 100644 index 00000000..324e7cff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.TextSecureExpiredException; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.util.Util; + +import java.lang.reflect.Array; +import java.util.LinkedList; +import java.util.List; + +public abstract class SendJob extends BaseJob { + + @SuppressWarnings("unused") + private final static String TAG = SendJob.class.getSimpleName(); + + public SendJob(Job.Parameters parameters) { + super(parameters); + } + + @Override + public final void onRun() throws Exception { + if (SignalStore.misc().isClientDeprecated()) { + throw new TextSecureExpiredException(String.format("TextSecure expired (build %d, now %d)", + BuildConfig.BUILD_TIMESTAMP, + System.currentTimeMillis())); + } + + Log.i(TAG, "Starting message send attempt"); + onSend(); + Log.i(TAG, "Message send completed"); + } + + protected abstract void onSend() throws Exception; + + protected void markAttachmentsUploaded(long messageId, @NonNull OutgoingMediaMessage message) { + List attachments = new LinkedList<>(); + + attachments.addAll(message.getAttachments()); + attachments.addAll(Stream.of(message.getLinkPreviews()).map(lp -> lp.getThumbnail().orNull()).withoutNulls().toList()); + attachments.addAll(Stream.of(message.getSharedContacts()).map(Contact::getAvatarAttachment).withoutNulls().toList()); + + if (message.getOutgoingQuote() != null) { + attachments.addAll(message.getOutgoingQuote().getAttachments()); + } + + AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); + + for (Attachment attachment : attachments) { + database.markAttachmentUploaded(messageId, attachment); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java new file mode 100644 index 00000000..a5094e4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.app.Application; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class SendReadReceiptJob extends BaseJob { + + public static final String KEY = "SendReadReceiptJob"; + + private static final String TAG = SendReadReceiptJob.class.getSimpleName(); + + static final int MAX_TIMESTAMPS = 500; + + private static final String KEY_THREAD = "thread"; + private static final String KEY_ADDRESS = "address"; + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_MESSAGE_IDS = "message_ids"; + private static final String KEY_TIMESTAMP = "timestamp"; + + private final long threadId; + private final RecipientId recipientId; + private final List messageIds; + private final long timestamp; + + public SendReadReceiptJob(long threadId, @NonNull RecipientId recipientId, List messageIds) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .setQueue(recipientId.toQueueKey()) + .build(), + threadId, + recipientId, + ensureSize(messageIds, MAX_TIMESTAMPS), + System.currentTimeMillis()); + } + + private SendReadReceiptJob(@NonNull Job.Parameters parameters, + long threadId, + @NonNull RecipientId recipientId, + @NonNull List messageIds, + long timestamp) + { + super(parameters); + + this.threadId = threadId; + this.recipientId = recipientId; + this.messageIds = messageIds; + this.timestamp = timestamp; + } + + /** + * Enqueues all the necessary jobs for read receipts, ensuring that they're all within the + * maximum size. + */ + public static void enqueue(long threadId, @NonNull RecipientId recipientId, List messageIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + List> messageIdChunks = Util.chunk(messageIds, MAX_TIMESTAMPS); + + if (messageIdChunks.size() > 1) { + Log.w(TAG, "Large receipt count! Had to break into multiple chunks. Total count: " + messageIds.size()); + } + + for (List chunk : messageIdChunks) { + jobManager.add(new SendReadReceiptJob(threadId, recipientId, chunk)); + } + } + + @Override + public @NonNull Data serialize() { + long[] ids = new long[messageIds.size()]; + for (int i = 0; i < ids.length; i++) { + ids[i] = messageIds.get(i); + } + + return new Data.Builder().putString(KEY_RECIPIENT, recipientId.serialize()) + .putLongArray(KEY_MESSAGE_IDS, ids) + .putLong(KEY_TIMESTAMP, timestamp) + .putLong(KEY_THREAD, threadId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException, UndeliverableMessageException { + if (!TextSecurePreferences.isReadReceiptsEnabled(context) || messageIds.isEmpty()) return; + + if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) { + Log.w(TAG, "Refusing to send receipts to untrusted recipient"); + return; + } + + Recipient recipient = Recipient.resolved(recipientId); + if (recipient.isBlocked()) { + Log.w(TAG, "Refusing to send receipts to blocked recipient"); + return; + } + + if (recipient.isGroup()) { + Log.w(TAG, "Refusing to send receipts to group"); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient); + SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, messageIds, timestamp); + + messageSender.sendReceipt(remoteAddress, + UnidentifiedAccessUtil.getAccessFor(context, Recipient.resolved(recipientId)), + receiptMessage); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + if (e instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send read receipts to: " + recipientId); + } + + static List ensureSize(@NonNull List list, int maxSize) { + if (list.size() > maxSize) { + throw new IllegalArgumentException("Too large! Size: " + list.size() + ", maxSize: " + maxSize); + } + return list; + } + + public static final class Factory implements Job.Factory { + + private final Application application; + + public Factory(@NonNull Application application) { + this.application = application; + } + + @Override + public @NonNull SendReadReceiptJob create(@NonNull Parameters parameters, @NonNull Data data) { + long timestamp = data.getLong(KEY_TIMESTAMP); + long[] ids = data.hasLongArray(KEY_MESSAGE_IDS) ? data.getLongArray(KEY_MESSAGE_IDS) : new long[0]; + List messageIds = new ArrayList<>(ids.length); + RecipientId recipientId = data.hasString(KEY_RECIPIENT) ? RecipientId.from(data.getString(KEY_RECIPIENT)) + : Recipient.external(application, data.getString(KEY_ADDRESS)).getId(); + long threadId = data.getLong(KEY_THREAD); + + for (long id : ids) { + messageIds.add(id); + } + + return new SendReadReceiptJob(parameters, threadId, recipientId, messageIds, timestamp); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java new file mode 100644 index 00000000..d99ab79c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.app.Application; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class SendViewedReceiptJob extends BaseJob { + + public static final String KEY = "SendViewedReceiptJob"; + + private static final String TAG = SendViewedReceiptJob.class.getSimpleName(); + + private static final String KEY_THREAD = "thread"; + private static final String KEY_ADDRESS = "address"; + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_SYNC_TIMESTAMPS = "message_ids"; + private static final String KEY_TIMESTAMP = "timestamp"; + + private long threadId; + private RecipientId recipientId; + private List syncTimestamps; + private long timestamp; + + public SendViewedReceiptJob(long threadId, @NonNull RecipientId recipientId, long syncTimestamp) { + this(threadId, recipientId, Collections.singletonList(syncTimestamp)); + } + + public SendViewedReceiptJob(long threadId, @NonNull RecipientId recipientId, @NonNull List syncTimestamps) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + threadId, + recipientId, + syncTimestamps, + System.currentTimeMillis()); + } + + private SendViewedReceiptJob(@NonNull Parameters parameters, + long threadId, + @NonNull RecipientId recipientId, + @NonNull List syncTimestamps, + long timestamp) + { + super(parameters); + + this.threadId = threadId; + this.recipientId = recipientId; + this.syncTimestamps = syncTimestamps; + this.timestamp = timestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT, recipientId.serialize()) + .putLongListAsArray(KEY_SYNC_TIMESTAMPS, syncTimestamps) + .putLong(KEY_TIMESTAMP, timestamp) + .putLong(KEY_THREAD, threadId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isReadReceiptsEnabled(context) || syncTimestamps.isEmpty() || !FeatureFlags.sendViewedReceipts()) return; + + if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) { + Log.w(TAG, "Refusing to send receipts to untrusted recipient"); + return; + } + + Recipient recipient = Recipient.resolved(recipientId); + if (recipient.isBlocked()) { + Log.w(TAG, "Refusing to send receipts to blocked recipient"); + return; + } + + if (recipient.isGroup()) { + Log.w(TAG, "Refusing to send receipts to group"); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient); + SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED, + syncTimestamps, + timestamp); + + messageSender.sendReceipt(remoteAddress, + UnidentifiedAccessUtil.getAccessFor(context, Recipient.resolved(recipientId)), + receiptMessage); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + if (e instanceof ServerRejectedException) return false; + if (e instanceof PushNetworkException) return true; + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send read receipts to: " + recipientId); + } + + public static final class Factory implements Job.Factory { + + private final Application application; + + public Factory(@NonNull Application application) { + this.application = application; + } + + @Override + public @NonNull + SendViewedReceiptJob create(@NonNull Parameters parameters, @NonNull Data data) { + long timestamp = data.getLong(KEY_TIMESTAMP); + List syncTimestamps = data.getLongArrayAsList(KEY_SYNC_TIMESTAMPS); + RecipientId recipientId = data.hasString(KEY_RECIPIENT) ? RecipientId.from(data.getString(KEY_RECIPIENT)) + : Recipient.external(application, data.getString(KEY_ADDRESS)).getId(); + long threadId = data.getLong(KEY_THREAD); + + return new SendViewedReceiptJob(parameters, threadId, recipientId, syncTimestamps, timestamp); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java new file mode 100644 index 00000000..1aeaec02 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ServiceOutageDetectionJob.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.events.ReminderUpdateEvent; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +public class ServiceOutageDetectionJob extends BaseJob { + + public static final String KEY = "ServiceOutageDetectionJob"; + + private static final String TAG = ServiceOutageDetectionJob.class.getSimpleName(); + + private static final String IP_SUCCESS = "127.0.0.1"; + private static final String IP_FAILURE = "127.0.0.2"; + private static final long CHECK_TIME = 1000 * 60; + + public ServiceOutageDetectionJob() { + this(new Job.Parameters.Builder() + .setQueue("ServiceOutageDetectionJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(5) + .setMaxInstancesForFactory(1) + .build()); + } + + private ServiceOutageDetectionJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws RetryLaterException { + Log.i(TAG, "onRun()"); + + long timeSinceLastCheck = System.currentTimeMillis() - TextSecurePreferences.getLastOutageCheckTime(context); + if (timeSinceLastCheck < CHECK_TIME) { + Log.w(TAG, "Skipping service outage check. Too soon."); + return; + } + + try { + InetAddress address = InetAddress.getByName(BuildConfig.SIGNAL_SERVICE_STATUS_URL); + Log.i(TAG, "Received outage check address: " + address.getHostAddress()); + + if (IP_SUCCESS.equals(address.getHostAddress())) { + Log.i(TAG, "Service is available."); + TextSecurePreferences.setServiceOutage(context, false); + } else if (IP_FAILURE.equals(address.getHostAddress())) { + Log.w(TAG, "Service is down."); + TextSecurePreferences.setServiceOutage(context, true); + } else { + Log.w(TAG, "Service status check returned an unrecognized IP address. Could be a weird network state. Prompting retry."); + throw new RetryLaterException(new Exception("Unrecognized service outage IP address.")); + } + + TextSecurePreferences.setLastOutageCheckTime(context, System.currentTimeMillis()); + EventBus.getDefault().post(new ReminderUpdateEvent()); + } catch (UnknownHostException e) { + throw new RetryLaterException(e); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + Log.i(TAG, "Service status check could not complete. Assuming success to avoid false positives due to bad network."); + TextSecurePreferences.setServiceOutage(context, false); + TextSecurePreferences.setLastOutageCheckTime(context, System.currentTimeMillis()); + EventBus.getDefault().post(new ReminderUpdateEvent()); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull ServiceOutageDetectionJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ServiceOutageDetectionJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsReceiveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsReceiveJob.java new file mode 100644 index 00000000..dddf82ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsReceiveJob.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.telephony.SmsMessage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; + +import com.google.android.gms.auth.api.phone.SmsRetriever; +import com.google.android.gms.common.api.Status; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.VerificationCodeParser; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class SmsReceiveJob extends BaseJob { + + public static final String KEY = "SmsReceiveJob"; + + private static final String TAG = SmsReceiveJob.class.getSimpleName(); + + private static final String KEY_PDUS = "pdus"; + private static final String KEY_SUBSCRIPTION_ID = "subscription_id"; + + private @Nullable Object[] pdus; + + private int subscriptionId; + + public SmsReceiveJob(@Nullable Object[] pdus, int subscriptionId) { + this(new Job.Parameters.Builder() + .addConstraint(SqlCipherMigrationConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build(), + pdus, + subscriptionId); + } + + private SmsReceiveJob(@NonNull Job.Parameters parameters, @Nullable Object[] pdus, int subscriptionId) { + super(parameters); + + this.pdus = pdus; + this.subscriptionId = subscriptionId; + } + + @Override + public @NonNull Data serialize() { + String[] encoded = new String[pdus.length]; + for (int i = 0; i < pdus.length; i++) { + encoded[i] = Base64.encodeBytes((byte[]) pdus[i]); + } + + return new Data.Builder().putStringArray(KEY_PDUS, encoded) + .putInt(KEY_SUBSCRIPTION_ID, subscriptionId) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws MigrationPendingException, RetryLaterException { + Optional message = assembleMessageFragments(pdus, subscriptionId); + + if (TextSecurePreferences.getLocalUuid(context) == null && TextSecurePreferences.getLocalNumber(context) == null) { + Log.i(TAG, "Received an SMS before we're registered..."); + + if (message.isPresent()) { + Optional token = VerificationCodeParser.parse(message.get().getMessageBody()); + + if (token.isPresent()) { + Log.i(TAG, "Received something that looks like a registration SMS. Posting a notification and broadcast."); + + NotificationManager manager = ServiceUtil.getNotificationManager(context); + Notification notification = buildPreRegistrationNotification(context, message.get()); + manager.notify(NotificationIds.PRE_REGISTRATION_SMS, notification); + + Intent smsRetrieverIntent = buildSmsRetrieverIntent(message.get()); + context.sendBroadcast(smsRetrieverIntent); + + return; + } else { + Log.w(TAG, "Received an SMS before registration is complete. We'll try again later."); + throw new RetryLaterException(); + } + } else { + Log.w(TAG, "Received an SMS before registration is complete, but couldn't assemble the message anyway. Ignoring."); + return; + } + } + + if (message.isPresent() && !isBlocked(message.get())) { + Optional insertResult = storeMessage(message.get()); + + if (insertResult.isPresent()) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else if (message.isPresent()) { + Log.w(TAG, "*** Received blocked SMS, ignoring..."); + } else { + Log.w(TAG, "*** Failed to assemble message fragments!"); + } + } + + @Override + public void onFailure() { + + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof MigrationPendingException || + exception instanceof RetryLaterException; + } + + private boolean isBlocked(IncomingTextMessage message) { + if (message.getSender() != null) { + Recipient recipient = Recipient.resolved(message.getSender()); + return recipient.isBlocked(); + } + + return false; + } + + private Optional storeMessage(IncomingTextMessage message) throws MigrationPendingException { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + database.ensureMigration(); + + if (TextSecurePreferences.getNeedsSqlCipherMigration(context)) { + throw new MigrationPendingException(); + } + + if (message.isSecureMessage()) { + IncomingTextMessage placeholder = new IncomingTextMessage(message, ""); + Optional insertResult = database.insertMessageInbox(placeholder); + database.markAsLegacyVersion(insertResult.get().getMessageId()); + + return insertResult; + } else { + return database.insertMessageInbox(message); + } + } + + private Optional assembleMessageFragments(@Nullable Object[] pdus, int subscriptionId) { + if (pdus == null) { + return Optional.absent(); + } + + List messages = new LinkedList<>(); + + for (Object pdu : pdus) { + SmsMessage message = SmsMessage.createFromPdu((byte[])pdu); + Recipient recipient = Recipient.external(context, message.getDisplayOriginatingAddress()); + messages.add(new IncomingTextMessage(recipient.getId(), message, subscriptionId)); + } + + if (messages.isEmpty()) { + return Optional.absent(); + } + + return Optional.of(new IncomingTextMessage(messages)); + } + + private static Notification buildPreRegistrationNotification(@NonNull Context context, @NonNull IncomingTextMessage message) { + Recipient sender = Recipient.resolved(message.getSender()); + + return new NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) + .setStyle(new NotificationCompat.MessagingStyle(new Person.Builder() + .setName(sender.getE164().or("")) + .build()) + .addMessage(new NotificationCompat.MessagingStyle.Message(message.getMessageBody(), + message.getSentTimestampMillis(), + (Person) null))) + .setSmallIcon(R.drawable.ic_notification) + .build(); + } + + /** + * @return An intent that is identical to the one the {@link SmsRetriever} API uses, so that + * we can auto-populate the SMS code on capable devices. + */ + private static Intent buildSmsRetrieverIntent(@NonNull IncomingTextMessage message) { + Intent intent = new Intent(SmsRetriever.SMS_RETRIEVED_ACTION); + intent.putExtra(SmsRetriever.EXTRA_STATUS, Status.RESULT_SUCCESS); + intent.putExtra(SmsRetriever.EXTRA_SMS_MESSAGE, message.getMessageBody()); + return intent; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull SmsReceiveJob create(@NonNull Parameters parameters, @NonNull Data data) { + try { + int subscriptionId = data.getInt(KEY_SUBSCRIPTION_ID); + String[] encoded = data.getStringArray(KEY_PDUS); + Object[] pdus = new Object[encoded.length]; + + for (int i = 0; i < encoded.length; i++) { + pdus[i] = Base64.decode(encoded[i]); + } + + return new SmsReceiveJob(parameters, pdus, subscriptionId); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } + + private class MigrationPendingException extends Exception { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSendJob.java new file mode 100644 index 00000000..897d52b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSendJob.java @@ -0,0 +1,249 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.telephony.PhoneNumberUtils; +import android.telephony.SmsManager; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.SmsDeliveryListener; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; + +public class SmsSendJob extends SendJob { + + public static final String KEY = "SmsSendJob"; + + private static final String TAG = SmsSendJob.class.getSimpleName(); + private static final int MAX_ATTEMPTS = 15; + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_RUN_ATTEMPT = "run_attempt"; + + private long messageId; + private int runAttempt; + + public SmsSendJob(long messageId, @NonNull Recipient destination) { + this(messageId, destination, 0); + } + + public SmsSendJob(long messageId, @NonNull Recipient destination, int runAttempt) { + this(constructParameters(destination), messageId, runAttempt); + } + + private SmsSendJob(@NonNull Job.Parameters parameters, long messageId, int runAttempt) { + super(parameters); + + this.messageId = messageId; + this.runAttempt = runAttempt; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putInt(KEY_RUN_ATTEMPT, runAttempt) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onAdded() { + DatabaseFactory.getSmsDatabase(context).markAsSending(messageId); + } + + @Override + public void onSend() throws NoSuchMessageException, TooManyRetriesException { + if (runAttempt >= MAX_ATTEMPTS) { + warn(TAG, "Hit the retry limit. Failing."); + throw new TooManyRetriesException(); + } + + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + SmsMessageRecord record = database.getSmsMessage(messageId); + + if (!record.isPending() && !record.isFailed()) { + warn(TAG, "Message " + messageId + " was already sent. Ignoring."); + return; + } + + try { + log(TAG, String.valueOf(record.getDateSent()), "Sending message: " + messageId + " (attempt " + runAttempt + ")"); + deliver(record); + log(TAG, String.valueOf(record.getDateSent()), "Sent message: " + messageId); + } catch (UndeliverableMessageException ude) { + warn(TAG, ude); + DatabaseFactory.getSmsDatabase(context).markAsSentFailed(record.getId()); + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, record.getRecipient(), record.getThreadId()); + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception throwable) { + return false; + } + + @Override + public void onFailure() { + warn(TAG, "onFailure() messageId: " + messageId); + long threadId = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageId); + + if (threadId != -1 && recipient != null) { + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, recipient, threadId); + } + } + + private void deliver(SmsMessageRecord message) + throws UndeliverableMessageException + { + if (message.isSecure() || message.isKeyExchange() || message.isEndSession()) { + throw new UndeliverableMessageException("Trying to send a secure SMS?"); + } + + String recipient = message.getIndividualRecipient().requireSmsAddress(); + + // See issue #1516 for bug report, and discussion on commits related to #4833 for problems + // related to the original fix to #1516. This still may not be a correct fix if networks allow + // SMS/MMS sending to alphanumeric recipients other than email addresses, but should also + // help to fix issue #3099. + if (!NumberUtil.isValidEmail(recipient)) { + recipient = PhoneNumberUtils.stripSeparators(PhoneNumberUtils.convertKeypadLettersToDigits(recipient)); + } + + if (!NumberUtil.isValidSmsOrEmail(recipient)) { + throw new UndeliverableMessageException("Not a valid SMS destination! " + recipient); + } + + ArrayList messages = SmsManager.getDefault().divideMessage(message.getBody()); + ArrayList sentIntents = constructSentIntents(message.getId(), message.getType(), messages, false); + ArrayList deliveredIntents = constructDeliveredIntents(message.getId(), message.getType(), messages); + + // NOTE 11/04/14 -- There's apparently a bug where for some unknown recipients + // and messages, this will throw an NPE. We have no idea why, so we're just + // catching it and marking the message as a failure. That way at least it doesn't + // repeatedly crash every time you start the app. + try { + getSmsManagerFor(message.getSubscriptionId()).sendMultipartTextMessage(recipient, null, messages, sentIntents, deliveredIntents); + } catch (NullPointerException | IllegalArgumentException npe) { + warn(TAG, npe); + log(TAG, String.valueOf(message.getDateSent()), "Recipient: " + recipient); + log(TAG, String.valueOf(message.getDateSent()), "Message Parts: " + messages.size()); + + try { + for (int i=0;i constructSentIntents(long messageId, long type, + ArrayList messages, boolean secure) + { + ArrayList sentIntents = new ArrayList<>(messages.size()); + + for (String ignored : messages) { + sentIntents.add(PendingIntent.getBroadcast(context, 0, + constructSentIntent(context, messageId, type, secure, false), + 0)); + } + + return sentIntents; + } + + private ArrayList constructDeliveredIntents(long messageId, long type, ArrayList messages) { + if (!TextSecurePreferences.isSmsDeliveryReportsEnabled(context)) { + return null; + } + + ArrayList deliveredIntents = new ArrayList<>(messages.size()); + + for (String ignored : messages) { + deliveredIntents.add(PendingIntent.getBroadcast(context, 0, + constructDeliveredIntent(context, messageId, type), + 0)); + } + + return deliveredIntents; + } + + private Intent constructSentIntent(Context context, long messageId, long type, + boolean upgraded, boolean push) + { + Intent pending = new Intent(SmsDeliveryListener.SENT_SMS_ACTION, + Uri.parse("custom://" + messageId + System.currentTimeMillis()), + context, SmsDeliveryListener.class); + + pending.putExtra("type", type); + pending.putExtra("message_id", messageId); + pending.putExtra("run_attempt", Math.max(runAttempt, getRunAttempt())); + pending.putExtra("upgraded", upgraded); + pending.putExtra("push", push); + + return pending; + } + + private Intent constructDeliveredIntent(Context context, long messageId, long type) { + Intent pending = new Intent(SmsDeliveryListener.DELIVERED_SMS_ACTION, + Uri.parse("custom://" + messageId + System.currentTimeMillis()), + context, SmsDeliveryListener.class); + pending.putExtra("type", type); + pending.putExtra("message_id", messageId); + + return pending; + } + + private SmsManager getSmsManagerFor(int subscriptionId) { + if (Build.VERSION.SDK_INT >= 22 && subscriptionId != -1) { + return SmsManager.getSmsManagerForSubscriptionId(subscriptionId); + } else { + return SmsManager.getDefault(); + } + } + + private static Job.Parameters constructParameters(@NonNull Recipient destination) { + return new Job.Parameters.Builder() + .setMaxAttempts(MAX_ATTEMPTS) + .setQueue(destination.getId().toQueueKey()) + .addConstraint(NetworkOrCellServiceConstraint.KEY) + .build(); + } + + private static class TooManyRetriesException extends Exception { } + + public static class Factory implements Job.Factory { + @Override + public @NonNull SmsSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { + return new SmsSendJob(parameters, data.getLong(KEY_MESSAGE_ID), data.getInt(KEY_RUN_ATTEMPT)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSentJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSentJob.java new file mode 100644 index 00000000..00cedd58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SmsSentJob.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Activity; +import android.telephony.SmsManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.service.SmsDeliveryListener; + +public class SmsSentJob extends BaseJob { + + public static final String KEY = "SmsSentJob"; + + private static final String TAG = SmsSentJob.class.getSimpleName(); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_ACTION = "action"; + private static final String KEY_RESULT = "result"; + private static final String KEY_RUN_ATTEMPT = "run_attempt"; + + private long messageId; + private String action; + private int result; + private int runAttempt; + + public SmsSentJob(long messageId, String action, int result, int runAttempt) { + this(new Job.Parameters.Builder().build(), + messageId, + action, + result, + runAttempt); + } + + private SmsSentJob(@NonNull Job.Parameters parameters, long messageId, String action, int result, int runAttempt) { + super(parameters); + + this.messageId = messageId; + this.action = action; + this.result = result; + this.runAttempt = runAttempt; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putString(KEY_ACTION, action) + .putInt(KEY_RESULT, result) + .putInt(KEY_RUN_ATTEMPT, runAttempt) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + Log.i(TAG, "Got SMS callback: " + action + " , " + result); + + switch (action) { + case SmsDeliveryListener.SENT_SMS_ACTION: + handleSentResult(messageId, result); + break; + case SmsDeliveryListener.DELIVERED_SMS_ACTION: + handleDeliveredResult(messageId, result); + break; + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception throwable) { + return false; + } + + @Override + public void onFailure() { + } + + private void handleDeliveredResult(long messageId, int result) { + DatabaseFactory.getSmsDatabase(context).markSmsStatus(messageId, result); + } + + private void handleSentResult(long messageId, int result) { + try { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + SmsMessageRecord record = database.getSmsMessage(messageId); + + switch (result) { + case Activity.RESULT_OK: + database.markAsSent(messageId, false); + break; + case SmsManager.RESULT_ERROR_NO_SERVICE: + case SmsManager.RESULT_ERROR_RADIO_OFF: + Log.w(TAG, "Service connectivity problem, requeuing..."); + ApplicationDependencies.getJobManager().add(new SmsSendJob(messageId, record.getIndividualRecipient(), runAttempt + 1)); + break; + default: + database.markAsSentFailed(messageId); + ApplicationDependencies.getMessageNotifier().notifyMessageDeliveryFailed(context, record.getRecipient(), record.getThreadId()); + } + } catch (NoSuchMessageException e) { + Log.w(TAG, e); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull SmsSentJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new SmsSentJob(parameters, + data.getLong(KEY_MESSAGE_ID), + data.getString(KEY_ACTION), + data.getInt(KEY_RESULT), + data.getInt(KEY_RUN_ATTEMPT)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerDownloadJob.java new file mode 100644 index 00000000..1a4ea149 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerDownloadJob.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.model.IncomingSticker; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; + +public class StickerDownloadJob extends BaseJob { + + public static final String KEY = "StickerDownloadJob"; + + private static final String TAG = Log.tag(StickerDownloadJob.class); + + private static final String KEY_PACK_ID = "pack_id"; + private static final String KEY_PACK_KEY = "pack_key"; + private static final String KEY_PACK_TITLE = "pack_title"; + private static final String KEY_PACK_AUTHOR = "pack_author"; + private static final String KEY_STICKER_ID = "sticker_id"; + private static final String KEY_EMOJI = "emoji"; + private static final String KEY_CONTENT_TYPE = "content_type"; + private static final String KEY_COVER = "cover"; + private static final String KEY_INSTALLED = "installed"; + private static final String KEY_NOTIFY = "notify"; + + private final IncomingSticker sticker; + private final boolean notify; + + StickerDownloadJob(@NonNull IncomingSticker sticker, boolean notify) { + this(new Job.Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(30)) + .build(), + sticker, + notify); + } + + private StickerDownloadJob(@NonNull Job.Parameters parameters, @NonNull IncomingSticker sticker, boolean notify) { + super(parameters); + this.sticker = sticker; + this.notify = notify; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_PACK_ID, sticker.getPackId()) + .putString(KEY_PACK_KEY, sticker.getPackKey()) + .putString(KEY_PACK_TITLE, sticker.getPackTitle()) + .putString(KEY_PACK_AUTHOR, sticker.getPackAuthor()) + .putInt(KEY_STICKER_ID, sticker.getStickerId()) + .putString(KEY_EMOJI, sticker.getEmoji()) + .putString(KEY_CONTENT_TYPE, sticker.getContentType()) + .putBoolean(KEY_COVER, sticker.isCover()) + .putBoolean(KEY_INSTALLED, sticker.isInstalled()) + .putBoolean(KEY_NOTIFY, notify) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + StickerDatabase db = DatabaseFactory.getStickerDatabase(context); + + StickerRecord stickerRecord = db.getSticker(sticker.getPackId(), sticker.getStickerId(), sticker.isCover()); + if (stickerRecord != null) { + try (InputStream stream = PartAuthority.getAttachmentStream(context, stickerRecord.getUri())) { + if (stream != null) { + Log.w(TAG, "Sticker already downloaded."); + return; + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Sticker file no longer exists, downloading again."); + } + } + + if (!db.isPackInstalled(sticker.getPackId()) && !sticker.isCover()) { + Log.w(TAG, "Pack is no longer installed."); + return; + } + + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + byte[] packIdBytes = Hex.fromStringCondensed(sticker.getPackId ()); + byte[] packKeyBytes = Hex.fromStringCondensed(sticker.getPackKey()); + InputStream stream = receiver.retrieveSticker(packIdBytes, packKeyBytes, sticker.getStickerId()); + + db.insertSticker(sticker, stream, notify); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to download sticker!"); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull StickerDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + IncomingSticker sticker = new IncomingSticker(data.getString(KEY_PACK_ID), + data.getString(KEY_PACK_KEY), + data.getString(KEY_PACK_TITLE), + data.getString(KEY_PACK_AUTHOR), + data.getInt(KEY_STICKER_ID), + data.getString(KEY_EMOJI), + data.getString(KEY_CONTENT_TYPE), + data.getBoolean(KEY_COVER), + data.getBoolean(KEY_INSTALLED)); + + return new StickerDownloadJob(parameters, sticker, data.getBoolean(KEY_NOTIFY)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerPackDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerPackDownloadJob.java new file mode 100644 index 00000000..bc8546aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StickerPackDownloadJob.java @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.core.util.Preconditions; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.model.IncomingSticker; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class StickerPackDownloadJob extends BaseJob { + + public static final String KEY = "StickerPackDownloadJob"; + + private static final String TAG = Log.tag(StickerPackDownloadJob.class); + + private static final String KEY_PACK_ID = "pack_key"; + private static final String KEY_PACK_KEY = "pack_id"; + private static final String KEY_REFERENCE_PACK = "reference_pack"; + private static final String KEY_NOTIFY = "notify"; + + private final String packId; + private final String packKey; + private final boolean isReferencePack; + private final boolean notify; + + /** + * Downloads all the stickers in a pack. + * @param notify Whether or not a tooltip will be shown indicating the pack was installed. + */ + public static @NonNull StickerPackDownloadJob forInstall(@NonNull String packId, @NonNull String packKey, boolean notify) { + return new StickerPackDownloadJob(packId, packKey, false, notify); + } + + /** + * Just installs a reference to the pack -- i.e. just the cover. + */ + public static @NonNull StickerPackDownloadJob forReference(@NonNull String packId, @NonNull String packKey) { + return new StickerPackDownloadJob(packId, packKey, true, true); + } + + private StickerPackDownloadJob(@NonNull String packId, @NonNull String packKey, boolean isReferencePack, boolean notify) + { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(30)) + .setQueue("StickerPackDownloadJob_" + packId) + .build(), + packId, + packKey, + isReferencePack, + notify); + } + + private StickerPackDownloadJob(@NonNull Parameters parameters, + @NonNull String packId, + @NonNull String packKey, + boolean isReferencePack, + boolean notify) + { + super(parameters); + + Preconditions.checkNotNull(packId); + Preconditions.checkNotNull(packKey); + + this.packId = packId; + this.packKey = packKey; + this.isReferencePack = isReferencePack; + this.notify = notify; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_PACK_ID, packId) + .putString(KEY_PACK_KEY, packKey) + .putBoolean(KEY_REFERENCE_PACK, isReferencePack) + .putBoolean(KEY_NOTIFY, notify) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, InvalidMessageException { + if (isReferencePack && !DatabaseFactory.getAttachmentDatabase(context).containsStickerPackId(packId) && !BlessedPacks.contains(packId)) { + Log.w(TAG, "There are no attachments with the requested packId present for this reference pack. Skipping."); + return; + } + + if (isReferencePack && DatabaseFactory.getStickerDatabase(context).isPackAvailableAsReference(packId)) { + Log.i(TAG, "Sticker pack already available for reference. Skipping."); + return; + } + + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + JobManager jobManager = ApplicationDependencies.getJobManager(); + StickerDatabase stickerDatabase = DatabaseFactory.getStickerDatabase(context); + byte[] packIdBytes = Hex.fromStringCondensed(packId); + byte[] packKeyBytes = Hex.fromStringCondensed(packKey); + SignalServiceStickerManifest manifest = receiver.retrieveStickerManifest(packIdBytes, packKeyBytes); + + if (manifest.getStickers().isEmpty()) { + Log.w(TAG, "No stickers in pack!"); + return; + } + + if (!isReferencePack && stickerDatabase.isPackAvailableAsReference(packId)) { + stickerDatabase.markPackAsInstalled(packId, notify); + } + + StickerInfo cover = manifest.getCover().or(manifest.getStickers().get(0)); + JobManager.Chain chain = jobManager.startChain(new StickerDownloadJob(new IncomingSticker(packId, + packKey, + manifest.getTitle().or(""), + manifest.getAuthor().or(""), + cover.getId(), + "", + cover.getContentType(), + true, + !isReferencePack), + notify)); + + + + if (!isReferencePack) { + List jobs = new ArrayList<>(manifest.getStickers().size()); + + for (StickerInfo stickerInfo : manifest.getStickers()) { + jobs.add(new StickerDownloadJob(new IncomingSticker(packId, + packKey, + manifest.getTitle().or(""), + manifest.getAuthor().or(""), + stickerInfo.getId(), + stickerInfo.getEmoji(), + stickerInfo.getContentType(), + false, + true), + notify)); + } + + chain.then(jobs); + } + + chain.enqueue(); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to download manifest! Uninstalling pack."); + DatabaseFactory.getStickerDatabase(context).uninstallPack(packId); + DatabaseFactory.getStickerDatabase(context).deleteOrphanedPacks(); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull StickerPackDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StickerPackDownloadJob(parameters, + data.getString(KEY_PACK_ID), + data.getString(KEY_PACK_KEY), + data.getBoolean(KEY_REFERENCE_PACK), + data.getBoolean(KEY_NOTIFY)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java new file mode 100644 index 00000000..67013c0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JobTracker; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.storage.StorageKey; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Restored the AccountRecord present in the storage service, if any. This will overwrite any local + * data that is stored in AccountRecord, so this should only be done immediately after registration. + */ +public class StorageAccountRestoreJob extends BaseJob { + + public static String KEY = "StorageAccountRestoreJob"; + + public static long LIFESPAN = TimeUnit.SECONDS.toMillis(20); + + private static final String TAG = Log.tag(StorageAccountRestoreJob.class); + + public StorageAccountRestoreJob() { + this(new Parameters.Builder() + .setQueue(StorageSyncJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setMaxAttempts(1) + .setLifespan(LIFESPAN) + .build()); + } + + private StorageAccountRestoreJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageKey(); + + Log.i(TAG, "Retrieving manifest..."); + Optional manifest = accountManager.getStorageManifest(storageServiceKey); + + if (!manifest.isPresent()) { + Log.w(TAG, "Manifest did not exist or was undecryptable (bad key). Not restoring. Force-pushing."); + ApplicationDependencies.getJobManager().add(new StorageForcePushJob()); + return; + } + + Optional accountId = manifest.get().getAccountStorageId(); + + if (!accountId.isPresent()) { + Log.w(TAG, "Manifest had no account record! Not restoring."); + return; + } + + Log.i(TAG, "Retrieving account record..."); + List records = accountManager.readStorageRecords(storageServiceKey, Collections.singletonList(accountId.get())); + SignalStorageRecord record = records.size() > 0 ? records.get(0) : null; + + if (record == null) { + Log.w(TAG, "Could not find account record, even though we had an ID! Not restoring."); + return; + } + + SignalAccountRecord accountRecord = record.getAccount().orNull(); + if (accountRecord == null) { + Log.w(TAG, "The storage record didn't actually have an account on it! Not restoring."); + return; + } + + + Log.i(TAG, "Applying changes locally..."); + StorageId selfStorageId = StorageId.forAccount(Recipient.self().getStorageServiceId()); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, selfStorageId, accountRecord, false); + + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (accountRecord.getAvatarUrlPath().isPresent()) { + Log.i(TAG, "Fetching avatar..."); + Optional state = jobManager.runSynchronously(new RetrieveProfileAvatarJob(Recipient.self(), accountRecord.getAvatarUrlPath().get()), LIFESPAN/2); + + if (state.isPresent()) { + Log.i(TAG, "Avatar retrieved successfully. " + state.get()); + } else { + Log.w(TAG, "Avatar retrieval did not complete in time (or otherwise failed)."); + } + } else { + Log.i(TAG, "No avatar present. Not fetching."); + } + + Log.i(TAG, "Refreshing attributes..."); + Optional state = jobManager.runSynchronously(new RefreshAttributesJob(), LIFESPAN/2); + + if (state.isPresent()) { + Log.i(TAG, "Attributes refreshed successfully. " + state.get()); + } else { + Log.w(TAG, "Attribute refresh did not complete in time (or otherwise failed)."); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + StorageAccountRestoreJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageAccountRestoreJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java new file mode 100644 index 00000000..fe28698f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.StorageKeyDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncModels; +import org.thoughtcrime.securesms.storage.StorageSyncValidations; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.storage.StorageKey; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Forces remote storage to match our local state. This should only be done when we detect that the + * remote data is badly-encrypted (which should only happen after re-registering without a PIN). + */ +public class StorageForcePushJob extends BaseJob { + + public static final String KEY = "StorageForcePushJob"; + + private static final String TAG = Log.tag(StorageForcePushJob.class); + + public StorageForcePushJob() { + this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY) + .setQueue(StorageSyncJob.QUEUE_KEY) + .setMaxInstancesForFactory(1) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); + } + + private StorageForcePushJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, RetryLaterException { + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageKey(); + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); + + long currentVersion = accountManager.getStorageManifestVersion(); + Map oldContactStorageIds = recipientDatabase.getContactStorageSyncIdsMap(); + + long newVersion = currentVersion + 1; + Map newContactStorageIds = generateContactStorageIds(oldContactStorageIds); + List inserts = Stream.of(oldContactStorageIds.keySet()) + .map(recipientDatabase::getRecipientSettingsForSync) + .withoutNulls() + .map(s -> StorageSyncModels.localToRemoteRecord(s, Objects.requireNonNull(newContactStorageIds.get(s.getId())).getRaw())) + .toList(); + + SignalStorageRecord accountRecord = StorageSyncHelper.buildAccountRecord(context, Recipient.self().fresh()); + List allNewStorageIds = new ArrayList<>(newContactStorageIds.values()); + + inserts.add(accountRecord); + allNewStorageIds.add(accountRecord.getId()); + + SignalStorageManifest manifest = new SignalStorageManifest(newVersion, allNewStorageIds); + StorageSyncValidations.validateForcePush(manifest, inserts); + + try { + if (newVersion > 1) { + Log.i(TAG, String.format(Locale.ENGLISH, "Force-pushing data. Inserting %d keys.", inserts.size())); + if (accountManager.resetStorageRecords(storageServiceKey, manifest, inserts).isPresent()) { + Log.w(TAG, "Hit a conflict. Trying again."); + throw new RetryLaterException(); + } + } else { + Log.i(TAG, String.format(Locale.ENGLISH, "First version, normal push. Inserting %d keys.", inserts.size())); + if (accountManager.writeStorageRecords(storageServiceKey, manifest, inserts, Collections.emptyList()).isPresent()) { + Log.w(TAG, "Hit a conflict. Trying again."); + throw new RetryLaterException(); + } + } + } catch (InvalidKeyException e) { + Log.w(TAG, "Hit an invalid key exception, which likely indicates a conflict."); + throw new RetryLaterException(e); + } + + Log.i(TAG, "Force push succeeded. Updating local manifest version to: " + newVersion); + TextSecurePreferences.setStorageManifestVersion(context, newVersion); + recipientDatabase.applyStorageIdUpdates(newContactStorageIds); + recipientDatabase.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().getId(), accountRecord.getId())); + storageKeyDatabase.deleteAll(); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private static @NonNull Map generateContactStorageIds(@NonNull Map oldKeys) { + Map out = new HashMap<>(); + + for (Map.Entry entry : oldKeys.entrySet()) { + out.put(entry.getKey(), entry.getValue().withNewBytes(StorageSyncHelper.generateKey())); + } + + return out; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageForcePushJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java new file mode 100644 index 00000000..0dffad86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -0,0 +1,353 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.database.StorageKeyDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.GroupV2ExistenceChecker; +import org.thoughtcrime.securesms.storage.StaticGroupV2ExistenceChecker; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyDifferenceResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.LocalWriteResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.MergeResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult; +import org.thoughtcrime.securesms.storage.StorageSyncModels; +import org.thoughtcrime.securesms.storage.StorageSyncValidations; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.storage.StorageKey; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Does a full sync of our local storage state with the remote storage state. Will write any pending + * local changes and resolve any conflicts with remote storage. + * + * This should be performed whenever a change is made locally, or whenever we want to retrieve + * changes that have been made remotely. + */ +public class StorageSyncJob extends BaseJob { + + public static final String KEY = "StorageSyncJob"; + public static final String QUEUE_KEY = "StorageSyncingJobs"; + + private static final String TAG = Log.tag(StorageSyncJob.class); + + public StorageSyncJob() { + this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY) + .setQueue(QUEUE_KEY) + .setMaxInstancesForFactory(2) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); + } + + private StorageSyncJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, RetryLaterException { + if (!SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut()) { + Log.i(TAG, "Doesn't have a PIN. Skipping."); + return; + } + + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.i(TAG, "Not registered. Skipping."); + return; + } + + try { + boolean needsMultiDeviceSync = performSync(); + + if (TextSecurePreferences.isMultiDevice(context) && needsMultiDeviceSync) { + ApplicationDependencies.getJobManager().add(new MultiDeviceStorageSyncRequestJob()); + } + + SignalStore.storageServiceValues().onSyncCompleted(); + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e); + + ApplicationDependencies.getJobManager().startChain(new MultiDeviceKeysUpdateJob()) + .then(new StorageForcePushJob()) + .then(new MultiDeviceStorageSyncRequestJob()) + .enqueue(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private boolean performSync() throws IOException, RetryLaterException, InvalidKeyException { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageKey(); + + boolean needsMultiDeviceSync = false; + boolean needsForcePush = false; + long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); + Optional remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifestVersion); + long remoteManifestVersion = remoteManifest.transform(SignalStorageManifest::getVersion).or(localManifestVersion); + + Log.i(TAG, "Our version: " + localManifestVersion + ", their version: " + remoteManifestVersion); + + if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) { + Log.i(TAG, "[Remote Newer] Newer manifest version found!"); + + List allLocalStorageKeys = getAllLocalStorageIds(context, Recipient.self().fresh()); + KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageIds(), allLocalStorageKeys); + + if (keyDifference.hasTypeMismatches()) { + Log.w(TAG, "Found type mismatches in the key sets! Scheduling a force push after this sync completes."); + needsForcePush = true; + } + + if (!keyDifference.isEmpty()) { + Log.i(TAG, "[Remote Newer] There's a difference in keys. Local-only: " + keyDifference.getLocalOnlyKeys().size() + ", Remote-only: " + keyDifference.getRemoteOnlyKeys().size()); + + List localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys()); + List remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys()); + GroupV2ExistenceChecker gv2ExistenceChecker = new StaticGroupV2ExistenceChecker(DatabaseFactory.getGroupDatabase(context).getAllGroupV2Ids()); + MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly, gv2ExistenceChecker); + WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), allLocalStorageKeys, mergeResult); + + if (remoteOnly.size() != keyDifference.getRemoteOnlyKeys().size()) { + Log.w(TAG, "Could not find all remote-only records! Requested: " + keyDifference.getRemoteOnlyKeys().size() + ", Found: " + remoteOnly.size() + ". Scheduling a force push after this sync completes."); + needsForcePush = true; + } + + StorageSyncValidations.validate(writeOperationResult); + + Log.i(TAG, "[Remote Newer] MergeResult :: " + mergeResult); + + if (!writeOperationResult.isEmpty()) { + Log.i(TAG, "[Remote Newer] WriteOperationResult :: " + writeOperationResult); + Log.i(TAG, "[Remote Newer] We have something to write remotely."); + + if (writeOperationResult.getManifest().getStorageIds().size() != remoteManifest.get().getStorageIds().size() + writeOperationResult.getInserts().size() - writeOperationResult.getDeletes().size()) { + Log.w(TAG, String.format(Locale.ENGLISH, "Bad storage key management! originalRemoteKeys: %d, newRemoteKeys: %d, insertedKeys: %d, deletedKeys: %d", + remoteManifest.get().getStorageIds().size(), writeOperationResult.getManifest().getStorageIds().size(), writeOperationResult.getInserts().size(), writeOperationResult.getDeletes().size())); + } + + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes()); + + if (conflict.isPresent()) { + Log.w(TAG, "[Remote Newer] Hit a conflict when trying to resolve the conflict! Retrying."); + throw new RetryLaterException(); + } + + remoteManifestVersion = writeOperationResult.getManifest().getVersion(); + + needsMultiDeviceSync = true; + } else { + Log.i(TAG, "[Remote Newer] After resolving the conflict, all changes are local. No remote writes needed."); + } + + migrateToGv2IfNecessary(context, mergeResult.getLocalGroupV2Inserts()); + recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates(), mergeResult.getLocalGroupV1Inserts(), mergeResult.getLocalGroupV1Updates(), mergeResult.getLocalGroupV2Inserts(), mergeResult.getLocalGroupV2Updates()); + storageKeyDatabase.applyStorageSyncUpdates(mergeResult.getLocalUnknownInserts(), mergeResult.getLocalUnknownDeletes()); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, mergeResult.getLocalAccountUpdate()); + + Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifestVersion); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifestVersion); + } else { + Log.i(TAG, "[Remote Newer] Remote version was newer, but our local data matched."); + Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifest.get().getVersion()); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.get().getVersion()); + } + } + + localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); + + Recipient self = Recipient.self().fresh(); + + List allLocalStorageKeys = getAllLocalStorageIds(context, self); + List pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates(); + List pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions(); + List pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions(); + Optional pendingAccountInsert = StorageSyncHelper.getPendingAccountSyncInsert(context, self); + Optional pendingAccountUpdate = StorageSyncHelper.getPendingAccountSyncUpdate(context, self); + Optional localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion, + allLocalStorageKeys, + pendingUpdates, + pendingInsertions, + pendingDeletions, + pendingAccountUpdate, + pendingAccountInsert); + + if (localWriteResult.isPresent()) { + Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes, account update: %b, account insert: %b.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size(), pendingAccountUpdate.isPresent(), pendingAccountInsert.isPresent())); + + WriteOperationResult localWrite = localWriteResult.get().getWriteResult(); + StorageSyncValidations.validate(localWrite); + + Log.i(TAG, "[Local Changes] WriteOperationResult :: " + localWrite); + + if (localWrite.isEmpty()) { + throw new AssertionError("Decided there were local writes, but our write result was empty!"); + } + + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes()); + + if (conflict.isPresent()) { + Log.w(TAG, "[Local Changes] Hit a conflict when trying to upload our local writes! Retrying."); + throw new RetryLaterException(); + } + + List clearIds = new ArrayList<>(pendingUpdates.size() + pendingInsertions.size() + pendingDeletions.size() + 1); + + clearIds.addAll(Stream.of(pendingUpdates).map(RecipientSettings::getId).toList()); + clearIds.addAll(Stream.of(pendingInsertions).map(RecipientSettings::getId).toList()); + clearIds.addAll(Stream.of(pendingDeletions).map(RecipientSettings::getId).toList()); + clearIds.add(Recipient.self().getId()); + + recipientDatabase.clearDirtyState(clearIds); + recipientDatabase.updateStorageKeys(localWriteResult.get().getStorageKeyUpdates()); + + needsMultiDeviceSync = true; + + Log.i(TAG, "[Local Changes] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion()); + TextSecurePreferences.setStorageManifestVersion(context, localWriteResult.get().getWriteResult().getManifest().getVersion()); + } else { + Log.i(TAG, "[Local Changes] No local changes."); + } + + if (needsForcePush) { + Log.w(TAG, "Scheduling a force push."); + ApplicationDependencies.getJobManager().add(new StorageForcePushJob()); + } + + return needsMultiDeviceSync; + } + + /** + * Migrates any of the provided V2 IDs that map a local V1 ID. If a match is found, we remove the + * record from the collection of V2 IDs. + */ + private static void migrateToGv2IfNecessary(@NonNull Context context, @NonNull Collection inserts) + throws IOException + { + Map idMap = DatabaseFactory.getGroupDatabase(context).getAllExpectedV2Ids(); + Iterator recordIterator = inserts.iterator(); + + while (recordIterator.hasNext()) { + GroupId.V2 id = GroupId.v2(GroupUtil.requireMasterKey(recordIterator.next().getMasterKeyBytes())); + + if (idMap.containsKey(id)) { + Log.i(TAG, "Discovered a new GV2 ID that is actually a migrated V1 group! Migrating now."); + GroupsV1MigrationUtil.performLocalMigration(context, idMap.get(id)); + recordIterator.remove(); + } + } + } + + private static @NonNull List getAllLocalStorageIds(@NonNull Context context, @NonNull Recipient self) { + return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getContactStorageSyncIds(), + Collections.singletonList(StorageId.forAccount(self.getStorageServiceId())), + DatabaseFactory.getStorageKeyDatabase(context).getAllKeys()); + } + + private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List ids) { + Recipient self = Recipient.self().fresh(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); + + List records = new ArrayList<>(ids.size()); + + for (StorageId id : ids) { + switch (id.getType()) { + case ManifestRecord.Identifier.Type.CONTACT_VALUE: + case ManifestRecord.Identifier.Type.GROUPV1_VALUE: + case ManifestRecord.Identifier.Type.GROUPV2_VALUE: + RecipientSettings settings = recipientDatabase.getByStorageId(id.getRaw()); + if (settings != null) { + if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && settings.getSyncExtras().getGroupMasterKey() == null) { + Log.w(TAG, "Missing master key on gv2 recipient"); + } else { + records.add(StorageSyncModels.localToRemoteRecord(settings)); + } + } else { + Log.w(TAG, "Missing local recipient model! Type: " + id.getType()); + } + break; + case ManifestRecord.Identifier.Type.ACCOUNT_VALUE: + if (!Arrays.equals(self.getStorageServiceId(), id.getRaw())) { + throw new AssertionError("Local storage ID doesn't match self!"); + } + records.add(StorageSyncHelper.buildAccountRecord(context, self)); + break; + default: + SignalStorageRecord unknown = storageKeyDatabase.getById(id.getRaw()); + if (unknown != null) { + records.add(unknown); + } else { + Log.w(TAG, "Missing local unknown model! Type: " + id.getType()); + } + break; + } + } + + return records; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull StorageSyncJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageSyncJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java new file mode 100644 index 00000000..2acb238a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java @@ -0,0 +1,87 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public class TrimThreadJob extends BaseJob { + + public static final String KEY = "TrimThreadJob"; + + private static final String TAG = TrimThreadJob.class.getSimpleName(); + + private static final String KEY_THREAD_ID = "thread_id"; + + private long threadId; + + public TrimThreadJob(long threadId) { + this(new Job.Parameters.Builder().setQueue("TrimThreadJob").build(), threadId); + } + + private TrimThreadJob(@NonNull Job.Parameters parameters, long threadId) { + super(parameters); + this.threadId = threadId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_THREAD_ID, threadId).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); + + int trimLength = SignalStore.settings().isTrimByLengthEnabled() ? SignalStore.settings().getThreadTrimLength() + : ThreadDatabase.NO_TRIM_MESSAGE_COUNT_SET; + + long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration() + : ThreadDatabase.NO_TRIM_BEFORE_DATE_SET; + + DatabaseFactory.getThreadDatabase(context).trimThread(threadId, trimLength, trimBeforeDate); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + @Override + public void onFailure() { + Log.w(TAG, "Canceling trim attempt: " + threadId); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull TrimThreadJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new TrimThreadJob(parameters, data.getLong(KEY_THREAD_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java new file mode 100644 index 00000000..391b3e13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.CancelationException; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage.Action; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class TypingSendJob extends BaseJob { + + public static final String KEY = "TypingSendJob"; + + private static final String TAG = TypingSendJob.class.getSimpleName(); + + private static final String KEY_THREAD_ID = "thread_id"; + private static final String KEY_TYPING = "typing"; + + private long threadId; + private boolean typing; + + public TypingSendJob(long threadId, boolean typing) { + this(new Job.Parameters.Builder() + .setQueue(getQueue(threadId)) + .setMaxAttempts(1) + .setLifespan(TimeUnit.SECONDS.toMillis(5)) + .addConstraint(NetworkConstraint.KEY) + .setMemoryOnly(true) + .build(), + threadId, + typing); + } + + public static String getQueue(long threadId) { + return "TYPING_" + threadId; + } + + private TypingSendJob(@NonNull Job.Parameters parameters, long threadId, boolean typing) { + super(parameters); + + this.threadId = threadId; + this.typing = typing; + } + + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_THREAD_ID, threadId) + .putBoolean(KEY_TYPING, typing) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws Exception { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(context)) { + return; + } + + Log.d(TAG, "Sending typing " + (typing ? "started" : "stopped") + " for thread " + threadId); + + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (recipient == null) { + Log.w(TAG, "Tried to send a typing indicator to a non-existent thread."); + return; + } + + if (recipient.isBlocked()) { + Log.w(TAG, "Not sending typing indicators to blocked recipients."); + return; + } + + if (recipient.isSelf()) { + Log.w(TAG, "Not sending typing indicators to self."); + return; + } + + List recipients = Collections.singletonList(recipient); + Optional groupId = Optional.absent(); + + if (recipient.isGroup()) { + recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + groupId = Optional.of(recipient.requireGroupId().getDecodedId()); + } + + recipients = RecipientUtil.getEligibleForSending(Stream.of(recipients) + .map(Recipient::resolve) + .filter(r -> !r.isBlocked()) + .toList()); + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, recipients); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipients); + SignalServiceTypingMessage typingMessage = new SignalServiceTypingMessage(typing ? Action.STARTED : Action.STOPPED, System.currentTimeMillis(), groupId); + + if (addresses.isEmpty()) { + Log.w(TAG, "No one to send typing indicators to"); + return; + } + + if (isCanceled()) { + Log.w(TAG, "Canceled before send!"); + return; + } + + try { + messageSender.sendTyping(addresses, unidentifiedAccess, typingMessage, this::isCanceled); + } catch (CancelationException e) { + Log.w(TAG, "Canceled during send!"); + } + } + + @Override + public void onFailure() { + } + + @Override + protected boolean onShouldRetry(@NonNull Exception exception) { + return false; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull TypingSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new TypingSendJob(parameters, data.getLong(KEY_THREAD_ID), data.getBoolean(KEY_TYPING)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java new file mode 100644 index 00000000..cc361884 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java @@ -0,0 +1,292 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.app.DownloadManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.service.UpdateApkReadyListener; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class UpdateApkJob extends BaseJob { + + public static final String KEY = "UpdateApkJob"; + + private static final String TAG = UpdateApkJob.class.getSimpleName(); + + public UpdateApkJob() { + this(new Job.Parameters.Builder() + .setQueue("UpdateApkJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(2) + .build()); + } + + private UpdateApkJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, PackageManager.NameNotFoundException { + if (!BuildConfig.PLAY_STORE_DISABLED) return; + + Log.i(TAG, "Checking for APK update..."); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder().url(String.format("%s/latest.json", BuildConfig.NOPLAY_UPDATE_URL)).build(); + + Response response = client.newCall(request).execute(); + + if (!response.isSuccessful()) { + throw new IOException("Bad response: " + response.message()); + } + + UpdateDescriptor updateDescriptor = JsonUtils.fromJson(response.body().string(), UpdateDescriptor.class); + byte[] digest = Hex.fromStringCondensed(updateDescriptor.getDigest()); + + Log.i(TAG, "Got descriptor: " + updateDescriptor); + + if (updateDescriptor.getVersionCode() > getVersionCode()) { + DownloadStatus downloadStatus = getDownloadStatus(updateDescriptor.getUrl(), digest); + + Log.i(TAG, "Download status: " + downloadStatus.getStatus()); + + if (downloadStatus.getStatus() == DownloadStatus.Status.COMPLETE) { + Log.i(TAG, "Download status complete, notifying..."); + handleDownloadNotify(downloadStatus.getDownloadId()); + } else if (downloadStatus.getStatus() == DownloadStatus.Status.MISSING) { + Log.i(TAG, "Download status missing, starting download..."); + handleDownloadStart(updateDescriptor.getUrl(), updateDescriptor.getVersionName(), digest); + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Update check failed"); + } + + private int getVersionCode() throws PackageManager.NameNotFoundException { + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); + + return packageInfo.versionCode; + } + + private DownloadStatus getDownloadStatus(String uri, byte[] theirDigest) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Query query = new DownloadManager.Query(); + + query.setFilterByStatus(DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_PENDING | DownloadManager.STATUS_RUNNING | DownloadManager.STATUS_SUCCESSFUL); + + long pendingDownloadId = TextSecurePreferences.getUpdateApkDownloadId(context); + byte[] pendingDigest = getPendingDigest(context); + Cursor cursor = downloadManager.query(query); + + try { + DownloadStatus status = new DownloadStatus(DownloadStatus.Status.MISSING, -1); + + while (cursor != null && cursor.moveToNext()) { + int jobStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); + String jobRemoteUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)); + long downloadId = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); + byte[] digest = getDigestForDownloadId(downloadId); + + if (jobRemoteUri != null && jobRemoteUri.equals(uri) && downloadId == pendingDownloadId) { + + if (jobStatus == DownloadManager.STATUS_SUCCESSFUL && + digest != null && pendingDigest != null && + MessageDigest.isEqual(pendingDigest, theirDigest) && + MessageDigest.isEqual(digest, theirDigest)) + { + return new DownloadStatus(DownloadStatus.Status.COMPLETE, downloadId); + } else if (jobStatus != DownloadManager.STATUS_SUCCESSFUL) { + status = new DownloadStatus(DownloadStatus.Status.PENDING, downloadId); + } + } + } + + return status; + } finally { + if (cursor != null) cursor.close(); + } + } + + private void handleDownloadStart(String uri, String versionName, byte[] digest) { + clearPreviousDownloads(context); + + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Request downloadRequest = new DownloadManager.Request(Uri.parse(uri)); + + downloadRequest.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI); + downloadRequest.setTitle("Downloading Signal update"); + downloadRequest.setDescription("Downloading Signal " + versionName); + downloadRequest.setVisibleInDownloadsUi(false); + downloadRequest.setDestinationInExternalFilesDir(context, null, "signal-update.apk"); + downloadRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); + + long downloadId = downloadManager.enqueue(downloadRequest); + TextSecurePreferences.setUpdateApkDownloadId(context, downloadId); + TextSecurePreferences.setUpdateApkDigest(context, Hex.toStringCondensed(digest)); + } + + private void handleDownloadNotify(long downloadId) { + Intent intent = new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE); + intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId); + + new UpdateApkReadyListener().onReceive(context, intent); + } + + private @Nullable byte[] getDigestForDownloadId(long downloadId) { + try { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); + byte[] digest = FileUtils.getFileDigest(fin); + + fin.close(); + + return digest; + } + catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private @Nullable byte[] getPendingDigest(Context context) { + try { + String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); + + if (encodedDigest == null) return null; + + return Hex.fromStringCondensed(encodedDigest); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private static void clearPreviousDownloads(@NonNull Context context) { + File directory = context.getExternalFilesDir(null); + + if (directory == null) { + Log.w(TAG, "Failed to read external files directory."); + return; + } + + for (File file : directory.listFiles()) { + if (file.getName().startsWith("signal-update")) { + if (file.delete()) { + Log.d(TAG, "Deleted " + file.getName()); + } + } + } + } + + private static class UpdateDescriptor { + @JsonProperty + private int versionCode; + + @JsonProperty + private String versionName; + + @JsonProperty + private String url; + + @JsonProperty + private String sha256sum; + + + public int getVersionCode() { + return versionCode; + } + + public String getVersionName() { + return versionName; + } + + public String getUrl() { + return url; + } + + public @NonNull String toString() { + return "[" + versionCode + ", " + versionName + ", " + url + "]"; + } + + public String getDigest() { + return sha256sum; + } + } + + private static class DownloadStatus { + enum Status { + PENDING, + COMPLETE, + MISSING + } + + private final Status status; + private final long downloadId; + + DownloadStatus(Status status, long downloadId) { + this.status = status; + this.downloadId = downloadId; + } + + public Status getStatus() { + return status; + } + + public long getDownloadId() { + return downloadId; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull UpdateApkJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new UpdateApkJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateType.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateType.java new file mode 100644 index 00000000..c6786983 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateType.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.keyvalue; + +public enum CertificateType { + UUID_AND_E164, + UUID_ONLY +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java new file mode 100644 index 00000000..c3ec5bbd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +public final class CertificateValues extends SignalStoreValues { + + private static final String UD_CERTIFICATE_UUID_AND_E164 = "certificate.uuidAndE164"; + private static final String UD_CERTIFICATE_UUID_ONLY = "certificate.uuidOnly"; + + CertificateValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + @WorkerThread + public void setUnidentifiedAccessCertificate(@NonNull CertificateType certificateType, + @Nullable byte[] certificate) + { + KeyValueStore.Writer writer = getStore().beginWrite(); + + switch (certificateType) { + case UUID_AND_E164: writer.putBlob(UD_CERTIFICATE_UUID_AND_E164, certificate); break; + case UUID_ONLY : writer.putBlob(UD_CERTIFICATE_UUID_ONLY, certificate); break; + default : throw new AssertionError(); + } + + writer.commit(); + } + + public @Nullable byte[] getUnidentifiedAccessCertificate(@NonNull CertificateType certificateType) { + switch (certificateType) { + case UUID_AND_E164: return getBlob(UD_CERTIFICATE_UUID_AND_E164, null); + case UUID_ONLY : return getBlob(UD_CERTIFICATE_UUID_ONLY, null); + default : throw new AssertionError(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java new file mode 100644 index 00000000..ac1cddf0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; + +public class EmojiValues extends SignalStoreValues { + + private static final String PREFIX = "emojiPref__"; + + EmojiValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + + } + + public void setPreferredVariation(@NonNull String emoji) { + String canonical = EmojiUtil.getCanonicalRepresentation(emoji); + + if (canonical.equals(emoji)) { + getStore().beginWrite().remove(PREFIX + canonical).apply(); + } else { + putString(PREFIX + canonical, emoji); + } + } + + public @NonNull String getPreferredVariation(@NonNull String emoji) { + String canonical = EmojiUtil.getCanonicalRepresentation(emoji); + + return getString(PREFIX + canonical, emoji); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java new file mode 100644 index 00000000..7bfc84e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.auth.AuthCredentialResponse; +import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponse; +import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponses; +import org.thoughtcrime.securesms.groups.GroupsV2Authorization; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Authorization.ValueCache { + + private static final String TAG = Log.tag(GroupsV2AuthorizationSignalStoreCache.class); + + private static final String KEY = "gv2:auth_token_cache"; + + private final KeyValueStore store; + + GroupsV2AuthorizationSignalStoreCache(KeyValueStore store) { + this.store = store; + } + + @Override + public void clear() { + store.beginWrite() + .remove(KEY) + .commit(); + + Log.i(TAG, "Cleared local response cache"); + } + + @Override + public @NonNull Map read() { + byte[] credentialBlob = store.getBlob(KEY, null); + + if (credentialBlob == null) { + Log.i(TAG, "No credentials responses are cached locally"); + return Collections.emptyMap(); + } + + try { + TemporalAuthCredentialResponses temporalCredentials = TemporalAuthCredentialResponses.parseFrom(credentialBlob); + HashMap result = new HashMap<>(temporalCredentials.getCredentialResponseCount()); + + for (TemporalAuthCredentialResponse credential : temporalCredentials.getCredentialResponseList()) { + result.put(credential.getDate(), new AuthCredentialResponse(credential.getAuthCredentialResponse().toByteArray())); + } + + Log.i(TAG, String.format(Locale.US, "Loaded %d credentials from local storage", result.size())); + + return result; + } catch (InvalidProtocolBufferException | InvalidInputException e) { + throw new AssertionError(e); + } + } + + @Override + public void write(@NonNull Map values) { + TemporalAuthCredentialResponses.Builder builder = TemporalAuthCredentialResponses.newBuilder(); + + for (Map.Entry entry : values.entrySet()) { + builder.addCredentialResponse(TemporalAuthCredentialResponse.newBuilder() + .setDate(entry.getKey()) + .setAuthCredentialResponse(ByteString.copyFrom(entry.getValue().serialize()))); + } + + store.beginWrite() + .putBlob(KEY, builder.build().toByteArray()) + .commit(); + + Log.i(TAG, String.format(Locale.US, "Written %d credentials to local storage", values.size())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java new file mode 100644 index 00000000..71753244 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.keyvalue; + +import org.thoughtcrime.securesms.util.FeatureFlags; + +public final class InternalValues extends SignalStoreValues { + + public static final String GV2_DO_NOT_CREATE_GV2 = "internal.gv2.do_not_create_gv2"; + public static final String GV2_FORCE_INVITES = "internal.gv2.force_invites"; + public static final String GV2_IGNORE_SERVER_CHANGES = "internal.gv2.ignore_server_changes"; + public static final String GV2_IGNORE_P2P_CHANGES = "internal.gv2.ignore_p2p_changes"; + public static final String GV2_DISABLE_AUTOMIGRATE_INITIATION = "internal.gv2.disable_automigrate_initiation"; + public static final String GV2_DISABLE_AUTOMIGRATE_NOTIFICATION = "internal.gv2.disable_automigrate_notification"; + public static final String RECIPIENT_DETAILS = "internal.recipient_details"; + public static final String FORCE_CENSORSHIP = "internal.force_censorship"; + + InternalValues(KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + /** + * Do not attempt to create GV2 groups, i.e. will force creation of GV1 or MMS groups. + */ + public synchronized boolean gv2DoNotCreateGv2Groups() { + return FeatureFlags.internalUser() && getBoolean(GV2_DO_NOT_CREATE_GV2, false); + } + + /** + * Members will not be added directly to a GV2 even if they could be. + */ + public synchronized boolean gv2ForceInvites() { + return FeatureFlags.internalUser() && getBoolean(GV2_FORCE_INVITES, false); + } + + /** + * The Server will leave out changes that can only be described by a future protocol level that + * an older client cannot understand. Ignoring those changes by nulling them out simulates that + * scenario for testing. + *

+ * In conjunction with {@link #gv2IgnoreP2PChanges()} it means no group changes are coming into + * the client and it will generate changes by group state comparison, and those changes will not + * have an editor and so will be in the passive voice. + */ + public synchronized boolean gv2IgnoreServerChanges() { + return FeatureFlags.internalUser() && getBoolean(GV2_IGNORE_SERVER_CHANGES, false); + } + + /** + * Signed group changes are sent P2P, if the client ignores them, it will then ask the server + * directly which allows testing of certain testing scenarios. + */ + public synchronized boolean gv2IgnoreP2PChanges() { + return FeatureFlags.internalUser() && getBoolean(GV2_IGNORE_P2P_CHANGES, false); + } + + /** + * Show detailed recipient info in the {@link org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientFragment}. + */ + public synchronized boolean recipientDetails() { + return FeatureFlags.internalUser() && getBoolean(RECIPIENT_DETAILS, false); + } + + /** + * Force the app to behave as if it is in a country where Signal is censored. + */ + public synchronized boolean forcedCensorship() { + return FeatureFlags.internalUser() && getBoolean(FORCE_CENSORSHIP, false); + } + + /** + * Disable initiating a GV1->GV2 auto-migration. You can still recognize a group has been + * auto-migrated. + */ + public synchronized boolean disableGv1AutoMigrateInitiation() { + return FeatureFlags.internalUser() && getBoolean(GV2_DISABLE_AUTOMIGRATE_INITIATION, false); + } + + /** + * Disable sending a group update after an automigration. This will force other group members to + * have to discover the migration on their own. + */ + public synchronized boolean disableGv1AutoMigrateNotification() { + return FeatureFlags.internalUser() && getBoolean(GV2_DISABLE_AUTOMIGRATE_NOTIFICATION, false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java new file mode 100644 index 00000000..8dd450f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java @@ -0,0 +1,179 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.lock.PinHashing; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.whispersystems.signalservice.api.KbsPinData; +import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; + +import java.io.IOException; +import java.security.SecureRandom; + +public final class KbsValues extends SignalStoreValues { + + public static final String V2_LOCK_ENABLED = "kbs.v2_lock_enabled"; + private static final String MASTER_KEY = "kbs.registration_lock_master_key"; + private static final String TOKEN_RESPONSE = "kbs.token_response"; + private static final String PIN = "kbs.pin"; + private static final String LOCK_LOCAL_PIN_HASH = "kbs.registration_lock_local_pin_hash"; + private static final String LAST_CREATE_FAILED_TIMESTAMP = "kbs.last_create_failed_timestamp"; + public static final String OPTED_OUT = "kbs.opted_out"; + + KbsValues(KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + /** + * Deliberately does not clear the {@link #MASTER_KEY}. + * + * Should only be called by {@link org.thoughtcrime.securesms.pin.PinState} + */ + public void clearRegistrationLockAndPin() { + getStore().beginWrite() + .remove(V2_LOCK_ENABLED) + .remove(TOKEN_RESPONSE) + .remove(LOCK_LOCAL_PIN_HASH) + .remove(PIN) + .remove(LAST_CREATE_FAILED_TIMESTAMP) + .remove(OPTED_OUT) + .commit(); + } + + /** Should only be set by {@link org.thoughtcrime.securesms.pin.PinState}. */ + public synchronized void setKbsMasterKey(@NonNull KbsPinData pinData, @NonNull String pin) { + MasterKey masterKey = pinData.getMasterKey(); + String tokenResponse; + try { + tokenResponse = JsonUtils.toJson(pinData.getTokenResponse()); + } catch (IOException e) { + throw new AssertionError(e); + } + + getStore().beginWrite() + .putString(TOKEN_RESPONSE, tokenResponse) + .putBlob(MASTER_KEY, masterKey.serialize()) + .putString(LOCK_LOCAL_PIN_HASH, PinHashing.localPinHash(pin)) + .putString(PIN, pin) + .putLong(LAST_CREATE_FAILED_TIMESTAMP, -1) + .putBoolean(OPTED_OUT, false) + .commit(); + } + + synchronized void setPinIfNotPresent(@NonNull String pin) { + if (getStore().getString(PIN, null) == null) { + getStore().beginWrite().putString(PIN, pin).commit(); + } + } + + /** Should only be set by {@link org.thoughtcrime.securesms.pin.PinState}. */ + public synchronized void setV2RegistrationLockEnabled(boolean enabled) { + putBoolean(V2_LOCK_ENABLED, enabled); + } + + /** + * Whether or not registration lock V2 is enabled. + */ + public synchronized boolean isV2RegistrationLockEnabled() { + return getBoolean(V2_LOCK_ENABLED, false); + } + + /** Should only be set by {@link org.thoughtcrime.securesms.pin.PinState}. */ + public synchronized void onPinCreateFailure() { + putLong(LAST_CREATE_FAILED_TIMESTAMP, System.currentTimeMillis()); + } + + /** + * Whether or not the last time the user attempted to create a PIN, it failed. + */ + public synchronized boolean lastPinCreateFailed() { + return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0; + } + + /** + * Finds or creates the master key. Therefore this will always return a master key whether backed + * up or not. + *

+ * If you only want a key when it's backed up, use {@link #getPinBackedMasterKey()}. + */ + public synchronized @NonNull MasterKey getOrCreateMasterKey() { + byte[] blob = getStore().getBlob(MASTER_KEY, null); + + if (blob == null) { + getStore().beginWrite() + .putBlob(MASTER_KEY, MasterKey.createNew(new SecureRandom()).serialize()) + .commit(); + blob = getBlob(MASTER_KEY, null); + } + + return new MasterKey(blob); + } + + /** + * Returns null if master key is not backed up by a pin. + */ + public synchronized @Nullable MasterKey getPinBackedMasterKey() { + if (!isV2RegistrationLockEnabled()) return null; + return getMasterKey(); + } + + private synchronized @Nullable MasterKey getMasterKey() { + byte[] blob = getBlob(MASTER_KEY, null); + return blob != null ? new MasterKey(blob) : null; + } + + public @Nullable String getRegistrationLockToken() { + MasterKey masterKey = getPinBackedMasterKey(); + if (masterKey == null) { + return null; + } else { + return masterKey.deriveRegistrationLock(); + } + } + + public synchronized @Nullable String getPin() { + return getString(PIN, null); + } + + public synchronized @Nullable String getLocalPinHash() { + return getString(LOCK_LOCAL_PIN_HASH, null); + } + + public synchronized boolean hasPin() { + return getLocalPinHash() != null; + } + + /** Should only be called by {@link org.thoughtcrime.securesms.pin.PinState}. */ + public synchronized void optOut() { + getStore().beginWrite() + .putBoolean(OPTED_OUT, true) + .remove(TOKEN_RESPONSE) + .putBlob(MASTER_KEY, MasterKey.createNew(new SecureRandom()).serialize()) + .remove(LOCK_LOCAL_PIN_HASH) + .remove(PIN) + .putLong(LAST_CREATE_FAILED_TIMESTAMP, -1) + .commit(); + } + + public synchronized boolean hasOptedOut() { + return getBoolean(OPTED_OUT, false); + } + + public synchronized @Nullable TokenResponse getRegistrationLockTokenResponse() { + String token = getStore().getString(TOKEN_RESPONSE, null); + + if (token == null) return null; + + try { + return JsonUtils.fromJson(token, TokenResponse.class); + } catch (IOException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java new file mode 100644 index 00000000..6577c5da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +import java.util.concurrent.TimeUnit; + +public enum KeepMessagesDuration { + FOREVER(0, R.string.preferences_storage__forever, Long.MAX_VALUE), + ONE_YEAR(1, R.string.preferences_storage__one_year, TimeUnit.DAYS.toMillis(365)), + SIX_MONTHS(2, R.string.preferences_storage__six_months, TimeUnit.DAYS.toMillis(183)), + THIRTY_DAYS(3, R.string.preferences_storage__thirty_days, TimeUnit.DAYS.toMillis(30)); + + private final int id; + private final int stringResource; + private final long duration; + + KeepMessagesDuration(int id, @StringRes int stringResource, long duration) { + this.id = id; + this.stringResource = stringResource; + this.duration = duration; + } + + public int getId() { + return id; + } + + public @StringRes int getStringResource() { + return stringResource; + } + + public long getDuration() { + return duration; + } + + static @NonNull KeepMessagesDuration fromId(int id) { + return values()[id]; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java new file mode 100644 index 00000000..0f61048c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueDataSet.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +public class KeyValueDataSet implements KeyValueReader { + private final Map values = new HashMap<>(); + private final Map types = new HashMap<>(); + + public void putBlob(@NonNull String key, byte[] value) { + values.put(key, value); + types.put(key, byte[].class); + } + + public void putBoolean(@NonNull String key, boolean value) { + values.put(key, value); + types.put(key, Boolean.class); + } + + public void putFloat(@NonNull String key, float value) { + values.put(key, value); + types.put(key, Float.class); + } + + public void putInteger(@NonNull String key, int value) { + values.put(key, value); + types.put(key, Integer.class); + } + + public void putLong(@NonNull String key, long value) { + values.put(key, value); + types.put(key, Long.class); + } + + public void putString(@NonNull String key, String value) { + values.put(key, value); + types.put(key, String.class); + } + + void putAll(@NonNull KeyValueDataSet other) { + values.putAll(other.values); + types.putAll(other.types); + } + + void removeAll(@NonNull Collection removes) { + for (String remove : removes) { + values.remove(remove); + types.remove(remove); + } + } + + @Override + public byte[] getBlob(@NonNull String key, byte[] defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, byte[].class, true); + } else { + return defaultValue; + } + } + + @Override + public boolean getBoolean(@NonNull String key, boolean defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Boolean.class, false); + } else { + return defaultValue; + } + } + + @Override + public float getFloat(@NonNull String key, float defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Float.class, false); + } else { + return defaultValue; + } + } + + @Override + public int getInteger(@NonNull String key, int defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Integer.class, false); + } else { + return defaultValue; + } + } + + @Override + public long getLong(@NonNull String key, long defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, Long.class, false); + } else { + return defaultValue; + } + } + + @Override + public String getString(@NonNull String key, String defaultValue) { + if (containsKey(key)) { + return readValueAsType(key, String.class, true); + } else { + return defaultValue; + } + } + + boolean containsKey(@NonNull String key) { + return values.containsKey(key); + } + + public @NonNull Map getValues() { + return values; + } + + public Class getType(@NonNull String key) { + return types.get(key); + } + + private E readValueAsType(@NonNull String key, Class type, boolean nullable) { + Object value = values.get(key); + if ((value == null && nullable) || (value != null && value.getClass() == type)) { + return type.cast(value); + } else { + throw new IllegalArgumentException("Type mismatch!"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java new file mode 100644 index 00000000..95a2c239 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueReader.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +interface KeyValueReader { + byte[] getBlob(@NonNull String key, byte[] defaultValue); + boolean getBoolean(@NonNull String key, boolean defaultValue); + float getFloat(@NonNull String key, float defaultValue); + int getInteger(@NonNull String key, int defaultValue); + long getLong(@NonNull String key, long defaultValue); + String getString(@NonNull String key, String defaultValue); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java new file mode 100644 index 00000000..953e500e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeyValueStore.java @@ -0,0 +1,200 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; + +/** + * An replacement for {@link android.content.SharedPreferences} that stores key-value pairs in our + * encrypted database. + * + * Implemented as a write-through cache that is safe to read and write to on the main thread. + * + * Writes are enqueued on a separate executor, but writes are finished up in + * {@link SignalUncaughtExceptionHandler}, meaning all write should finish barring a native crash + * or the system killing us unexpectedly (i.e. a force-stop). + */ +public final class KeyValueStore implements KeyValueReader { + + private static final String TAG = Log.tag(KeyValueStore.class); + + private final ExecutorService executor; + private final KeyValueDatabase database; + + private KeyValueDataSet dataSet; + + public KeyValueStore(@NonNull Application application) { + this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-KeyValueStore"); + this.database = KeyValueDatabase.getInstance(application); + } + + @AnyThread + @Override + public synchronized byte[] getBlob(@NonNull String key, byte[] defaultValue) { + initializeIfNecessary(); + return dataSet.getBlob(key, defaultValue); + } + + @AnyThread + @Override + public synchronized boolean getBoolean(@NonNull String key, boolean defaultValue) { + initializeIfNecessary(); + return dataSet.getBoolean(key, defaultValue); + } + + @AnyThread + @Override + public synchronized float getFloat(@NonNull String key, float defaultValue) { + initializeIfNecessary(); + return dataSet.getFloat(key, defaultValue); + } + + @AnyThread + @Override + public synchronized int getInteger(@NonNull String key, int defaultValue) { + initializeIfNecessary(); + return dataSet.getInteger(key, defaultValue); + } + + @AnyThread + @Override + public synchronized long getLong(@NonNull String key, long defaultValue) { + initializeIfNecessary(); + return dataSet.getLong(key, defaultValue); + } + + @AnyThread + @Override + public synchronized String getString(@NonNull String key, String defaultValue) { + initializeIfNecessary(); + return dataSet.getString(key, defaultValue); + } + + /** + * @return A writer that allows writing and removing multiple entries in a single atomic + * transaction. + */ + @AnyThread + @NonNull Writer beginWrite() { + return new Writer(); + } + + /** + * @return A reader that lets you read from an immutable snapshot of the store, ensuring that data + * is consistent between reads. If you're only reading a single value, it is more + * efficient to use the various get* methods instead. + */ + @AnyThread + synchronized @NonNull KeyValueReader beginRead() { + initializeIfNecessary(); + + KeyValueDataSet copy = new KeyValueDataSet(); + copy.putAll(dataSet); + + return copy; + } + + /** + * Ensures that any pending writes (such as those made via {@link Writer#apply()}) are finished. + */ + @AnyThread + synchronized void blockUntilAllWritesFinished() { + CountDownLatch latch = new CountDownLatch(1); + + executor.execute(latch::countDown); + + try { + latch.await(); + } catch (InterruptedException e) { + Log.w(TAG, "Failed to wait for all writes."); + } + } + + + private synchronized void write(@NonNull KeyValueDataSet newDataSet, @NonNull Collection removes) { + initializeIfNecessary(); + + dataSet.putAll(newDataSet); + dataSet.removeAll(removes); + + executor.execute(() -> database.writeDataSet(newDataSet, removes)); + } + + private void initializeIfNecessary() { + if (dataSet != null) return; + this.dataSet = database.getDataSet(); + } + + class Writer { + private final KeyValueDataSet dataSet = new KeyValueDataSet(); + private final Set removes = new HashSet<>(); + + @NonNull Writer putBlob(@NonNull String key, @Nullable byte[] value) { + dataSet.putBlob(key, value); + return this; + } + + @NonNull Writer putBoolean(@NonNull String key, boolean value) { + dataSet.putBoolean(key, value); + return this; + } + + @NonNull Writer putFloat(@NonNull String key, float value) { + dataSet.putFloat(key, value); + return this; + } + + @NonNull Writer putInteger(@NonNull String key, int value) { + dataSet.putInteger(key, value); + return this; + } + + @NonNull Writer putLong(@NonNull String key, long value) { + dataSet.putLong(key, value); + return this; + } + + @NonNull Writer putString(@NonNull String key, String value) { + dataSet.putString(key, value); + return this; + } + + @NonNull Writer remove(@NonNull String key) { + removes.add(key); + return this; + } + + @AnyThread + void apply() { + for (String key : removes) { + if (dataSet.containsKey(key)) { + throw new IllegalStateException("Tried to remove a key while also setting it!"); + } + } + + write(dataSet, removes); + } + + @WorkerThread + void commit() { + apply(); + blockUntilAllWritesFinished(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java new file mode 100644 index 00000000..c0bb184f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +public final class MiscellaneousValues extends SignalStoreValues { + + private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time"; + private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"; + private static final String LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time"; + private static final String LAST_GV1_ROUTINE_MIGRATION_TIME = "misc.last_gv1_routine_migration_time"; + private static final String USERNAME_SHOW_REMINDER = "username.show.reminder"; + private static final String CLIENT_DEPRECATED = "misc.client_deprecated"; + + MiscellaneousValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + putLong(MESSAGE_REQUEST_ENABLE_TIME, 0); + } + + public long getLastPrekeyRefreshTime() { + return getLong(LAST_PREKEY_REFRESH_TIME, 0); + } + + public void setLastPrekeyRefreshTime(long time) { + putLong(LAST_PREKEY_REFRESH_TIME, time); + } + + public long getMessageRequestEnableTime() { + return getLong(MESSAGE_REQUEST_ENABLE_TIME, 0); + } + + public long getLastProfileRefreshTime() { + return getLong(LAST_PROFILE_REFRESH_TIME, 0); + } + + public void setLastProfileRefreshTime(long time) { + putLong(LAST_PROFILE_REFRESH_TIME, time); + } + + public long getLastGv1RoutineMigrationTime() { + return getLong(LAST_GV1_ROUTINE_MIGRATION_TIME, 0); + } + + public void setLastGv1RoutineMigrationTime(long time) { + putLong(LAST_GV1_ROUTINE_MIGRATION_TIME, time); + } + + public void hideUsernameReminder() { + putBoolean(USERNAME_SHOW_REMINDER, false); + } + + public boolean shouldShowUsernameReminder() { + return getBoolean(USERNAME_SHOW_REMINDER, true); + } + + public boolean isClientDeprecated() { + return getBoolean(CLIENT_DEPRECATED, false); + } + + public void markClientDeprecated() { + putBoolean(CLIENT_DEPRECATED, true); + } + + public void clearClientDeprecated() { + putBoolean(CLIENT_DEPRECATED, false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java new file mode 100644 index 00000000..3f04276d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.util.Util; + +public final class OnboardingValues extends SignalStoreValues { + + private static final String SHOW_NEW_GROUP = "onboarding.new_group"; + private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends"; + private static final String SHOW_SMS = "onboarding.sms"; + + OnboardingValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + putBoolean(SHOW_NEW_GROUP, true); + putBoolean(SHOW_INVITE_FRIENDS, true); + putBoolean(SHOW_SMS, true); + } + + public void clearAll() { + setShowNewGroup(false); + setShowInviteFriends(false); + setShowSms(false); + } + + public boolean hasOnboarding(@NonNull Context context) { + return shouldShowNewGroup() || + shouldShowInviteFriends() || + shouldShowSms(context); + } + + public void setShowNewGroup(boolean value) { + putBoolean(SHOW_NEW_GROUP, value); + } + + public boolean shouldShowNewGroup() { + return getBoolean(SHOW_NEW_GROUP, false); + } + + public void setShowInviteFriends(boolean value) { + putBoolean(SHOW_INVITE_FRIENDS, value); + } + + public boolean shouldShowInviteFriends() { + return getBoolean(SHOW_INVITE_FRIENDS, false); + } + + public void setShowSms(boolean value) { + putBoolean(SHOW_SMS, value); + } + + public boolean shouldShowSms(@NonNull Context context) { + return getBoolean(SHOW_SMS, false) && !Util.isDefaultSmsProvider(context) && PhoneNumberFormatter.getLocalCountryCode() != 91; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java new file mode 100644 index 00000000..4d013f7f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.FeatureFlags; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +public final class PhoneNumberPrivacyValues extends SignalStoreValues { + + public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode"; + public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode"; + + private static final Collection REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164); + private static final Collection PRIVACY_CERTIFICATE = Collections.singletonList(CertificateType.UUID_ONLY); + private static final Collection BOTH_CERTIFICATES = Collections.unmodifiableCollection(Arrays.asList(CertificateType.UUID_AND_E164, CertificateType.UUID_ONLY)); + + PhoneNumberPrivacyValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + // TODO [ALAN] PhoneNumberPrivacy: During registration, set the attribute to so that new registrations start out as not listed + //getStore().beginWrite() + // .putInteger(LISTING_MODE, PhoneNumberListingMode.UNLISTED.ordinal()) + // .apply(); + } + + public @NonNull PhoneNumberSharingMode getPhoneNumberSharingMode() { + if (!FeatureFlags.phoneNumberPrivacy()) return PhoneNumberSharingMode.EVERYONE; + return PhoneNumberSharingMode.values()[getInteger(SHARING_MODE, PhoneNumberSharingMode.EVERYONE.ordinal())]; + } + + public void setPhoneNumberSharingMode(@NonNull PhoneNumberSharingMode phoneNumberSharingMode) { + putInteger(SHARING_MODE, phoneNumberSharingMode.ordinal()); + } + + public @NonNull PhoneNumberListingMode getPhoneNumberListingMode() { + if (!FeatureFlags.phoneNumberPrivacy()) return PhoneNumberListingMode.LISTED; + return PhoneNumberListingMode.values()[getInteger(LISTING_MODE, PhoneNumberListingMode.LISTED.ordinal())]; + } + + public void setPhoneNumberListingMode(@NonNull PhoneNumberListingMode phoneNumberListingMode) { + putInteger(LISTING_MODE, phoneNumberListingMode.ordinal()); + } + + /** + * If you respect {@link #getPhoneNumberSharingMode}, then you will only ever need to fetch and store + * these certificates types. + */ + public Collection getRequiredCertificateTypes() { + switch (getPhoneNumberSharingMode()) { + case EVERYONE: return REGULAR_CERTIFICATE; + case CONTACTS: return BOTH_CERTIFICATES; + case NOBODY : return PRIVACY_CERTIFICATE; + default : throw new AssertionError(); + } + } + + /** + * All certificate types required according to the feature flags. + */ + public Collection getAllCertificateTypes() { + return FeatureFlags.phoneNumberPrivacy() ? BOTH_CERTIFICATES : REGULAR_CERTIFICATE; + } + + /** + * Serialized, do not change ordinal/order + */ + public enum PhoneNumberSharingMode { + EVERYONE, + CONTACTS, + NOBODY + } + + /** + * Serialized, do not change ordinal/order + */ + public enum PhoneNumberListingMode { + LISTED, + UNLISTED; + + public boolean isDiscoverable() { + return this == LISTED; + } + + public boolean isUnlisted() { + return this == UNLISTED; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PinValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PinValues.java new file mode 100644 index 00000000..042dd42a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PinValues.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.lock.SignalPinReminders; +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Specifically handles just the UI/UX state around PINs. For actual keys, see {@link KbsValues}. + */ +public final class PinValues extends SignalStoreValues { + + private static final String TAG = Log.tag(PinValues.class); + + private static final String LAST_SUCCESSFUL_ENTRY = "pin.last_successful_entry"; + private static final String NEXT_INTERVAL = "pin.interval_index"; + private static final String KEYBOARD_TYPE = "kbs.keyboard_type"; + private static final String PIN_STATE = "pin.pin_state"; + public static final String PIN_REMINDERS_ENABLED = "pin.pin_reminders_enabled"; + + PinValues(KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public void onEntrySuccess(@NonNull String pin) { + long nextInterval = SignalPinReminders.getNextInterval(getCurrentInterval()); + Log.i(TAG, "onEntrySuccess() nextInterval: " + nextInterval); + + getStore().beginWrite() + .putLong(LAST_SUCCESSFUL_ENTRY, System.currentTimeMillis()) + .putLong(NEXT_INTERVAL, nextInterval) + .apply(); + + SignalStore.kbsValues().setPinIfNotPresent(pin); + } + + public void onEntrySuccessWithWrongGuess(@NonNull String pin) { + long nextInterval = SignalPinReminders.getPreviousInterval(getCurrentInterval()); + Log.i(TAG, "onEntrySuccessWithWrongGuess() nextInterval: " + nextInterval); + + getStore().beginWrite() + .putLong(LAST_SUCCESSFUL_ENTRY, System.currentTimeMillis()) + .putLong(NEXT_INTERVAL, nextInterval) + .apply(); + + SignalStore.kbsValues().setPinIfNotPresent(pin); + } + + public void onEntrySkipWithWrongGuess() { + long nextInterval = SignalPinReminders.getPreviousInterval(getCurrentInterval()); + Log.i(TAG, "onEntrySkipWithWrongGuess() nextInterval: " + nextInterval); + + putLong(NEXT_INTERVAL, nextInterval); + } + + public void resetPinReminders() { + long nextInterval = SignalPinReminders.INITIAL_INTERVAL; + Log.i(TAG, "resetPinReminders() nextInterval: " + nextInterval, new Throwable()); + + getStore().beginWrite() + .putLong(NEXT_INTERVAL, nextInterval) + .putLong(LAST_SUCCESSFUL_ENTRY, System.currentTimeMillis()) + .apply(); + } + + public long getCurrentInterval() { + return getLong(NEXT_INTERVAL, TextSecurePreferences.getRegistrationLockNextReminderInterval(ApplicationDependencies.getApplication())); + } + + public long getLastSuccessfulEntryTime() { + return getLong(LAST_SUCCESSFUL_ENTRY, TextSecurePreferences.getRegistrationLockLastReminderTime(ApplicationDependencies.getApplication())); + } + + public void setKeyboardType(@NonNull PinKeyboardType keyboardType) { + putString(KEYBOARD_TYPE, keyboardType.getCode()); + } + + public void setPinRemindersEnabled(boolean enabled) { + putBoolean(PIN_REMINDERS_ENABLED, enabled); + } + + public boolean arePinRemindersEnabled() { + return getBoolean(PIN_REMINDERS_ENABLED, true); + } + + public @NonNull PinKeyboardType getKeyboardType() { + return PinKeyboardType.fromCode(getStore().getString(KEYBOARD_TYPE, null)); + } + + public void setNextReminderIntervalToAtMost(long maxInterval) { + if (getStore().getLong(NEXT_INTERVAL, 0) > maxInterval) { + putLong(NEXT_INTERVAL, maxInterval); + } + } + + /** Should only be set by {@link org.thoughtcrime.securesms.pin.PinState} */ + public void setPinState(@NonNull String pinState) { + getStore().beginWrite().putString(PIN_STATE, pinState).commit(); + } + + public @Nullable String getPinState() { + return getString(PIN_STATE, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ProxyValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ProxyValues.java new file mode 100644 index 00000000..160e31ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ProxyValues.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.internal.configuration.SignalProxy; + +public final class ProxyValues extends SignalStoreValues { + + private static final String KEY_PROXY_ENABLED = "proxy.enabled"; + private static final String KEY_HOST = "proxy.host"; + private static final String KEY_PORT = "proxy.port"; + + ProxyValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public void enableProxy(@NonNull SignalProxy proxy) { + if (Util.isEmpty(proxy.getHost())) { + throw new IllegalArgumentException("Empty host!"); + } + + getStore().beginWrite() + .putBoolean(KEY_PROXY_ENABLED, true) + .putString(KEY_HOST, proxy.getHost()) + .putInteger(KEY_PORT, proxy.getPort()) + .apply(); + } + + /** + * Disables the proxy, but does not clear out the last-chosen host. + */ + public void disableProxy() { + putBoolean(KEY_PROXY_ENABLED, false); + } + + public boolean isProxyEnabled() { + return getBoolean(KEY_PROXY_ENABLED, false); + } + + /** + * Sets the proxy. This does not *enable* the proxy. This is because the user may want to set a + * proxy and then enabled it and disable it at will. + */ + public void setProxy(@Nullable SignalProxy proxy) { + if (proxy != null) { + getStore().beginWrite() + .putString(KEY_HOST, proxy.getHost()) + .putInteger(KEY_PORT, proxy.getPort()) + .apply(); + } else { + getStore().beginWrite() + .remove(KEY_HOST) + .remove(KEY_PORT) + .apply(); + } + } + + public @Nullable SignalProxy getProxy() { + String host = getString(KEY_HOST, null); + int port = getInteger(KEY_PORT, 0); + + if (host != null) { + return new SignalProxy(host, port); + } else { + return null; + } + } + + public @Nullable String getProxyHost() { + SignalProxy proxy = getProxy(); + return proxy != null ? proxy.getHost() : null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java new file mode 100644 index 00000000..60280462 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; + +public final class RegistrationValues extends SignalStoreValues { + + private static final String REGISTRATION_COMPLETE = "registration.complete"; + private static final String PIN_REQUIRED = "registration.pin_required"; + + RegistrationValues(@NonNull KeyValueStore store) { + super(store); + } + + public synchronized void onFirstEverAppLaunch() { + getStore().beginWrite() + .putBoolean(REGISTRATION_COMPLETE, false) + .putBoolean(PIN_REQUIRED, true) + .commit(); + } + + public synchronized void clearRegistrationComplete() { + onFirstEverAppLaunch(); + } + + public synchronized void setRegistrationComplete() { + getStore().beginWrite() + .putBoolean(REGISTRATION_COMPLETE, true) + .commit(); + } + + @CheckResult + public synchronized boolean pinWasRequiredAtRegistration() { + return getStore().getBoolean(PIN_REQUIRED, false); + } + + @CheckResult + public synchronized boolean isRegistrationComplete() { + return getStore().getBoolean(REGISTRATION_COMPLETE, true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java new file mode 100644 index 00000000..a6a8b6a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +public final class RemoteConfigValues extends SignalStoreValues { + + private static final String TAG = Log.tag(RemoteConfigValues.class); + + private static final String CURRENT_CONFIG = "remote_config"; + private static final String PENDING_CONFIG = "pending_remote_config"; + private static final String LAST_FETCH_TIME = "remote_config_last_fetch_time"; + + RemoteConfigValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public String getCurrentConfig() { + return getString(CURRENT_CONFIG, null); + } + + public void setCurrentConfig(String value) { + putString(CURRENT_CONFIG, value); + } + + public String getPendingConfig() { + return getString(PENDING_CONFIG, getCurrentConfig()); + } + + public void setPendingConfig(String value) { + putString(PENDING_CONFIG, value); + } + + public long getLastFetchTime() { + return getLong(LAST_FETCH_TIME, 0); + } + + public void setLastFetchTime(long time) { + putLong(LAST_FETCH_TIME, time); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java new file mode 100644 index 00000000..d3664df3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.webrtc.CallBandwidthMode; + +public final class SettingsValues extends SignalStoreValues { + + public static final String LINK_PREVIEWS = "settings.link_previews"; + public static final String KEEP_MESSAGES_DURATION = "settings.keep_messages_duration"; + + public static final String PREFER_SYSTEM_CONTACT_PHOTOS = "settings.prefer.system.contact.photos"; + + private static final String SIGNAL_BACKUP_DIRECTORY = "settings.signal.backup.directory"; + private static final String SIGNAL_LATEST_BACKUP_DIRECTORY = "settings.signal.backup.directory,latest"; + + private static final String CALL_BANDWIDTH_MODE = "settings.signal.call.bandwidth.mode"; + + public static final String THREAD_TRIM_LENGTH = "pref_trim_length"; + public static final String THREAD_TRIM_ENABLED = "pref_trim_threads"; + + SettingsValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + getStore().beginWrite() + .putBoolean(LINK_PREVIEWS, true) + .apply(); + } + + public boolean isLinkPreviewsEnabled() { + return getBoolean(LINK_PREVIEWS, false); + } + + public void setLinkPreviewsEnabled(boolean enabled) { + putBoolean(LINK_PREVIEWS, enabled); + } + + public @NonNull KeepMessagesDuration getKeepMessagesDuration() { + return KeepMessagesDuration.fromId(getInteger(KEEP_MESSAGES_DURATION, 0)); + } + + public void setKeepMessagesForDuration(@NonNull KeepMessagesDuration duration) { + putInteger(KEEP_MESSAGES_DURATION, duration.getId()); + } + + public boolean isTrimByLengthEnabled() { + return getBoolean(THREAD_TRIM_ENABLED, false); + } + + public void setThreadTrimByLengthEnabled(boolean enabled) { + putBoolean(THREAD_TRIM_ENABLED, enabled); + } + + public int getThreadTrimLength() { + return getInteger(THREAD_TRIM_LENGTH, 500); + } + + public void setThreadTrimLength(int length) { + putInteger(THREAD_TRIM_LENGTH, length); + } + + public void setSignalBackupDirectory(@NonNull Uri uri) { + putString(SIGNAL_BACKUP_DIRECTORY, uri.toString()); + putString(SIGNAL_LATEST_BACKUP_DIRECTORY, uri.toString()); + } + + public void setPreferSystemContactPhotos(boolean preferSystemContactPhotos) { + putBoolean(PREFER_SYSTEM_CONTACT_PHOTOS, preferSystemContactPhotos); + } + + public boolean isPreferSystemContactPhotos() { + return getBoolean(PREFER_SYSTEM_CONTACT_PHOTOS, false); + } + + public @Nullable Uri getSignalBackupDirectory() { + return getUri(SIGNAL_BACKUP_DIRECTORY); + } + + public @Nullable Uri getLatestSignalBackupDirectory() { + return getUri(SIGNAL_LATEST_BACKUP_DIRECTORY); + } + + public void clearSignalBackupDirectory() { + putString(SIGNAL_BACKUP_DIRECTORY, null); + } + + public void setCallBandwidthMode(@NonNull CallBandwidthMode callBandwidthMode) { + putInteger(CALL_BANDWIDTH_MODE, callBandwidthMode.getCode()); + } + + public @NonNull CallBandwidthMode getCallBandwidthMode() { + return CallBandwidthMode.fromCode(getInteger(CALL_BANDWIDTH_MODE, CallBandwidthMode.HIGH_ALWAYS.getCode())); + } + + private @Nullable Uri getUri(@NonNull String key) { + String uri = getString(key, ""); + + if (TextUtils.isEmpty(uri)) { + return null; + } else { + return Uri.parse(uri); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalPreferenceDataStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalPreferenceDataStore.java new file mode 100644 index 00000000..74ef2864 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalPreferenceDataStore.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceDataStore; + +/** + * An implementation of the {@link PreferenceDataStore} interface to let us link preference screens + * to the {@link SignalStore}. + */ +public class SignalPreferenceDataStore extends PreferenceDataStore { + + private final KeyValueStore store; + + SignalPreferenceDataStore(@NonNull KeyValueStore store) { + this.store = store; + } + + @Override + public void putString(String key, @Nullable String value) { + store.beginWrite().putString(key, value).apply(); + } + + @Override + public void putInt(String key, int value) { + store.beginWrite().putInteger(key, value).apply(); + } + + @Override + public void putLong(String key, long value) { + store.beginWrite().putLong(key, value).apply(); + } + + @Override + public void putFloat(String key, float value) { + store.beginWrite().putFloat(key, value).apply(); + } + + @Override + public void putBoolean(String key, boolean value) { + store.beginWrite().putBoolean(key, value).apply(); + } + + @Override + public @Nullable String getString(String key, @Nullable String defValue) { + return store.getString(key, defValue); + } + + @Override + public int getInt(String key, int defValue) { + return store.getInteger(key, defValue); + } + + @Override + public long getLong(String key, long defValue) { + return store.getLong(key, defValue); + } + + @Override + public float getFloat(String key, float defValue) { + return store.getFloat(key, defValue); + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + return store.getBoolean(key, defValue); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java new file mode 100644 index 00000000..aaab35b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceDataStore; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; + +/** + * Simple, encrypted key-value store. + */ +public final class SignalStore { + + private static final SignalStore INSTANCE = new SignalStore(); + + private final KeyValueStore store; + private final KbsValues kbsValues; + private final RegistrationValues registrationValues; + private final PinValues pinValues; + private final RemoteConfigValues remoteConfigValues; + private final StorageServiceValues storageServiceValues; + private final UiHints uiHints; + private final TooltipValues tooltipValues; + private final MiscellaneousValues misc; + private final InternalValues internalValues; + private final EmojiValues emojiValues; + private final SettingsValues settingsValues; + private final CertificateValues certificateValues; + private final PhoneNumberPrivacyValues phoneNumberPrivacyValues; + private final OnboardingValues onboardingValues; + private final WallpaperValues wallpaperValues; + private final ProxyValues proxyValues; + + private SignalStore() { + this.store = new KeyValueStore(ApplicationDependencies.getApplication()); + this.kbsValues = new KbsValues(store); + this.registrationValues = new RegistrationValues(store); + this.pinValues = new PinValues(store); + this.remoteConfigValues = new RemoteConfigValues(store); + this.storageServiceValues = new StorageServiceValues(store); + this.uiHints = new UiHints(store); + this.tooltipValues = new TooltipValues(store); + this.misc = new MiscellaneousValues(store); + this.internalValues = new InternalValues(store); + this.emojiValues = new EmojiValues(store); + this.settingsValues = new SettingsValues(store); + this.certificateValues = new CertificateValues(store); + this.phoneNumberPrivacyValues = new PhoneNumberPrivacyValues(store); + this.onboardingValues = new OnboardingValues(store); + this.wallpaperValues = new WallpaperValues(store); + this.proxyValues = new ProxyValues(store); + } + + public static void onFirstEverAppLaunch() { + kbsValues().onFirstEverAppLaunch(); + registrationValues().onFirstEverAppLaunch(); + pinValues().onFirstEverAppLaunch(); + remoteConfigValues().onFirstEverAppLaunch(); + storageServiceValues().onFirstEverAppLaunch(); + uiHints().onFirstEverAppLaunch(); + tooltips().onFirstEverAppLaunch(); + misc().onFirstEverAppLaunch(); + internalValues().onFirstEverAppLaunch(); + settings().onFirstEverAppLaunch(); + certificateValues().onFirstEverAppLaunch(); + phoneNumberPrivacy().onFirstEverAppLaunch(); + onboarding().onFirstEverAppLaunch(); + wallpaper().onFirstEverAppLaunch(); + proxy().onFirstEverAppLaunch(); + } + + public static @NonNull KbsValues kbsValues() { + return INSTANCE.kbsValues; + } + + public static @NonNull RegistrationValues registrationValues() { + return INSTANCE.registrationValues; + } + + public static @NonNull PinValues pinValues() { + return INSTANCE.pinValues; + } + + public static @NonNull RemoteConfigValues remoteConfigValues() { + return INSTANCE.remoteConfigValues; + } + + public static @NonNull StorageServiceValues storageServiceValues() { + return INSTANCE.storageServiceValues; + } + + public static @NonNull UiHints uiHints() { + return INSTANCE.uiHints; + } + + public static @NonNull TooltipValues tooltips() { + return INSTANCE.tooltipValues; + } + + public static @NonNull MiscellaneousValues misc() { + return INSTANCE.misc; + } + + public static @NonNull InternalValues internalValues() { + return INSTANCE.internalValues; + } + + public static @NonNull EmojiValues emojiValues() { + return INSTANCE.emojiValues; + } + + public static @NonNull SettingsValues settings() { + return INSTANCE.settingsValues; + } + + public static @NonNull CertificateValues certificateValues() { + return INSTANCE.certificateValues; + } + + public static @NonNull PhoneNumberPrivacyValues phoneNumberPrivacy() { + return INSTANCE.phoneNumberPrivacyValues; + } + + public static @NonNull OnboardingValues onboarding() { + return INSTANCE.onboardingValues; + } + + public static @NonNull WallpaperValues wallpaper() { + return INSTANCE.wallpaperValues; + } + + public static @NonNull ProxyValues proxy() { + return INSTANCE.proxyValues; + } + + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { + return new GroupsV2AuthorizationSignalStoreCache(getStore()); + } + + public static @NonNull PreferenceDataStore getPreferenceDataStore() { + return new SignalPreferenceDataStore(getStore()); + } + + /** + * Ensures any pending writes are finished. Only intended to be called by + * {@link SignalUncaughtExceptionHandler}. + */ + public static void blockUntilAllWritesFinished() { + getStore().blockUntilAllWritesFinished(); + } + + private static @NonNull KeyValueStore getStore() { + return INSTANCE.store; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java new file mode 100644 index 00000000..02557064 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +abstract class SignalStoreValues { + + private final KeyValueStore store; + + SignalStoreValues(@NonNull KeyValueStore store) { + this.store = store; + } + + @NonNull KeyValueStore getStore() { + return store; + } + + abstract void onFirstEverAppLaunch(); + + String getString(String key, String defaultValue) { + return store.getString(key, defaultValue); + } + + int getInteger(String key, int defaultValue) { + return store.getInteger(key, defaultValue); + } + + long getLong(String key, long defaultValue) { + return store.getLong(key, defaultValue); + } + + boolean getBoolean(String key, boolean defaultValue) { + return store.getBoolean(key, defaultValue); + } + + float getFloat(String key, float defaultValue) { + return store.getFloat(key, defaultValue); + } + + byte[] getBlob(String key, byte[] defaultValue) { + return store.getBlob(key, defaultValue); + } + + void putBlob(@NonNull String key, byte[] value) { + store.beginWrite().putBlob(key, value).apply(); + } + + void putBoolean(@NonNull String key, boolean value) { + store.beginWrite().putBoolean(key, value).apply(); + } + + void putFloat(@NonNull String key, float value) { + store.beginWrite().putFloat(key, value).apply(); + } + + void putInteger(@NonNull String key, int value) { + store.beginWrite().putInteger(key, value).apply(); + } + + void putLong(@NonNull String key, long value) { + store.beginWrite().putLong(key, value).apply(); + } + + void putString(@NonNull String key, String value) { + store.beginWrite().putString(key, value).apply(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java new file mode 100644 index 00000000..b67937d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.whispersystems.signalservice.api.storage.StorageKey; + +public class StorageServiceValues extends SignalStoreValues { + + private static final String LAST_SYNC_TIME = "storage.last_sync_time"; + private static final String NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore"; + + StorageServiceValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public synchronized StorageKey getOrCreateStorageKey() { + return SignalStore.kbsValues().getOrCreateMasterKey().deriveStorageServiceKey(); + } + + public long getLastSyncTime() { + return getLong(LAST_SYNC_TIME, 0); + } + + public void onSyncCompleted() { + putLong(LAST_SYNC_TIME, System.currentTimeMillis()); + } + + public boolean needsAccountRestore() { + return getBoolean(NEEDS_ACCOUNT_RESTORE, false); + } + + public void setNeedsAccountRestore(boolean value) { + putBoolean(NEEDS_ACCOUNT_RESTORE, value); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java new file mode 100644 index 00000000..33a2fc6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +public class TooltipValues extends SignalStoreValues { + + private static final int GROUP_CALLING_MAX_TOOLTIP_DISPLAY_COUNT = 3; + + private static final String BLUR_HUD_ICON = "tooltip.blur_hud_icon"; + private static final String GROUP_CALL_SPEAKER_VIEW = "tooltip.group_call_speaker_view"; + private static final String GROUP_CALL_TOOLTIP_DISPLAY_COUNT = "tooltip.group_call_tooltip_display_count"; + + + TooltipValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + public void onFirstEverAppLaunch() { + } + + public boolean hasSeenBlurHudIconTooltip() { + return getBoolean(BLUR_HUD_ICON, false); + } + + public void markBlurHudIconTooltipSeen() { + putBoolean(BLUR_HUD_ICON, true); + } + + public boolean hasSeenGroupCallSpeakerView() { + return getBoolean(GROUP_CALL_SPEAKER_VIEW, false); + } + + public void markGroupCallSpeakerViewSeen() { + putBoolean(GROUP_CALL_SPEAKER_VIEW, true); + } + + public boolean shouldShowGroupCallingTooltip() { + return getInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, 0) < GROUP_CALLING_MAX_TOOLTIP_DISPLAY_COUNT; + } + + public void markGroupCallingTooltipSeen() { + putInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, getInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, 0) + 1); + } + + public void markGroupCallingLobbyEntered() { + putInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, Integer.MAX_VALUE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java new file mode 100644 index 00000000..f4769345 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +public class UiHints extends SignalStoreValues { + + private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast"; + private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once"; + + UiHints(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + markHasSeenGroupSettingsMenuToast(); + } + + public void markHasSeenGroupSettingsMenuToast() { + putBoolean(HAS_SEEN_GROUP_SETTINGS_MENU_TOAST, true); + } + + public boolean hasSeenGroupSettingsMenuToast() { + return getBoolean(HAS_SEEN_GROUP_SETTINGS_MENU_TOAST, false); + } + + public void markHasConfirmedDeleteForEveryoneOnce() { + putBoolean(HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE, true); + } + + public boolean hasConfirmedDeleteForEveryoneOnce() { + return getBoolean(HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE, false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java new file mode 100644 index 00000000..20764f7f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; + +public final class WallpaperValues extends SignalStoreValues { + + private static final String TAG = Log.tag(WallpaperValues.class); + + private static final String KEY_WALLPAPER = "wallpaper.wallpaper"; + + WallpaperValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public void setWallpaper(@NonNull Context context, @Nullable ChatWallpaper wallpaper) { + Wallpaper currentWallpaper = getCurrentWallpaper(); + Uri currentUri = null; + + if (currentWallpaper != null && currentWallpaper.hasFile()) { + currentUri = Uri.parse(currentWallpaper.getFile().getUri()); + } + + if (wallpaper != null) { + putBlob(KEY_WALLPAPER, wallpaper.serialize().toByteArray()); + } else { + getStore().beginWrite().remove(KEY_WALLPAPER).apply(); + } + + if (currentUri != null) { + WallpaperStorage.onWallpaperDeselected(context, currentUri); + } + } + + public @Nullable ChatWallpaper getWallpaper() { + Wallpaper currentWallpaper = getCurrentWallpaper(); + + if (currentWallpaper != null) { + return ChatWallpaperFactory.create(currentWallpaper); + } else { + return null; + } + } + + public boolean hasWallpaperSet() { + return getStore().getBlob(KEY_WALLPAPER, null) != null; + } + + public void setDimInDarkTheme(boolean enabled) { + Wallpaper currentWallpaper = getCurrentWallpaper(); + + if (currentWallpaper != null) { + putBlob(KEY_WALLPAPER, + currentWallpaper.toBuilder() + .setDimLevelInDarkTheme(enabled ? 0.2f : 0) + .build() + .toByteArray()); + } else { + throw new IllegalStateException("No wallpaper currently set!"); + } + } + + + /** + * Retrieves the URI of the current wallpaper. Note that this will only return a value if the + * wallpaper is both set *and* it's an image. + */ + public @Nullable Uri getWallpaperUri() { + Wallpaper currentWallpaper = getCurrentWallpaper(); + + if (currentWallpaper != null && currentWallpaper.hasFile()) { + return Uri.parse(currentWallpaper.getFile().getUri()); + } else { + return null; + } + } + + private @Nullable Wallpaper getCurrentWallpaper() { + byte[] serialized = getBlob(KEY_WALLPAPER, null); + + if (serialized != null) { + try { + return Wallpaper.parseFrom(serialized); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Invalid proto stored for wallpaper!"); + return null; + } + } else { + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/Link.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/Link.java new file mode 100644 index 00000000..d58b3704 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/Link.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.linkpreview; + +public class Link { + + private final String url; + private final int position; + + public Link(String url, int position) { + this.url = url; + this.position = position; + } + + public String getUrl() { + return url; + } + + public int getPosition() { + return position; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java new file mode 100644 index 00000000..731f0048 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.linkpreview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; + +public class LinkPreview { + + @JsonProperty + private final String url; + + @JsonProperty + private final String title; + + @JsonProperty + private final String description; + + @JsonProperty + private final long date; + + @JsonProperty + private final AttachmentId attachmentId; + + @JsonIgnore + private final Optional thumbnail; + + public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, long date, @NonNull DatabaseAttachment thumbnail) { + this.url = url; + this.title = title; + this.description = description; + this.date = date; + this.thumbnail = Optional.of(thumbnail); + this.attachmentId = thumbnail.getAttachmentId(); + } + + public LinkPreview(@NonNull String url, @NonNull String title, @NonNull String description, long date, @NonNull Optional thumbnail) { + this.url = url; + this.title = title; + this.description = description; + this.date = date; + this.thumbnail = thumbnail; + this.attachmentId = null; + } + + public LinkPreview(@JsonProperty("url") @NonNull String url, + @JsonProperty("title") @NonNull String title, + @JsonProperty("description") @Nullable String description, + @JsonProperty("date") long date, + @JsonProperty("attachmentId") @Nullable AttachmentId attachmentId) + { + this.url = url; + this.title = title; + this.description = Optional.fromNullable(description).or(""); + this.date = date; + this.attachmentId = attachmentId; + this.thumbnail = Optional.absent(); + } + + public @NonNull String getUrl() { + return url; + } + + public @NonNull String getTitle() { + return title; + } + + public @NonNull String getDescription() { + if (description.equals(title)) { + return ""; + } + return description; + } + + public long getDate() { + return date; + } + + public @NonNull Optional getThumbnail() { + return thumbnail; + } + + public @Nullable AttachmentId getAttachmentId() { + return attachmentId; + } + + public @NonNull String serialize() throws IOException { + return JsonUtils.toJson(this); + } + + public static @NonNull LinkPreview deserialize(@NonNull String serialized) throws IOException { + return JsonUtils.fromJson(serialized, LinkPreview.class); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java new file mode 100644 index 00000000..cbccc6e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -0,0 +1,403 @@ +package org.thoughtcrime.securesms.linkpreview; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.OpenGraph; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.net.CallRequestController; +import org.thoughtcrime.securesms.net.CompositeRequestController; +import org.thoughtcrime.securesms.net.RequestController; +import org.thoughtcrime.securesms.net.UserAgentInterceptor; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.stickers.StickerRemoteUri; +import org.thoughtcrime.securesms.stickers.StickerUrl; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.ByteUnit; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.OkHttpUtil; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.ExecutionException; + +import okhttp3.CacheControl; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class LinkPreviewRepository { + + private static final String TAG = LinkPreviewRepository.class.getSimpleName(); + + private static final CacheControl NO_CACHE = new CacheControl.Builder().noCache().build(); + + private static final long FAILSAFE_MAX_TEXT_SIZE = ByteUnit.MEGABYTES.toBytes(2); + private static final long FAILSAFE_MAX_IMAGE_SIZE = ByteUnit.MEGABYTES.toBytes(2); + + private final OkHttpClient client; + + public LinkPreviewRepository() { + this.client = new OkHttpClient.Builder() + .cache(null) + .addInterceptor(new UserAgentInterceptor("WhatsApp/2")) + .build(); + } + + @Nullable RequestController getLinkPreview(@NonNull Context context, + @NonNull String url, + @NonNull Callback callback) + { + if (!SignalStore.settings().isLinkPreviewsEnabled()) { + throw new IllegalStateException(); + } + + CompositeRequestController compositeController = new CompositeRequestController(); + + if (!LinkPreviewUtil.isValidPreviewUrl(url)) { + Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain."); + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + return compositeController; + } + + RequestController metadataController; + + if (StickerUrl.isValidShareLink(url)) { + metadataController = fetchStickerPackLinkPreview(context, url, callback); + } else if (GroupInviteLinkUrl.isGroupLink(url)) { + metadataController = fetchGroupLinkPreview(context, url, callback); + } else { + metadataController = fetchMetadata(url, metadata -> { + if (metadata.isEmpty()) { + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + return; + } + + if (!metadata.getImageUrl().isPresent()) { + callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), metadata.getDate(), Optional.absent())); + return; + } + + RequestController imageController = fetchThumbnail(metadata.getImageUrl().get(), attachment -> { + if (!metadata.getTitle().isPresent() && !attachment.isPresent()) { + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + } else { + callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), metadata.getDescription().or(""), metadata.getDate(), attachment)); + } + }); + + compositeController.addController(imageController); + }); + } + + compositeController.addController(metadataController); + return compositeController; + } + + private @NonNull RequestController fetchMetadata(@NonNull String url, Consumer callback) { + Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build()); + + call.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + Log.w(TAG, "Request failed.", e); + callback.accept(Metadata.empty()); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + if (!response.isSuccessful()) { + Log.w(TAG, "Non-successful response. Code: " + response.code()); + callback.accept(Metadata.empty()); + return; + } else if (response.body() == null) { + Log.w(TAG, "No response body."); + callback.accept(Metadata.empty()); + return; + } + + String body = OkHttpUtil.readAsString(response.body(), FAILSAFE_MAX_TEXT_SIZE); + OpenGraph openGraph = LinkPreviewUtil.parseOpenGraphFields(body); + Optional title = openGraph.getTitle(); + Optional description = openGraph.getDescription(); + Optional imageUrl = openGraph.getImageUrl(); + long date = openGraph.getDate(); + + if (imageUrl.isPresent() && !LinkPreviewUtil.isValidPreviewUrl(imageUrl.get())) { + Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping."); + imageUrl = Optional.absent(); + } + + callback.accept(new Metadata(title, description, date, imageUrl)); + } + }); + + return new CallRequestController(call); + } + + private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Consumer> callback) { + Call call = client.newCall(new Request.Builder().url(imageUrl).build()); + CallRequestController controller = new CallRequestController(call); + + SignalExecutors.UNBOUNDED.execute(() -> { + try { + Response response = call.execute(); + if (!response.isSuccessful() || response.body() == null) { + return; + } + + InputStream bodyStream = response.body().byteStream(); + controller.setStream(bodyStream); + + byte[] data = OkHttpUtil.readAsBytes(bodyStream, FAILSAFE_MAX_IMAGE_SIZE); + Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length); + Optional thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaUtil.IMAGE_JPEG); + + if (bitmap != null) bitmap.recycle(); + + callback.accept(thumbnail); + } catch (IOException e) { + Log.w(TAG, "Exception during link preview image retrieval.", e); + controller.cancel(); + callback.accept(Optional.absent()); + } + }); + + return controller; + } + + private static RequestController fetchStickerPackLinkPreview(@NonNull Context context, + @NonNull String packUrl, + @NonNull Callback callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + Pair stickerParams = StickerUrl.parseShareLink(packUrl).or(new Pair<>("", "")); + String packIdString = stickerParams.first(); + String packKeyString = stickerParams.second(); + byte[] packIdBytes = Hex.fromStringCondensed(packIdString); + byte[] packKeyBytes = Hex.fromStringCondensed(packKeyString); + + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + SignalServiceStickerManifest manifest = receiver.retrieveStickerManifest(packIdBytes, packKeyBytes); + + String title = manifest.getTitle().or(manifest.getAuthor()).or(""); + Optional firstSticker = Optional.fromNullable(manifest.getStickers().size() > 0 ? manifest.getStickers().get(0) : null); + Optional cover = manifest.getCover().or(firstSticker); + + if (cover.isPresent()) { + Bitmap bitmap = GlideApp.with(context).asBitmap() + .load(new StickerRemoteUri(packIdString, packKeyString, cover.get().getId())) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerInside() + .submit(512, 512) + .get(); + + Optional thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP); + + callback.onSuccess(new LinkPreview(packUrl, title, "", 0, thumbnail)); + } else { + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + } + } catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) { + Log.w(TAG, "Failed to fetch sticker pack link preview."); + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + } + }); + + return () -> Log.i(TAG, "Cancelled sticker pack link preview fetch -- no effect."); + } + + private static RequestController fetchGroupLinkPreview(@NonNull Context context, + @NonNull String groupUrl, + @NonNull Callback callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUri(groupUrl); + if (groupInviteLinkUrl == null) { + throw new AssertionError(); + } + + GroupMasterKey groupMasterKey = groupInviteLinkUrl.getGroupMasterKey(); + GroupId.V2 groupId = GroupId.v2(groupMasterKey); + Optional group = DatabaseFactory.getGroupDatabase(context) + .getGroup(groupId); + + if (group.isPresent()) { + Log.i(TAG, "Creating preview for locally available group"); + + GroupDatabase.GroupRecord groupRecord = group.get(); + String title = groupRecord.getTitle(); + int memberCount = groupRecord.getMembers().size(); + String description = getMemberCountDescription(context, memberCount); + Optional thumbnail = Optional.absent(); + + if (AvatarHelper.hasAvatar(context, groupRecord.getRecipientId())) { + Recipient recipient = Recipient.resolved(groupRecord.getRecipientId()); + Bitmap bitmap = AvatarUtil.loadIconBitmapSquareNoCache(context, recipient, 512, 512); + + thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP); + } + + callback.onSuccess(new LinkPreview(groupUrl, title, description, 0, thumbnail)); + } else { + Log.i(TAG, "Group is not locally available for preview generation, fetching from server"); + + DecryptedGroupJoinInfo joinInfo = GroupManager.getGroupJoinInfoFromServer(context, groupMasterKey, groupInviteLinkUrl.getPassword()); + String description = getMemberCountDescription(context, joinInfo.getMemberCount()); + Optional thumbnail = Optional.absent(); + byte[] avatarBytes = AvatarGroupsV2DownloadJob.downloadGroupAvatarBytes(context, groupMasterKey, joinInfo.getAvatar()); + + if (avatarBytes != null) { + Bitmap bitmap = BitmapFactory.decodeByteArray(avatarBytes, 0, avatarBytes.length); + + thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP); + + if (bitmap != null) bitmap.recycle(); + } + + callback.onSuccess(new LinkPreview(groupUrl, joinInfo.getTitle(), description, 0, thumbnail)); + } + } catch (ExecutionException | InterruptedException | IOException | VerificationFailedException e) { + Log.w(TAG, "Failed to fetch group link preview.", e); + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { + Log.w(TAG, "Bad group link.", e); + callback.onError(Error.PREVIEW_NOT_AVAILABLE); + } catch (GroupLinkNotActiveException e) { + Log.w(TAG, "Group link not active.", e); + callback.onError(Error.GROUP_LINK_INACTIVE); + } + }); + + return () -> Log.i(TAG, "Cancelled group link preview fetch -- no effect."); + } + + private static @NonNull String getMemberCountDescription(@NonNull Context context, int memberCount) { + return context.getResources() + .getQuantityString(R.plurals.LinkPreviewRepository_d_members, + memberCount, + memberCount); + } + + private static Optional bitmapToAttachment(@Nullable Bitmap bitmap, + @NonNull Bitmap.CompressFormat format, + @NonNull String contentType) + { + if (bitmap == null) { + return Optional.absent(); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + bitmap.compress(format, 80, baos); + + byte[] bytes = baos.toByteArray(); + Uri uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(); + + return Optional.of(new UriAttachment(uri, + contentType, + AttachmentDatabase.TRANSFER_PROGRESS_STARTED, + bytes.length, + bitmap.getWidth(), + bitmap.getHeight(), + null, + null, + false, + false, + false, + null, + null, + null, + null, + null)); + } + + private static class Metadata { + private final Optional title; + private final Optional description; + private final long date; + private final Optional imageUrl; + + Metadata(Optional title, Optional description, long date, Optional imageUrl) { + this.title = title; + this.description = description; + this.date = date; + this.imageUrl = imageUrl; + } + + static Metadata empty() { + return new Metadata(Optional.absent(), Optional.absent(), 0, Optional.absent()); + } + + Optional getTitle() { + return title; + } + + Optional getDescription() { + return description; + } + + long getDate() { + return date; + } + + Optional getImageUrl() { + return imageUrl; + } + + boolean isEmpty() { + return !title.isPresent() && !imageUrl.isPresent(); + } + } + + interface Callback { + void onSuccess(@NonNull LinkPreview linkPreview); + + void onError(@NonNull Error error); + } + + public enum Error { + PREVIEW_NOT_AVAILABLE, + GROUP_LINK_INACTIVE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java new file mode 100644 index 00000000..a9627b2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java @@ -0,0 +1,256 @@ +package org.thoughtcrime.securesms.linkpreview; + +import android.annotation.SuppressLint; +import android.text.Html; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.URLSpan; +import android.text.util.Linkify; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.stickers.StickerUrl; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.util.OptionalUtil; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import okhttp3.HttpUrl; + +public final class LinkPreviewUtil { + + private static final String TAG = Log.tag(LinkPreviewUtil.class); + + private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$"); + private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$"); + private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$"); + private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>"); + private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>"); + private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\""); + private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>"); + private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>"); + private static final Pattern FAVICON_HREF_PATTERN = Pattern.compile("href\\s*=\\s*\"([^\"]*)\""); + + private static final Set INVALID_TOP_LEVEL_DOMAINS = SetUtil.newHashSet("onion", "i2p"); + + /** + * @return All whitelisted URLs in the source text. + */ + public static @NonNull Links findValidPreviewUrls(@NonNull String text) { + SpannableString spannable = new SpannableString(text); + boolean found = Linkify.addLinks(spannable, Linkify.WEB_URLS); + + if (!found) { + return Links.EMPTY; + } + + return new Links(Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class)) + .map(span -> new Link(span.getURL(), spannable.getSpanStart(span))) + .filter(link -> isValidPreviewUrl(link.getUrl())) + .toList()); + } + + /** + * @return True if the host is present in the link whitelist. + */ + public static boolean isValidPreviewUrl(@Nullable String linkUrl) { + if (linkUrl == null) return false; + if (StickerUrl.isValidShareLink(linkUrl)) return true; + + HttpUrl url = HttpUrl.parse(linkUrl); + return url != null && + !TextUtils.isEmpty(url.scheme()) && + "https".equals(url.scheme()) && + isLegalUrl(linkUrl); + } + + public static boolean isLegalUrl(@NonNull String url) { + Matcher matcher = DOMAIN_PATTERN.matcher(url); + + if (matcher.matches()) { + String domain = matcher.group(2); + String cleanedDomain = domain.replaceAll("\\.", ""); + String topLevelDomain = parseTopLevelDomain(domain); + + boolean validCharacters = ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || + ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); + + boolean validTopLevelDomain = !INVALID_TOP_LEVEL_DOMAINS.contains(topLevelDomain); + + return validCharacters && validTopLevelDomain; + } else { + return false; + } + } + + public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) { + return parseOpenGraphFields(html, text -> Html.fromHtml(text).toString()); + } + + @VisibleForTesting + static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html, @NonNull HtmlDecoder htmlDecoder) { + if (html == null) { + return new OpenGraph(Collections.emptyMap(), null, null); + } + + Map openGraphTags = new HashMap<>(); + Matcher openGraphMatcher = OPEN_GRAPH_TAG_PATTERN.matcher(html); + + while (openGraphMatcher.find()) { + String tag = openGraphMatcher.group(); + String property = openGraphMatcher.groupCount() > 0 ? openGraphMatcher.group(1) : null; + + if (property != null) { + Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); + if (contentMatcher.find() && contentMatcher.groupCount() > 0) { + String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); + openGraphTags.put(property.toLowerCase(), content); + } + } + } + + Matcher articleMatcher = ARTICLE_TAG_PATTERN.matcher(html); + + while (articleMatcher.find()) { + String tag = articleMatcher.group(); + String property = articleMatcher.groupCount() > 0 ? articleMatcher.group(1) : null; + + if (property != null) { + Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); + if (contentMatcher.find() && contentMatcher.groupCount() > 0) { + String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); + openGraphTags.put(property.toLowerCase(), content); + } + } + } + + String htmlTitle = ""; + String faviconUrl = ""; + + Matcher titleMatcher = TITLE_PATTERN.matcher(html); + if (titleMatcher.find() && titleMatcher.groupCount() > 0) { + htmlTitle = htmlDecoder.fromEncoded(titleMatcher.group(1)); + } + + Matcher faviconMatcher = FAVICON_PATTERN.matcher(html); + if (faviconMatcher.find()) { + Matcher faviconHrefMatcher = FAVICON_HREF_PATTERN.matcher(faviconMatcher.group()); + if (faviconHrefMatcher.find() && faviconHrefMatcher.groupCount() > 0) { + faviconUrl = faviconHrefMatcher.group(1); + } + } + + return new OpenGraph(openGraphTags, htmlTitle, faviconUrl); + } + + private static @Nullable String parseTopLevelDomain(@NonNull String domain) { + int periodIndex = domain.lastIndexOf("."); + + if (periodIndex >= 0 && periodIndex < domain.length() - 1) { + return domain.substring(periodIndex + 1); + } else { + return null; + } + } + + + public static final class OpenGraph { + + private final Map values; + + private final @Nullable String htmlTitle; + private final @Nullable String faviconUrl; + + private static final String KEY_TITLE = "title"; + private static final String KEY_DESCRIPTION_URL = "description"; + private static final String KEY_IMAGE_URL = "image"; + private static final String KEY_PUBLISHED_TIME_1 = "published_time"; + private static final String KEY_PUBLISHED_TIME_2 = "article:published_time"; + private static final String KEY_MODIFIED_TIME_1 = "modified_time"; + private static final String KEY_MODIFIED_TIME_2 = "article:modified_time"; + + public OpenGraph(@NonNull Map values, @Nullable String htmlTitle, @Nullable String faviconUrl) { + this.values = values; + this.htmlTitle = htmlTitle; + this.faviconUrl = faviconUrl; + } + + public @NonNull Optional getTitle() { + return OptionalUtil.absentIfEmpty(Util.getFirstNonEmpty(values.get(KEY_TITLE), htmlTitle)); + } + + public @NonNull Optional getImageUrl() { + return OptionalUtil.absentIfEmpty(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl)); + } + + @SuppressLint("ObsoleteSdkInt") + public long getDate() { + return Stream.of(values.get(KEY_PUBLISHED_TIME_1), + values.get(KEY_PUBLISHED_TIME_2), + values.get(KEY_MODIFIED_TIME_1), + values.get(KEY_MODIFIED_TIME_2)) + .map(DateUtils::parseIso8601) + .filter(time -> time > 0) + .findFirst() + .orElse(0L); + } + + public @NonNull Optional getDescription() { + return OptionalUtil.absentIfEmpty(values.get(KEY_DESCRIPTION_URL)); + } + } + + public interface HtmlDecoder { + @NonNull String fromEncoded(@NonNull String html); + } + + public static class Links { + static final Links EMPTY = new Links(Collections.emptyList()); + + private final List links; + private final Set urlSet; + + private Links(@NonNull List links) { + this.links = links; + this.urlSet = Stream.of(links) + .map(link -> trimTrailingSlash(link.getUrl())) + .collect(Collectors.toSet()); + } + + public Optional findFirst() { + return links.isEmpty() ? Optional.absent() + : Optional.of(links.get(0)); + } + + /** + * Slightly forgiving comparison where it will ignore trailing '/' on the supplied url. + */ + public boolean containsUrl(@NonNull String url) { + return urlSet.contains(trimTrailingSlash(url)); + } + + private @NonNull String trimTrailingSlash(@NonNull String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) + : url; + } + + public int size() { + return links.size(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java new file mode 100644 index 00000000..d81fea67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.linkpreview; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.net.RequestController; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.List; + + +public class LinkPreviewViewModel extends ViewModel { + + private final LinkPreviewRepository repository; + private final MutableLiveData linkPreviewState; + private final LiveData linkPreviewSafeState; + + private String activeUrl; + private RequestController activeRequest; + private boolean userCanceled; + private Debouncer debouncer; + private boolean enabled; + + private LinkPreviewViewModel(@NonNull LinkPreviewRepository repository) { + this.repository = repository; + this.linkPreviewState = new MutableLiveData<>(); + this.debouncer = new Debouncer(250); + this.enabled = SignalStore.settings().isLinkPreviewsEnabled(); + this.linkPreviewSafeState = Transformations.map(linkPreviewState, state -> enabled ? state : LinkPreviewState.forNoLinks()); + } + + public LiveData getLinkPreviewState() { + return linkPreviewSafeState; + } + + public boolean hasLinkPreview() { + return linkPreviewSafeState.getValue() != null && linkPreviewSafeState.getValue().getLinkPreview().isPresent(); + } + + public boolean hasLinkPreviewUi() { + return linkPreviewSafeState.getValue() != null && linkPreviewSafeState.getValue().hasContent(); + } + + public @NonNull List getActiveLinkPreviews() { + final LinkPreviewState state = linkPreviewSafeState.getValue(); + + if (state == null || !state.getLinkPreview().isPresent()) { + return Collections.emptyList(); + } else { + return Collections.singletonList(state.getLinkPreview().get()); + } + } + + public void onTextChanged(@NonNull Context context, @NonNull String text, int cursorStart, int cursorEnd) { + if (!enabled) return; + + debouncer.publish(() -> { + if (TextUtils.isEmpty(text)) { + userCanceled = false; + } + + if (userCanceled) { + return; + } + + Optional link = LinkPreviewUtil.findValidPreviewUrls(text) + .findFirst(); + + if (link.isPresent() && link.get().getUrl().equals(activeUrl)) { + return; + } + + if (activeRequest != null) { + activeRequest.cancel(); + activeRequest = null; + } + + if (!link.isPresent() || !isCursorPositionValid(text, link.get(), cursorStart, cursorEnd)) { + activeUrl = null; + linkPreviewState.setValue(LinkPreviewState.forNoLinks()); + return; + } + + linkPreviewState.setValue(LinkPreviewState.forLoading()); + + activeUrl = link.get().getUrl(); + activeRequest = repository.getLinkPreview(context, link.get().getUrl(), new LinkPreviewRepository.Callback() { + @Override + public void onSuccess(@NonNull LinkPreview linkPreview) { + Util.runOnMain(() -> { + if (!userCanceled) { + if (activeUrl != null && activeUrl.equals(linkPreview.getUrl())) { + linkPreviewState.setValue(LinkPreviewState.forPreview(linkPreview)); + } else { + linkPreviewState.setValue(LinkPreviewState.forNoLinks()); + } + } + activeRequest = null; + }); + } + + @Override + public void onError(@NonNull LinkPreviewRepository.Error error) { + Util.runOnMain(() -> { + if (!userCanceled) { + linkPreviewState.setValue(LinkPreviewState.forLinksWithNoPreview(error)); + } + activeRequest = null; + }); + } + }); + }); + } + + public void onUserCancel() { + if (activeRequest != null) { + activeRequest.cancel(); + activeRequest = null; + } + + userCanceled = true; + activeUrl = null; + + debouncer.clear(); + linkPreviewState.setValue(LinkPreviewState.forNoLinks()); + } + + public void onTransportChanged(boolean isSms) { + enabled = SignalStore.settings().isLinkPreviewsEnabled() && !isSms; + + if (!enabled) { + onUserCancel(); + } + } + + public void onSend() { + if (activeRequest != null) { + activeRequest.cancel(); + activeRequest = null; + } + + userCanceled = false; + activeUrl = null; + + debouncer.clear(); + linkPreviewState.setValue(LinkPreviewState.forNoLinks()); + } + + public void onEnabled() { + userCanceled = false; + enabled = SignalStore.settings().isLinkPreviewsEnabled(); + } + + @Override + protected void onCleared() { + if (activeRequest != null) { + activeRequest.cancel(); + } + + debouncer.clear(); + } + + private boolean isCursorPositionValid(@NonNull String text, @NonNull Link link, int cursorStart, int cursorEnd) { + if (cursorStart != cursorEnd) { + return true; + } + + if (text.endsWith(link.getUrl()) && cursorStart == link.getPosition() + link.getUrl().length()) { + return true; + } + + return cursorStart < link.getPosition() || cursorStart > link.getPosition() + link.getUrl().length(); + } + + public static class LinkPreviewState { + private final boolean isLoading; + private final boolean hasLinks; + private final Optional linkPreview; + private final LinkPreviewRepository.Error error; + + private LinkPreviewState(boolean isLoading, + boolean hasLinks, + Optional linkPreview, + @Nullable LinkPreviewRepository.Error error) + { + this.isLoading = isLoading; + this.hasLinks = hasLinks; + this.linkPreview = linkPreview; + this.error = error; + } + + private static LinkPreviewState forLoading() { + return new LinkPreviewState(true, false, Optional.absent(), null); + } + + private static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) { + return new LinkPreviewState(false, true, Optional.of(linkPreview), null); + } + + private static LinkPreviewState forLinksWithNoPreview(@NonNull LinkPreviewRepository.Error error) { + return new LinkPreviewState(false, true, Optional.absent(), error); + } + + private static LinkPreviewState forNoLinks() { + return new LinkPreviewState(false, false, Optional.absent(), null); + } + + public boolean isLoading() { + return isLoading; + } + + public boolean hasLinks() { + return hasLinks; + } + + public Optional getLinkPreview() { + return linkPreview; + } + + public @Nullable LinkPreviewRepository.Error getError() { + return error; + } + + boolean hasContent() { + return isLoading || hasLinks; + } + } + + public static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final LinkPreviewRepository repository; + + public Factory(@NonNull LinkPreviewRepository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new LinkPreviewViewModel(repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewsMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewsMegaphoneView.java new file mode 100644 index 00000000..5842323e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewsMegaphoneView.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.linkpreview; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController; + +public class LinkPreviewsMegaphoneView extends FrameLayout { + + private View yesButton; + private View noButton; + + public LinkPreviewsMegaphoneView(Context context) { + super(context); + initialize(context); + } + + public LinkPreviewsMegaphoneView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + private void initialize(@NonNull Context context) { + inflate(context, R.layout.link_previews_megaphone, this); + + this.yesButton = findViewById(R.id.linkpreview_megaphone_ok); + this.noButton = findViewById(R.id.linkpreview_megaphone_disable); + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController listener) { + this.yesButton.setOnClickListener(v -> { + SignalStore.settings().setLinkPreviewsEnabled(true); + listener.onMegaphoneCompleted(megaphone.getEvent()); + }); + + this.noButton.setOnClickListener(v -> { + SignalStore.settings().setLinkPreviewsEnabled(false); + listener.onMegaphoneCompleted(megaphone.getEvent()); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/PinHashing.java b/app/src/main/java/org/thoughtcrime/securesms/lock/PinHashing.java new file mode 100644 index 00000000..ff90e28e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/PinHashing.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.lock; + +import androidx.annotation.NonNull; + +import org.signal.argon2.Argon2; +import org.signal.argon2.Argon2Exception; +import org.signal.argon2.MemoryCost; +import org.signal.argon2.Type; +import org.signal.argon2.UnknownTypeException; +import org.signal.argon2.Version; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.KeyBackupService; +import org.whispersystems.signalservice.api.kbs.HashedPin; +import org.whispersystems.signalservice.internal.registrationpin.PinHasher; + +public final class PinHashing { + + private PinHashing() { + } + + public static HashedPin hashPin(@NonNull String pin, @NonNull KeyBackupService.HashSession hashSession) { + return PinHasher.hashPin(PinHasher.normalize(pin), password -> { + try { + return new Argon2.Builder(Version.V13) + .type(Type.Argon2id) + .memoryCost(MemoryCost.MiB(16)) + .parallelism(1) + .iterations(32) + .hashLength(64) + .build() + .hash(password, hashSession.hashSalt()) + .getHash(); + } catch (Argon2Exception e) { + throw new AssertionError(e); + } + }); + } + + public static String localPinHash(@NonNull String pin) { + byte[] normalized = PinHasher.normalize(pin); + try { + return new Argon2.Builder(Version.V13) + .type(Type.Argon2i) + .memoryCost(MemoryCost.KiB(256)) + .parallelism(1) + .iterations(50) + .hashLength(32) + .build() + .hash(normalized, Util.getSecretBytes(16)) + .getEncoded(); + } catch (Argon2Exception e) { + throw new AssertionError(e); + } + } + + public static boolean verifyLocalPinHash(@NonNull String localPinHash, @NonNull String pin) { + byte[] normalized = PinHasher.normalize(pin); + try { + return Argon2.verify(localPinHash, normalized); + } catch (UnknownTypeException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockReminders.java b/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockReminders.java new file mode 100644 index 00000000..98bbbe04 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/RegistrationLockReminders.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.lock; + + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.NavigableSet; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; + +public class RegistrationLockReminders { + + private static final NavigableSet INTERVALS = new TreeSet() {{ + add(TimeUnit.HOURS.toMillis(6)); + add(TimeUnit.HOURS.toMillis(12)); + add(TimeUnit.DAYS.toMillis(1)); + add(TimeUnit.DAYS.toMillis(3)); + add(TimeUnit.DAYS.toMillis(7)); + }}; + + public static final long INITIAL_INTERVAL = INTERVALS.first(); + + public static boolean needsReminder(@NonNull Context context) { + long lastReminderTime = TextSecurePreferences.getRegistrationLockLastReminderTime(context); + long nextIntervalTime = TextSecurePreferences.getRegistrationLockNextReminderInterval(context); + + return System.currentTimeMillis() > lastReminderTime + nextIntervalTime; + } + + public static void scheduleReminder(@NonNull Context context, boolean success) { + if (success) { + long timeSinceLastReminder = System.currentTimeMillis() - TextSecurePreferences.getRegistrationLockLastReminderTime(context); + Long nextReminderInterval = INTERVALS.higher(timeSinceLastReminder); + + if (nextReminderInterval == null) { + nextReminderInterval = INTERVALS.last(); + } + + TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis()); + TextSecurePreferences.setRegistrationLockNextReminderInterval(context, nextReminderInterval); + } else { + long timeSinceLastReminder = TextSecurePreferences.getRegistrationLockLastReminderTime(context) + TimeUnit.MINUTES.toMillis(5); + TextSecurePreferences.setRegistrationLockLastReminderTime(context, timeSinceLastReminder); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminderDialog.java b/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminderDialog.java new file mode 100644 index 00000000..1b638839 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminderDialog.java @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.lock; + +import android.content.Context; +import android.content.Intent; +import android.text.Editable; +import android.text.InputType; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.autofill.HintConstants; +import androidx.core.app.DialogCompat; +import androidx.core.view.ViewCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.lock.v2.KbsConstants; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; + +public final class SignalPinReminderDialog { + + private static final String TAG = Log.tag(SignalPinReminderDialog.class); + + public static void show(@NonNull Context context, @NonNull Launcher launcher, @NonNull Callback mainCallback) { + if (!SignalStore.kbsValues().hasPin()) { + throw new AssertionError("Must have a PIN!"); + } + + Log.i(TAG, "Showing PIN reminder dialog."); + + AlertDialog dialog = new AlertDialog.Builder(context, ThemeUtil.isDarkTheme(context) ? R.style.Theme_Signal_AlertDialog_Dark_Cornered_ColoredAccent : R.style.Theme_Signal_AlertDialog_Light_Cornered_ColoredAccent) + .setView(R.layout.kbs_pin_reminder_view) + .setCancelable(false) + .setOnCancelListener(d -> RegistrationLockReminders.scheduleReminder(context, false)) + .create(); + + WindowManager windowManager = ServiceUtil.getWindowManager(context); + Display display = windowManager.getDefaultDisplay(); + DisplayMetrics metrics = new DisplayMetrics(); + display.getMetrics(metrics); + + dialog.show(); + dialog.getWindow().setLayout((int)(metrics.widthPixels * .80), ViewGroup.LayoutParams.WRAP_CONTENT); + + EditText pinEditText = (EditText) DialogCompat.requireViewById(dialog, R.id.pin); + TextView pinStatus = (TextView) DialogCompat.requireViewById(dialog, R.id.pin_status); + TextView reminder = (TextView) DialogCompat.requireViewById(dialog, R.id.reminder); + View skip = DialogCompat.requireViewById(dialog, R.id.skip); + View submit = DialogCompat.requireViewById(dialog, R.id.submit); + + SpannableString reminderText = new SpannableString(context.getString(R.string.KbsReminderDialog__to_help_you_memorize_your_pin)); + SpannableString forgotText = new SpannableString(context.getString(R.string.KbsReminderDialog__forgot_pin)); + + ViewUtil.focusAndShowKeyboard(pinEditText); + ViewCompat.setAutofillHints(pinEditText, HintConstants.AUTOFILL_HINT_PASSWORD); + + switch (SignalStore.pinValues().getKeyboardType()) { + case NUMERIC: + pinEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + break; + case ALPHA_NUMERIC: + pinEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + break; + } + + ClickableSpan clickableSpan = new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + dialog.dismiss(); + launcher.launch(CreateKbsPinActivity.getIntentForPinChangeFromForgotPin(context), CreateKbsPinActivity.REQUEST_NEW_PIN); + } + }; + + forgotText.setSpan(clickableSpan, 0, forgotText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + reminder.setText(new SpannableStringBuilder(reminderText).append(" ").append(forgotText)); + reminder.setMovementMethod(LinkMovementMethod.getInstance()); + + PinVerifier.Callback callback = getPinWatcherCallback(context, dialog, pinEditText, pinStatus, mainCallback); + PinVerifier verifier = new V2PinVerifier(); + + skip.setOnClickListener(v -> { + dialog.dismiss(); + mainCallback.onReminderDismissed(callback.hadWrongGuess()); + }); + + submit.setEnabled(false); + submit.setOnClickListener(v -> { + Editable pinEditable = pinEditText.getText(); + + verifier.verifyPin(pinEditable == null ? null : pinEditable.toString(), callback); + }); + + pinEditText.addTextChangedListener(new SimpleTextWatcher() { + + private final String localHash = Objects.requireNonNull(SignalStore.kbsValues().getLocalPinHash()); + + @Override + public void onTextChanged(String text) { + if (text.length() >= KbsConstants.MINIMUM_PIN_LENGTH) { + submit.setEnabled(true); + + if (PinHashing.verifyLocalPinHash(localHash, text)) { + dialog.dismiss(); + mainCallback.onReminderCompleted(text, callback.hadWrongGuess()); + } + } else { + submit.setEnabled(false); + } + } + }); + } + + private static PinVerifier.Callback getPinWatcherCallback(@NonNull Context context, + @NonNull AlertDialog dialog, + @NonNull EditText inputText, + @NonNull TextView statusText, + @NonNull Callback mainCallback) + { + return new PinVerifier.Callback() { + boolean hadWrongGuess = false; + + @Override + public void onPinCorrect(@NonNull String pin) { + Log.i(TAG, "Correct PIN entry."); + dialog.dismiss(); + mainCallback.onReminderCompleted(pin, hadWrongGuess); + } + + @Override + public void onPinWrong() { + Log.i(TAG, "Incorrect PIN entry."); + hadWrongGuess = true; + inputText.getText().clear(); + statusText.setText(context.getString(R.string.KbsReminderDialog__incorrect_pin_try_again)); + } + + @Override + public boolean hadWrongGuess() { + return hadWrongGuess; + } + }; + } + + private static final class V2PinVerifier implements PinVerifier { + + private final String localPinHash; + + V2PinVerifier() { + localPinHash = SignalStore.kbsValues().getLocalPinHash(); + + if (localPinHash == null) throw new AssertionError("No local pin hash set at time of reminder"); + } + + @Override + public void verifyPin(@Nullable String pin, @NonNull Callback callback) { + if (pin == null) return; + if (TextUtils.isEmpty(pin)) return; + + if (pin.length() < KbsConstants.MINIMUM_PIN_LENGTH) return; + + if (PinHashing.verifyLocalPinHash(localPinHash, pin)) { + callback.onPinCorrect(pin); + } else { + callback.onPinWrong(); + } + } + } + + private interface PinVerifier { + + void verifyPin(@Nullable String pin, @NonNull PinVerifier.Callback callback); + + interface Callback { + void onPinCorrect(@NonNull String pin); + void onPinWrong(); + boolean hadWrongGuess(); + } + } + + public interface Launcher { + void launch(@NonNull Intent intent, int requestCode); + } + + public interface Callback { + void onReminderDismissed(boolean includedFailure); + void onReminderCompleted(@NonNull String pin, boolean includedFailure); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminders.java b/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminders.java new file mode 100644 index 00000000..c2f7fa3f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/SignalPinReminders.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.lock; + +import androidx.annotation.StringRes; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; + +import java.util.HashMap; +import java.util.Map; +import java.util.NavigableSet; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; + +/** + * Reminder intervals for Signal PINs. + */ +public class SignalPinReminders { + + private static final String TAG = Log.tag(SignalPinReminders.class); + + private static final long ONE_DAY = TimeUnit.DAYS.toMillis(1); + private static final long THREE_DAYS = TimeUnit.DAYS.toMillis(3); + private static final long ONE_WEEK = TimeUnit.DAYS.toMillis(7); + private static final long TWO_WEEKS = TimeUnit.DAYS.toMillis(14); + private static final long FOUR_WEEKS = TimeUnit.DAYS.toMillis(28); + + private static final NavigableSet INTERVALS = new TreeSet() {{ + add(ONE_DAY); + add(THREE_DAYS); + add(ONE_WEEK); + add(TWO_WEEKS); + add(FOUR_WEEKS); + }}; + + private static final Map STRINGS = new HashMap() {{ + put(ONE_DAY, R.string.SignalPinReminders_well_remind_you_again_tomorrow); + put(THREE_DAYS, R.string.SignalPinReminders_well_remind_you_again_in_a_few_days); + put(ONE_WEEK, R.string.SignalPinReminders_well_remind_you_again_in_a_week); + put(TWO_WEEKS, R.string.SignalPinReminders_well_remind_you_again_in_a_couple_weeks); + put(FOUR_WEEKS, R.string.SignalPinReminders_well_remind_you_again_in_a_month); + }}; + + public static final long INITIAL_INTERVAL = INTERVALS.first(); + + public static long getNextInterval(long currentInterval) { + Long next = INTERVALS.higher(currentInterval); + return next != null ? next : INTERVALS.last(); + } + + public static long getPreviousInterval(long currentInterval) { + Long previous = INTERVALS.lower(currentInterval); + return previous != null ? previous : INTERVALS.first(); + } + + public static @StringRes int getReminderString(long interval) { + Integer stringRes = STRINGS.get(interval); + + if (stringRes != null) { + return stringRes; + } else { + Log.w(TAG, "Couldn't find a string for interval " + interval); + return R.string.SignalPinReminders_well_remind_you_again_later; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinFragment.java new file mode 100644 index 00000000..b9f0f0e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinFragment.java @@ -0,0 +1,223 @@ +package org.thoughtcrime.securesms.lock.v2; + +import android.content.Intent; +import android.os.Bundle; +import android.text.InputType; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import com.airbnb.lottie.LottieAnimationView; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.pin.PinOptOutDialog; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +abstract class BaseKbsPinFragment extends LoggingFragment { + + private TextView title; + private LearnMoreTextView description; + private EditText input; + private TextView label; + private TextView keyboardToggle; + private TextView confirm; + private LottieAnimationView lottieProgress; + private LottieAnimationView lottieEnd; + private ViewModel viewModel; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.base_kbs_pin_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViews(view); + + viewModel = initializeViewModel(); + viewModel.getUserEntry().observe(getViewLifecycleOwner(), kbsPin -> { + boolean isEntryValid = kbsPin.length() >= KbsConstants.MINIMUM_PIN_LENGTH; + + confirm.setEnabled(isEntryValid); + confirm.setAlpha(isEntryValid ? 1f : 0.5f); + }); + + viewModel.getKeyboard().observe(getViewLifecycleOwner(), keyboardType -> { + updateKeyboard(keyboardType); + keyboardToggle.setText(resolveKeyboardToggleText(keyboardType)); + }); + + description.setOnLinkClickListener(v -> { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.BaseKbsPinFragment__learn_more_url)); + }); + + Toolbar toolbar = view.findViewById(R.id.kbs_pin_toolbar); + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(null); + + initializeListeners(); + } + + @Override + public void onResume() { + super.onResume(); + + input.requestFocus(); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + inflater.inflate(R.menu.pin_skip, menu); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + if (RegistrationLockUtil.userHasRegistrationLock(requireContext()) || + SignalStore.kbsValues().hasPin() || + SignalStore.kbsValues().hasOptedOut()) + { + menu.clear(); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_pin_learn_more: + onLearnMore(); + return true; + case R.id.menu_pin_skip: + onPinSkipped(); + return true; + } + + return false; + } + + protected abstract ViewModel initializeViewModel(); + + protected abstract void initializeViewStates(); + + protected TextView getTitle() { + return title; + } + + protected LearnMoreTextView getDescription() { + return description; + } + + protected EditText getInput() { + return input; + } + + protected LottieAnimationView getLottieProgress() { + return lottieProgress; + } + + protected LottieAnimationView getLottieEnd() { + return lottieEnd; + } + + protected TextView getLabel() { + return label; + } + + protected TextView getKeyboardToggle() { + return keyboardToggle; + } + + protected TextView getConfirm() { + return confirm; + } + + protected void closeNavGraphBranch() { + Intent activityIntent = requireActivity().getIntent(); + if (activityIntent != null && activityIntent.hasExtra("next_intent")) { + startActivity(activityIntent.getParcelableExtra("next_intent")); + } + + requireActivity().finish(); + } + + private void initializeViews(@NonNull View view) { + title = view.findViewById(R.id.edit_kbs_pin_title); + description = view.findViewById(R.id.edit_kbs_pin_description); + input = view.findViewById(R.id.edit_kbs_pin_input); + label = view.findViewById(R.id.edit_kbs_pin_input_label); + keyboardToggle = view.findViewById(R.id.edit_kbs_pin_keyboard_toggle); + confirm = view.findViewById(R.id.edit_kbs_pin_confirm); + lottieProgress = view.findViewById(R.id.edit_kbs_pin_lottie_progress); + lottieEnd = view.findViewById(R.id.edit_kbs_pin_lottie_end); + + initializeViewStates(); + } + + private void initializeListeners() { + input.addTextChangedListener(new AfterTextChanged(s -> viewModel.setUserEntry(s.toString()))); + input.setImeOptions(EditorInfo.IME_ACTION_NEXT); + input.setOnEditorActionListener(this::handleEditorAction); + keyboardToggle.setOnClickListener(v -> viewModel.toggleAlphaNumeric()); + confirm.setOnClickListener(v -> viewModel.confirm()); + } + + private boolean handleEditorAction(@NonNull View view, int actionId, @NonNull KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_NEXT && confirm.isEnabled()) { + viewModel.confirm(); + } + + return true; + } + + private void updateKeyboard(@NonNull PinKeyboardType keyboard) { + boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC; + + input.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD + : InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + } + + private @StringRes int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) { + if (keyboard == PinKeyboardType.ALPHA_NUMERIC) { + return R.string.BaseKbsPinFragment__create_numeric_pin; + } else { + return R.string.BaseKbsPinFragment__create_alphanumeric_pin; + } + } + + private void onLearnMore() { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.KbsSplashFragment__learn_more_link)); + } + + private void onPinSkipped() { + PinOptOutDialog.show(requireContext(), () -> { + RegistrationUtil.maybeMarkRegistrationComplete(requireContext()); + closeNavGraphBranch(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinViewModel.java new file mode 100644 index 00000000..4a234792 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseKbsPinViewModel.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.lock.v2; + +import androidx.annotation.MainThread; +import androidx.lifecycle.LiveData; + +interface BaseKbsPinViewModel { + LiveData getUserEntry(); + + LiveData getKeyboard(); + + @MainThread + void setUserEntry(String userEntry); + + @MainThread + void toggleAlphaNumeric(); + + @MainThread + void confirm(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java new file mode 100644 index 00000000..1382f851 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.lock.v2; + +import android.animation.Animator; +import android.app.Activity; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.RawRes; +import androidx.appcompat.app.AlertDialog; +import androidx.autofill.HintConstants; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.ViewModelProviders; + +import com.airbnb.lottie.LottieAnimationView; +import com.airbnb.lottie.LottieDrawable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.animation.AnimationRepeatListener; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.SpanUtil; + +import java.util.Objects; + +public class ConfirmKbsPinFragment extends BaseKbsPinFragment { + + private ConfirmKbsPinViewModel viewModel; + + @Override + protected void initializeViewStates() { + ConfirmKbsPinFragmentArgs args = ConfirmKbsPinFragmentArgs.fromBundle(requireArguments()); + + if (args.getIsPinChange()) { + initializeViewStatesForPinChange(); + } else { + initializeViewStatesForPinCreate(); + } + ViewCompat.setAutofillHints(getInput(), HintConstants.AUTOFILL_HINT_NEW_PASSWORD); + } + + @Override + protected ConfirmKbsPinViewModel initializeViewModel() { + ConfirmKbsPinFragmentArgs args = ConfirmKbsPinFragmentArgs.fromBundle(requireArguments()); + KbsPin userEntry = Objects.requireNonNull(args.getUserEntry()); + PinKeyboardType keyboard = args.getKeyboard(); + ConfirmKbsPinRepository repository = new ConfirmKbsPinRepository(); + ConfirmKbsPinViewModel.Factory factory = new ConfirmKbsPinViewModel.Factory(userEntry, keyboard, repository); + + viewModel = ViewModelProviders.of(this, factory).get(ConfirmKbsPinViewModel.class); + + viewModel.getLabel().observe(getViewLifecycleOwner(), this::updateLabel); + viewModel.getSaveAnimation().observe(getViewLifecycleOwner(), this::updateSaveAnimation); + + return viewModel; + } + + private void initializeViewStatesForPinCreate() { + getTitle().setText(R.string.CreateKbsPinFragment__create_your_pin); + getDescription().setText(R.string.ConfirmKbsPinFragment__confirm_your_pin); + getKeyboardToggle().setVisibility(View.INVISIBLE); + getLabel().setText(""); + getDescription().setLearnMoreVisible(false); + } + + private void initializeViewStatesForPinChange() { + getTitle().setText(R.string.CreateKbsPinFragment__create_a_new_pin); + getDescription().setText(R.string.ConfirmKbsPinFragment__confirm_your_pin); + getDescription().setLearnMoreVisible(false); + getKeyboardToggle().setVisibility(View.INVISIBLE); + getLabel().setText(""); + } + + private void updateLabel(@NonNull ConfirmKbsPinViewModel.Label label) { + switch (label) { + case EMPTY: + getLabel().setText(""); + break; + case CREATING_PIN: + getLabel().setText(R.string.ConfirmKbsPinFragment__creating_pin); + getInput().setEnabled(false); + break; + case RE_ENTER_PIN: + getLabel().setText(R.string.ConfirmKbsPinFragment__re_enter_your_pin); + break; + case PIN_DOES_NOT_MATCH: + getLabel().setText(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.red), + getString(R.string.ConfirmKbsPinFragment__pins_dont_match))); + getInput().getText().clear(); + break; + } + } + + private void updateSaveAnimation(@NonNull ConfirmKbsPinViewModel.SaveAnimation animation) { + updateAnimationAndInputVisibility(animation); + LottieAnimationView lottieProgress = getLottieProgress(); + + switch (animation) { + case NONE: + lottieProgress.cancelAnimation(); + break; + case LOADING: + lottieProgress.setAnimation(R.raw.lottie_kbs_loading); + lottieProgress.setRepeatMode(LottieDrawable.RESTART); + lottieProgress.setRepeatCount(LottieDrawable.INFINITE); + lottieProgress.playAnimation(); + break; + case SUCCESS: + startEndAnimationOnNextProgressRepetition(R.raw.lottie_kbs_success, new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + requireActivity().setResult(Activity.RESULT_OK); + closeNavGraphBranch(); + RegistrationUtil.maybeMarkRegistrationComplete(requireContext()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + }); + break; + case FAILURE: + startEndAnimationOnNextProgressRepetition(R.raw.lottie_kbs_fail, new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + RegistrationUtil.maybeMarkRegistrationComplete(requireContext()); + displayFailedDialog(); + } + }); + break; + } + } + + private void startEndAnimationOnNextProgressRepetition(@RawRes int lottieAnimationId, + @NonNull AnimationCompleteListener listener) + { + LottieAnimationView lottieProgress = getLottieProgress(); + LottieAnimationView lottieEnd = getLottieEnd(); + + lottieEnd.setAnimation(lottieAnimationId); + lottieEnd.removeAllAnimatorListeners(); + lottieEnd.setRepeatCount(0); + lottieEnd.addAnimatorListener(listener); + + if (lottieProgress.isAnimating()) { + lottieProgress.addAnimatorListener(new AnimationRepeatListener(animator -> + hideProgressAndStartEndAnimation(lottieProgress, lottieEnd) + )); + } else { + hideProgressAndStartEndAnimation(lottieProgress, lottieEnd); + } + } + + private void hideProgressAndStartEndAnimation(@NonNull LottieAnimationView lottieProgress, + @NonNull LottieAnimationView lottieEnd) + { + viewModel.onLoadingAnimationComplete(); + lottieProgress.setVisibility(View.GONE); + lottieEnd.setVisibility(View.VISIBLE); + lottieEnd.playAnimation(); + } + + private void updateAnimationAndInputVisibility(ConfirmKbsPinViewModel.SaveAnimation saveAnimation) { + if (saveAnimation == ConfirmKbsPinViewModel.SaveAnimation.NONE) { + getInput().setVisibility(View.VISIBLE); + getLottieProgress().setVisibility(View.GONE); + } else { + getInput().setVisibility(View.GONE); + getLottieProgress().setVisibility(View.VISIBLE); + } + } + + private void displayFailedDialog() { + new AlertDialog.Builder(requireContext()).setTitle(R.string.ConfirmKbsPinFragment__pin_creation_failed) + .setMessage(R.string.ConfirmKbsPinFragment__your_pin_was_not_saved) + .setCancelable(false) + .setPositiveButton(R.string.ok, (d, w) -> { + d.dismiss(); + markMegaphoneSeenIfNecessary(); + requireActivity().setResult(Activity.RESULT_CANCELED); + closeNavGraphBranch(); + }) + .show(); + } + + private void markMegaphoneSeenIfNecessary() { + ApplicationDependencies.getMegaphoneRepository().markSeen(Megaphones.Event.PINS_FOR_ALL); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinRepository.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinRepository.java new file mode 100644 index 00000000..b2d14b6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinRepository.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.lock.v2; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.pin.PinState; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; + +final class ConfirmKbsPinRepository { + + private static final String TAG = Log.tag(ConfirmKbsPinRepository.class); + + void setPin(@NonNull KbsPin kbsPin, @NonNull PinKeyboardType keyboard, @NonNull Consumer resultConsumer) { + + Context context = ApplicationDependencies.getApplication(); + String pinValue = kbsPin.toString(); + + SimpleTask.run(() -> { + try { + Log.i(TAG, "Setting pin on KBS"); + PinState.onPinChangedOrCreated(context, pinValue, keyboard); + Log.i(TAG, "Pin set on KBS"); + + return PinSetResult.SUCCESS; + } catch (IOException | UnauthenticatedResponseException | InvalidKeyException e) { + Log.w(TAG, e); + PinState.onPinCreateFailure(); + return PinSetResult.FAILURE; + } + }, resultConsumer::accept); + } + + enum PinSetResult { + SUCCESS, + FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinViewModel.java new file mode 100644 index 00000000..ea0a9d2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinViewModel.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.lock.v2; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.lock.v2.ConfirmKbsPinRepository.PinSetResult; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; + +final class ConfirmKbsPinViewModel extends ViewModel implements BaseKbsPinViewModel { + + private final ConfirmKbsPinRepository repository; + + private final DefaultValueLiveData userEntry = new DefaultValueLiveData<>(KbsPin.EMPTY); + private final DefaultValueLiveData keyboard = new DefaultValueLiveData<>(PinKeyboardType.NUMERIC); + private final DefaultValueLiveData saveAnimation = new DefaultValueLiveData<>(SaveAnimation.NONE); + private final DefaultValueLiveData

+ * Based on https://github.com/suchoX/PlacePicker + */ +public final class PlacePickerActivity extends AppCompatActivity { + + private static final String TAG = Log.tag(PlacePickerActivity.class); + + // If it cannot load location for any reason, it defaults to the prime meridian. + private static final LatLng PRIME_MERIDIAN = new LatLng(51.4779, -0.0015); + private static final String ADDRESS_INTENT = "ADDRESS"; + private static final float ZOOM = 17.0f; + + private static final int ANIMATION_DURATION = 250; + private static final OvershootInterpolator OVERSHOOT_INTERPOLATOR = new OvershootInterpolator(); + + private SingleAddressBottomSheet bottomSheet; + private Address currentAddress; + private LatLng initialLocation; + private LatLng currentLocation = new LatLng(0, 0); + private AddressLookup addressLookup; + private GoogleMap googleMap; + + public static void startActivityForResultAtCurrentLocation(@NonNull Activity activity, int requestCode) { + activity.startActivityForResult(new Intent(activity, PlacePickerActivity.class), requestCode); + } + + public static AddressData addressFromData(@NonNull Intent data) { + return data.getParcelableExtra(ADDRESS_INTENT); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_place_picker); + + bottomSheet = findViewById(R.id.bottom_sheet); + View markerImage = findViewById(R.id.marker_image_view); + View fab = findViewById(R.id.place_chosen_button); + + fab.setOnClickListener(v -> finishWithAddress()); + + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) + { + new LocationRetriever(this, this, location -> { + setInitialLocation(new LatLng(location.getLatitude(), location.getLongitude())); + }, () -> { + Log.w(TAG, "Failed to get location."); + setInitialLocation(PRIME_MERIDIAN); + }); + } else { + Log.w(TAG, "No location permissions"); + setInitialLocation(PRIME_MERIDIAN); + } + + SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map); + if (mapFragment == null) throw new AssertionError("No map fragment"); + + mapFragment.getMapAsync(googleMap -> { + + setMap(googleMap); + + enableMyLocationButtonIfHaveThePermission(googleMap); + + googleMap.setOnCameraMoveStartedListener(i -> { + markerImage.animate() + .translationY(-75f) + .setInterpolator(OVERSHOOT_INTERPOLATOR) + .setDuration(ANIMATION_DURATION) + .start(); + + bottomSheet.hide(); + }); + + googleMap.setOnCameraIdleListener(() -> { + markerImage.animate() + .translationY(0f) + .setInterpolator(OVERSHOOT_INTERPOLATOR) + .setDuration(ANIMATION_DURATION) + .start(); + + setCurrentLocation(googleMap.getCameraPosition().target); + }); + }); + } + + private void setInitialLocation(@NonNull LatLng latLng) { + initialLocation = latLng; + + moveMapToInitialIfPossible(); + } + + private void setMap(GoogleMap googleMap) { + this.googleMap = googleMap; + + moveMapToInitialIfPossible(); + } + + private void moveMapToInitialIfPossible() { + if (initialLocation != null && googleMap != null) { + Log.d(TAG, "Moving map to initial location"); + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(initialLocation, ZOOM)); + setCurrentLocation(initialLocation); + } + } + + private void setCurrentLocation(LatLng location) { + currentLocation = location; + bottomSheet.showLoading(); + lookupAddress(location); + } + + private void finishWithAddress() { + Intent returnIntent = new Intent(); + String address = currentAddress != null && currentAddress.getAddressLine(0) != null ? currentAddress.getAddressLine(0) : ""; + AddressData addressData = new AddressData(currentLocation.latitude, currentLocation.longitude, address); + + returnIntent.putExtra(ADDRESS_INTENT, addressData); + setResult(RESULT_OK, returnIntent); + finish(); + } + + private void enableMyLocationButtonIfHaveThePermission(GoogleMap googleMap) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) + { + googleMap.setMyLocationEnabled(true); + } + } + + private void lookupAddress(@Nullable LatLng target) { + if (addressLookup != null) { + addressLookup.cancel(true); + } + addressLookup = new AddressLookup(); + addressLookup.execute(target); + } + + @Override + protected void onPause() { + super.onPause(); + if (addressLookup != null) { + addressLookup.cancel(true); + } + } + + @SuppressLint("StaticFieldLeak") + private class AddressLookup extends AsyncTask { + + private final String TAG = Log.tag(AddressLookup.class); + private final Geocoder geocoder; + + AddressLookup() { + geocoder = new Geocoder(getApplicationContext(), Locale.getDefault()); + } + + @Override + protected Address doInBackground(LatLng... latLngs) { + if (latLngs.length == 0) return null; + LatLng latLng = latLngs[0]; + if (latLng == null) return null; + try { + List

result = geocoder.getFromLocation(latLng.latitude, latLng.longitude, 1); + return !result.isEmpty() ? result.get(0) : null; + } catch (IOException e) { + Log.w(TAG, "Failed to get address from location", e); + return null; + } + } + + @Override + protected void onPostExecute(@Nullable Address address) { + currentAddress = address; + if (address != null) { + bottomSheet.showResult(address.getLatitude(), address.getLongitude(), addressToShortString(address), addressToString(address)); + } else { + bottomSheet.hide(); + } + } + } + + private static @NonNull String addressToString(@Nullable Address address) { + return address != null ? address.getAddressLine(0) : ""; + } + + private static @NonNull String addressToShortString(@Nullable Address address) { + if (address == null) return ""; + + String addressLine = address.getAddressLine(0); + String[] split = addressLine.split(","); + + if (split.length >= 3) { + return split[1].trim() + ", " + split[2].trim(); + } else if (split.length == 2) { + return split[1].trim(); + } else return split[0].trim(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/maps/SingleAddressBottomSheet.java b/app/src/main/java/org/thoughtcrime/securesms/maps/SingleAddressBottomSheet.java new file mode 100644 index 00000000..9d5f9b76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/maps/SingleAddressBottomSheet.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.maps; + +import android.content.Context; +import android.location.Location; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; + +import org.thoughtcrime.securesms.R; + +import java.util.Locale; + +final class SingleAddressBottomSheet extends CoordinatorLayout { + + private TextView placeNameTextView; + private TextView placeAddressTextView; + private ProgressBar placeProgressBar; + private BottomSheetBehavior bottomSheetBehavior; + + public SingleAddressBottomSheet(@NonNull Context context) { + super(context); + init(); + } + + public SingleAddressBottomSheet(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public SingleAddressBottomSheet(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + CoordinatorLayout rootView = (CoordinatorLayout) inflate(getContext(), R.layout.activity_map_bottom_sheet_view, this); + + bottomSheetBehavior = BottomSheetBehavior.from(rootView.findViewById(R.id.root_bottom_sheet)); + bottomSheetBehavior.setHideable(true); + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + bindViews(); + } + + private void bindViews() { + placeNameTextView = findViewById(R.id.text_view_place_name); + placeAddressTextView = findViewById(R.id.text_view_place_address); + placeProgressBar = findViewById(R.id.progress_bar_place); + } + + public void showLoading() { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + placeNameTextView.setText(""); + placeAddressTextView.setText(""); + placeProgressBar.setVisibility(View.VISIBLE); + } + + public void showResult(double latitude, double longitude, String addressToShortString, String addressToString) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + placeProgressBar.setVisibility(View.GONE); + + if (TextUtils.isEmpty(addressToString)) { + String longString = Location.convert(longitude, Location.FORMAT_DEGREES); + String latString = Location.convert(latitude, Location.FORMAT_DEGREES); + + placeNameTextView.setText(String.format(Locale.getDefault(), "%s %s", latString, longString)); + } else { + placeNameTextView.setText(addressToShortString); + placeAddressTextView.setText(addressToString); + } + } + + public void hide() { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java new file mode 100644 index 00000000..b7de8521 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.media; + +import android.content.Context; +import android.media.MediaDataSource; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.PartUriParser; +import org.thoughtcrime.securesms.providers.BlobProvider; + +import java.io.IOException; + +@RequiresApi(api = 23) +public final class DecryptableUriMediaInput { + + private DecryptableUriMediaInput() { + } + + public static @NonNull MediaInput createForUri(@NonNull Context context, @NonNull Uri uri) throws IOException { + + if (BlobProvider.isAuthority(uri)) { + return new MediaInput.MediaDataSourceMediaInput(BlobProvider.getInstance().getMediaDataSource(context, uri)); + } + + if (PartAuthority.isLocalUri(uri)) { + return createForAttachmentUri(context, uri); + } + + return new MediaInput.UriMediaInput(context, uri); + } + + private static @NonNull MediaInput createForAttachmentUri(@NonNull Context context, @NonNull Uri uri) { + AttachmentId partId = new PartUriParser(uri).getPartId(); + + if (!partId.isValid()) { + throw new AssertionError(); + } + + MediaDataSource mediaDataSource = DatabaseFactory.getAttachmentDatabase(context) + .mediaDataSourceFor(partId); + + if (mediaDataSource == null) { + throw new AssertionError(); + } + + return new MediaInput.MediaDataSourceMediaInput(mediaDataSource); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java b/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java new file mode 100644 index 00000000..d82b80b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.media; + +import android.content.Context; +import android.media.MediaDataSource; +import android.media.MediaExtractor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; + +public abstract class MediaInput implements Closeable { + + @NonNull + public abstract MediaExtractor createExtractor() throws IOException; + + public static class FileMediaInput extends MediaInput { + + private final File file; + + public FileMediaInput(@NonNull File file) { + this.file = file; + } + + @Override + public @NonNull MediaExtractor createExtractor() throws IOException { + final MediaExtractor extractor = new MediaExtractor(); + extractor.setDataSource(file.getAbsolutePath()); + return extractor; + } + + @Override + public void close() { + } + } + + public static class UriMediaInput extends MediaInput { + + private final Uri uri; + private final Context context; + + public UriMediaInput(@NonNull Context context, @NonNull Uri uri) { + this.uri = uri; + this.context = context; + } + + @Override + public @NonNull MediaExtractor createExtractor() throws IOException { + final MediaExtractor extractor = new MediaExtractor(); + extractor.setDataSource(context, uri, null); + return extractor; + } + + @Override + public void close() { + } + } + + @RequiresApi(23) + public static class MediaDataSourceMediaInput extends MediaInput { + + private final MediaDataSource mediaDataSource; + + public MediaDataSourceMediaInput(@NonNull MediaDataSource mediaDataSource) { + this.mediaDataSource = mediaDataSource; + } + + @Override + public @NonNull MediaExtractor createExtractor() throws IOException { + final MediaExtractor extractor = new MediaExtractor(); + extractor.setDataSource(mediaDataSource); + return extractor; + } + + @Override + public void close() throws IOException { + mediaDataSource.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java new file mode 100644 index 00000000..b4e38866 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java @@ -0,0 +1,123 @@ +package org.thoughtcrime.securesms.mediaoverview; + +import android.Manifest; +import android.content.Context; +import android.content.res.Resources; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.AttachmentUtil; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; + +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +final class MediaActions { + + private MediaActions() { + } + + static void handleSaveMedia(@NonNull Fragment fragment, + @NonNull Collection mediaRecords, + @Nullable Runnable postExecute) + { + Context context = fragment.requireContext(); + + if (StorageUtil.canWriteToMediaStore()) { + performSaveToDisk(context, mediaRecords, postExecute); + return; + } + + SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> Permissions.with(fragment) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(fragment.getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(context, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(() -> performSaveToDisk(context, mediaRecords, postExecute)) + .execute(), mediaRecords.size()); + } + + static void handleDeleteMedia(@NonNull Context context, + @NonNull Collection mediaRecords) + { + int recordCount = mediaRecords.size(); + Resources res = context.getResources(); + String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title, + recordCount, + recordCount); + String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, + recordCount, + recordCount); + + AlertDialog.Builder builder = new AlertDialog.Builder(context) + .setIcon(R.drawable.ic_warning) + .setTitle(confirmTitle) + .setMessage(confirmMessage) + .setCancelable(true); + + builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> + new ProgressDialogAsyncTask(context, + R.string.MediaOverviewActivity_Media_delete_progress_title, + R.string.MediaOverviewActivity_Media_delete_progress_message) + { + @Override + protected Void doInBackground(MediaDatabase.MediaRecord... records) { + if (records == null || records.length == 0) { + return null; + } + + for (MediaDatabase.MediaRecord record : records) { + AttachmentUtil.deleteAttachment(context, record.getAttachment()); + } + return null; + } + + }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[0])) + ); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + private static void performSaveToDisk(@NonNull Context context, @NonNull Collection mediaRecords, @Nullable Runnable postExecute) { + new ProgressDialogAsyncTask>(context, + R.string.MediaOverviewActivity_collecting_attachments, + R.string.please_wait) + { + @Override + protected List doInBackground(Void... params) { + List attachments = new LinkedList<>(); + + for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) { + if (mediaRecord.getAttachment().getUri() != null) { + attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getUri(), + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getFileName())); + } + } + + return attachments; + } + + @Override + protected void onPostExecute(List attachments) { + super.onPostExecute(attachments); + SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size()); + saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, + attachments.toArray(new SaveAttachmentTask.Attachment[0])); + + if (postExecute != null) postExecute.run(); + } + }.execute(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java new file mode 100644 index 00000000..fcdc02f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java @@ -0,0 +1,543 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mediaoverview; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Observer; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; +import org.thoughtcrime.securesms.database.loaders.GroupedThreadMediaLoader.GroupedThreadMedia; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataPair; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { + + private final Context context; + private final boolean showThread; + private final GlideRequests glideRequests; + private final ItemClickListener itemClickListener; + private final Map selected = new HashMap<>(); + private final AudioItemListener audioItemListener; + + private GroupedThreadMedia media; + private boolean showFileSizes; + private boolean detailView; + + private static final int AUDIO_DETAIL = 1; + private static final int GALLERY = 2; + private static final int GALLERY_DETAIL = 3; + private static final int DOCUMENT_DETAIL = 4; + + void detach(RecyclerView.ViewHolder holder) { + if (holder instanceof SelectableViewHolder) { + ((SelectableViewHolder) holder).onDetached(); + } + } + + private static class HeaderHolder extends HeaderViewHolder { + TextView textView; + + HeaderHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.text); + } + } + + MediaGalleryAllAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + GroupedThreadMedia media, + ItemClickListener clickListener, + @NonNull AudioItemListener audioItemListener, + boolean showFileSizes, + boolean showThread) + { + this.context = context; + this.glideRequests = glideRequests; + this.media = media; + this.itemClickListener = clickListener; + this.audioItemListener = audioItemListener; + this.showFileSizes = showFileSizes; + this.showThread = showThread; + } + + public void setMedia(GroupedThreadMedia media) { + this.media = media; + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { + return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_item_header, parent, false)); + } + + @Override + public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) { + switch (itemType) { + case GALLERY: + return new GalleryViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false)); + case GALLERY_DETAIL: + return new GalleryDetailViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_detail_item_media, parent, false)); + case AUDIO_DETAIL: + return new AudioDetailViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_detail_item_audio, parent, false)); + default: + return new DocumentDetailViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_detail_item_document, parent, false)); + } + } + + @Override + public int getSectionItemViewType(int section, int offset) { + MediaDatabase.MediaRecord mediaRecord = media.get(section, offset); + Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); + + if (slide.hasAudio()) return AUDIO_DETAIL; + if (slide.hasImage() || slide.hasVideo()) return detailView ? GALLERY_DETAIL : GALLERY; + if (slide.hasDocument()) return DOCUMENT_DETAIL; + + return 0; + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int section) { + ((HeaderHolder)viewHolder).textView.setText(media.getName(section)); + } + + @Override + public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) { + MediaDatabase.MediaRecord mediaRecord = media.get(section, offset); + Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); + + ((SelectableViewHolder)viewHolder).bind(context, mediaRecord, slide); + } + + @Override + public void onViewDetachedFromWindow(@NonNull ViewHolder holder) { + super.onViewDetachedFromWindow(holder); + if (holder instanceof SelectableViewHolder) { + ((SelectableViewHolder) holder).onDetached(); + } + } + + @Override + public int getSectionCount() { + return media.getSectionCount(); + } + + @Override + public int getSectionItemCount(int section) { + return media.getSectionItemCount(section); + } + + public void toggleSelection(@NonNull MediaRecord mediaRecord) { + AttachmentId attachmentId = mediaRecord.getAttachment().getAttachmentId(); + MediaDatabase.MediaRecord removed = selected.remove(attachmentId); + if (removed == null) { + selected.put(attachmentId, mediaRecord); + } + + notifyDataSetChanged(); + } + + public int getSelectedMediaCount() { + return selected.size(); + } + + public long getSelectedMediaTotalFileSize() { + //noinspection ConstantConditions attacment cannot be null if selected + return Stream.of(selected.values()) + .collect(Collectors.summingLong(a -> a.getAttachment().getSize())); + } + + @NonNull + public Collection getSelectedMedia() { + return new HashSet<>(selected.values()); + } + + public void clearSelection() { + selected.clear(); + notifyDataSetChanged(); + } + + void selectAllMedia() { + int sectionCount = media.getSectionCount(); + for (int section = 0; section < sectionCount; section++) { + int sectionItemCount = media.getSectionItemCount(section); + for (int item = 0; item < sectionItemCount; item++) { + MediaRecord mediaRecord = media.get(section, item); + selected.put(mediaRecord.getAttachment().getAttachmentId(), mediaRecord); + } + } + this.notifyDataSetChanged(); + } + + void setShowFileSizes(boolean showFileSizes) { + this.showFileSizes = showFileSizes; + } + + void setDetailView(boolean detailView) { + this.detailView = detailView; + } + + class SelectableViewHolder extends ItemViewHolder { + + private final View selectedIndicator; + private MediaDatabase.MediaRecord mediaRecord; + private boolean bound; + + SelectableViewHolder(@NonNull View itemView) { + super(itemView); + this.selectedIndicator = itemView.findViewById(R.id.selected_indicator); + } + + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + if (bound) { + unbind(); + } + this.mediaRecord = mediaRecord; + updateSelectedView(); + bound = true; + } + + void unbind() { + bound = false; + } + + private void updateSelectedView() { + if (selectedIndicator != null) { + selectedIndicator.setVisibility(selected.containsKey(mediaRecord.getAttachment().getAttachmentId()) ? View.VISIBLE : View.GONE); + } + } + + boolean onLongClick() { + itemClickListener.onMediaLongClicked(mediaRecord); + updateSelectedView(); + return true; + } + + void onDetached() { + if (bound) { + unbind(); + } + } + } + + private class GalleryViewHolder extends SelectableViewHolder { + + private final ThumbnailView thumbnailView; + private final TextView imageFileSize; + + GalleryViewHolder(@NonNull View itemView) { + super(itemView); + this.thumbnailView = itemView.findViewById(R.id.image); + this.imageFileSize = itemView.findViewById(R.id.image_file_size); + } + + @Override + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + super.bind(context, mediaRecord, slide); + + if (showFileSizes | detailView) { + imageFileSize.setText(Util.getPrettyFileSize(slide.getFileSize())); + imageFileSize.setVisibility(View.VISIBLE); + } else { + imageFileSize.setVisibility(View.GONE); + } + + thumbnailView.setImageResource(glideRequests, slide, false, false); + thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + thumbnailView.setOnLongClickListener(view -> onLongClick()); + } + + @Override + void unbind() { + thumbnailView.clear(glideRequests); + super.unbind(); + } + } + + private abstract class DetailViewHolder extends SelectableViewHolder implements Observer> { + + protected final View itemView; + private final TextView line1; + private final TextView line2; + private LiveDataPair liveDataPair; + private Optional fileName; + private String fileTypeDescription; + private Handler handler; + private Runnable selectForMarque; + + DetailViewHolder(@NonNull View itemView) { + super(itemView); + this.line1 = itemView.findViewById(R.id.line1); + this.line2 = itemView.findViewById(R.id.line2); + this.itemView = itemView; + } + + @Override + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + super.bind(context, mediaRecord, slide); + + fileName = slide.getFileName(); + fileTypeDescription = getFileTypeDescription(context, slide); + + line1.setText(fileName.or(fileTypeDescription)); + line2.setText(getLine2(context, mediaRecord, slide)); + itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + itemView.setOnLongClickListener(view -> onLongClick()); + selectForMarque = () -> line1.setSelected(true); + handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(selectForMarque, 2500); + + LiveRecipient from = mediaRecord.isOutgoing() ? Recipient.self().live() : Recipient.live(mediaRecord.getRecipientId()); + LiveRecipient to = Recipient.live(mediaRecord.getThreadRecipientId()); + + liveDataPair = new LiveDataPair<>(from.getLiveData(), to.getLiveData(), Recipient.UNKNOWN, Recipient.UNKNOWN); + liveDataPair.observeForever(this); + } + + @Override + void unbind() { + liveDataPair.removeObserver(this); + handler.removeCallbacks(selectForMarque); + line1.setSelected(false); + super.unbind(); + } + + private String getLine2(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + return context.getString(R.string.MediaOverviewActivity_detail_line_3_part, + Util.getPrettyFileSize(slide.getFileSize()), + getFileTypeDescription(context, slide), + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), mediaRecord.getDate())); + } + + protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide){ + return context.getString(R.string.MediaOverviewActivity_file); + } + + @Override + public void onChanged(Pair fromToPair) { + line1.setText(describe(fromToPair.first(), fromToPair.second())); + } + + private String describe(@NonNull Recipient from, @NonNull Recipient thread) { + if (from == Recipient.UNKNOWN && thread == Recipient.UNKNOWN) { + return fileName.or(fileTypeDescription); + } + + String sentFromToString = getSentFromToString(from, thread); + + if (fileName.isPresent()) { + return context.getString(R.string.MediaOverviewActivity_detail_line_2_part, + fileName.get(), + sentFromToString); + } else { + return sentFromToString; + } + } + + private String getSentFromToString(@NonNull Recipient from, @NonNull Recipient thread) { + if (from.isSelf() && from == thread) { + return context.getString(R.string.note_to_self); + } + + if (showThread && (from.isSelf() || thread.isGroup())) { + if (from.isSelf()) { + return context.getString(R.string.MediaOverviewActivity_sent_by_you_to_s, thread.getDisplayName(context)); + } else { + return context.getString(R.string.MediaOverviewActivity_sent_by_s_to_s, from.getDisplayName(context), thread.getDisplayName(context)); + } + } else { + if (from.isSelf()) { + return context.getString(R.string.MediaOverviewActivity_sent_by_you); + } else { + return context.getString(R.string.MediaOverviewActivity_sent_by_s, from.getDisplayName(context)); + } + } + } + } + + private class DocumentDetailViewHolder extends DetailViewHolder { + + private final TextView documentType; + + DocumentDetailViewHolder(@NonNull View itemView) { + super(itemView); + this.documentType = itemView.findViewById(R.id.document_extension); + } + + @Override + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + super.bind(context, mediaRecord, slide); + + documentType.setText(slide.getFileType(context).or("").toLowerCase()); + } + } + + private class AudioDetailViewHolder extends DetailViewHolder { + + private final AudioView audioView; + + AudioDetailViewHolder(@NonNull View itemView) { + super(itemView); + this.audioView = itemView.findViewById(R.id.audio); + } + + @Override + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + super.bind(context, mediaRecord, slide); + + if (!slide.hasAudio()) { + throw new AssertionError(); + } + + long mmsId = Objects.requireNonNull(mediaRecord.getAttachment()).getMmsId(); + + audioItemListener.unregisterPlaybackStateObserver(audioView.getPlaybackStateObserver()); + audioView.setAudio((AudioSlide) slide, new AudioViewCallbacksAdapter(audioItemListener, mmsId), true, true); + audioItemListener.registerPlaybackStateObserver(audioView.getPlaybackStateObserver()); + + audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + } + + @Override + void unbind() { + super.unbind(); + audioItemListener.unregisterPlaybackStateObserver(audioView.getPlaybackStateObserver()); + } + + @Override + protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide) { + return context.getString(R.string.MediaOverviewActivity_audio); + } + } + + private class GalleryDetailViewHolder extends DetailViewHolder { + + private final ThumbnailView thumbnailView; + + GalleryDetailViewHolder(@NonNull View itemView) { + super(itemView); + this.thumbnailView = itemView.findViewById(R.id.image); + } + + @Override + public void bind(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord, @NonNull Slide slide) { + super.bind(context, mediaRecord, slide); + + thumbnailView.setImageResource(glideRequests, slide, false, false); + thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + thumbnailView.setOnLongClickListener(view -> onLongClick()); + } + + @Override + protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide) { + if (slide.hasVideo()) return context.getString(R.string.MediaOverviewActivity_video); + if (slide.hasImage()) return context.getString(R.string.MediaOverviewActivity_image); + return super.getFileTypeDescription(context, slide); + } + + @Override + void unbind() { + thumbnailView.clear(glideRequests); + super.unbind(); + } + } + + private static final class AudioViewCallbacksAdapter implements AudioView.Callbacks { + + private final AudioItemListener audioItemListener; + private final long messageId; + + private AudioViewCallbacksAdapter(@NonNull AudioItemListener audioItemListener, long messageId) { + this.audioItemListener = audioItemListener; + this.messageId = messageId; + } + + @Override + public void onPlay(@NonNull Uri audioUri, double progress) { + audioItemListener.onPlay(audioUri, progress, messageId); + } + + @Override + public void onPause(@NonNull Uri audioUri) { + audioItemListener.onPause(audioUri); + } + + @Override + public void onSeekTo(@NonNull Uri audioUri, double progress) { + audioItemListener.onSeekTo(audioUri, progress); + } + + @Override + public void onStopAndReset(@NonNull Uri audioUri) { + audioItemListener.onStopAndReset(audioUri); + } + + @Override + public void onProgressUpdated(long durationMillis, long playheadMillis) { + } + } + + interface ItemClickListener { + void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord); + void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord); + } + + interface AudioItemListener { + void onPlay(@NonNull Uri audioUri, double progress, long messageId); + void onPause(@NonNull Uri audioUri); + void onSeekTo(@NonNull Uri audioUri, double progress); + void onStopAndReset(@NonNull Uri audioUri); + void registerPlaybackStateObserver(@NonNull Observer observer); + void unregisterPlaybackStateObserver(@NonNull Observer observer); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java new file mode 100644 index 00000000..30a7dbdb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mediaoverview; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MediaDatabase.Sorting; +import org.thoughtcrime.securesms.database.loaders.MediaLoader; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.Pair; + +import java.util.ArrayList; +import java.util.List; + +/** + * Activity for displaying media attachments in-app + */ +public final class MediaOverviewActivity extends PassphraseRequiredActivity { + + private static final String THREAD_ID_EXTRA = "thread_id"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + private Toolbar toolbar; + private TabLayout tabLayout; + private ViewPager viewPager; + private TextView sortOrder; + private View sortOrderArrow; + private Sorting currentSorting; + private Boolean currentDetailLayout; + private MediaOverviewViewModel model; + private AnimatingToggle displayToggle; + private View viewGrid; + private View viewDetail; + private long threadId; + + public static Intent forThread(@NonNull Context context, long threadId) { + Intent intent = new Intent(context, MediaOverviewActivity.class); + intent.putExtra(MediaOverviewActivity.THREAD_ID_EXTRA, threadId); + return intent; + } + + public static Intent forAll(@NonNull Context context) { + return forThread(context, MediaDatabase.ALL_THREADS); + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle bundle, boolean ready) { + setContentView(R.layout.media_overview_activity); + + initializeResources(); + initializeToolbar(); + + boolean allThreads = threadId == MediaDatabase.ALL_THREADS; + + fillTabLayoutIfFits(tabLayout); + + tabLayout.setupWithViewPager(viewPager); + viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager())); + + model = MediaOverviewViewModel.getMediaOverviewViewModel(this); + model.setSortOrder(allThreads ? Sorting.Largest : Sorting.Newest); + model.setDetailLayout(allThreads); + model.getSortOrder().observe(this, this::setSorting); + model.getDetailLayout().observe(this, this::setDetailLayout); + + sortOrder.setOnClickListener(this::showSortOrderDialog); + sortOrderArrow.setOnClickListener(this::showSortOrderDialog); + + displayToggle.setOnClickListener(v -> setDetailLayout(!currentDetailLayout)); + + viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + boolean gridToggleEnabled = allowGridSelectionOnPage(position); + displayToggle.animate() + .alpha(gridToggleEnabled ? 1 : 0) + .start(); + displayToggle.setEnabled(gridToggleEnabled); + } + }); + + viewPager.setCurrentItem(allThreads ? 3 : 0); + } + + private static boolean allowGridSelectionOnPage(int page) { + return page == 0; + } + + private void setSorting(@NonNull Sorting sorting) { + if (currentSorting == sorting) return; + + sortOrder.setText(sortingToString(sorting)); + currentSorting = sorting; + model.setSortOrder(sorting); + } + + private static @StringRes int sortingToString(@NonNull Sorting sorting) { + switch (sorting) { + case Oldest : return R.string.MediaOverviewActivity_Oldest; + case Newest : return R.string.MediaOverviewActivity_Newest; + case Largest : return R.string.MediaOverviewActivity_Storage_used; + default : throw new AssertionError(); + } + } + + private void setDetailLayout(@NonNull Boolean detailLayout) { + if (currentDetailLayout == detailLayout) return; + + currentDetailLayout = detailLayout; + model.setDetailLayout(detailLayout); + displayToggle.display(detailLayout ? viewGrid : viewDetail); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return false; + } + + private void initializeResources() { + Intent intent = getIntent(); + long threadId = intent.getLongExtra(THREAD_ID_EXTRA, Long.MIN_VALUE); + + if (threadId == Long.MIN_VALUE) throw new AssertionError(); + + this.viewPager = findViewById(R.id.pager); + this.toolbar = findViewById(R.id.toolbar); + this.tabLayout = findViewById(R.id.tab_layout); + this.sortOrder = findViewById(R.id.sort_order); + this.sortOrderArrow = findViewById(R.id.sort_order_arrow); + this.displayToggle = findViewById(R.id.grid_list_toggle); + this.viewDetail = findViewById(R.id.view_detail); + this.viewGrid = findViewById(R.id.view_grid); + this.threadId = threadId; + } + + private void initializeToolbar() { + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + if (threadId == MediaDatabase.ALL_THREADS) { + getSupportActionBar().setTitle(R.string.MediaOverviewActivity_All_storage_use); + } else { + SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadId), + (recipient) -> { + if (recipient != null) { + getSupportActionBar().setTitle(recipient.getDisplayName(this)); + recipient.live().observe(this, r -> getSupportActionBar().setTitle(r.getDisplayName(this))); + } + } + ); + } + } + + public void onEnterMultiSelect() { + tabLayout.setEnabled(false); + viewPager.setEnabled(false); + } + + public void onExitMultiSelect() { + tabLayout.setEnabled(true); + viewPager.setEnabled(true); + } + + private void showSortOrderDialog(View v) { + new AlertDialog.Builder(MediaOverviewActivity.this) + .setTitle(R.string.MediaOverviewActivity_Sort_by) + .setSingleChoiceItems(R.array.MediaOverviewActivity_Sort_by, + currentSorting.ordinal(), + (dialog, item) -> { + setSorting(Sorting.values()[item]); + dialog.dismiss(); + }) + .create() + .show(); + } + + private static void fillTabLayoutIfFits(@NonNull TabLayout tabLayout) { + tabLayout.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + int totalWidth = 0; + int maxWidth = 0; + ViewGroup tabs = (ViewGroup) tabLayout.getChildAt(0); + + for (int i = 0; i < tabLayout.getTabCount(); i++) { + int tabWidth = tabs.getChildAt(i).getWidth(); + totalWidth += tabWidth; + maxWidth = Math.max(maxWidth, tabWidth); + } + + int viewWidth = right - left; + if (totalWidth < viewWidth) { + tabLayout.setTabMode(TabLayout.MODE_FIXED); + } + }); + } + + private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter { + + private final List> pages; + + MediaOverviewPagerAdapter(FragmentManager fragmentManager) { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + + pages = new ArrayList<>(4); + pages.add(new Pair<>(MediaLoader.MediaType.GALLERY, getString(R.string.MediaOverviewActivity_Media))); + pages.add(new Pair<>(MediaLoader.MediaType.DOCUMENT, getString(R.string.MediaOverviewActivity_Files))); + pages.add(new Pair<>(MediaLoader.MediaType.AUDIO, getString(R.string.MediaOverviewActivity_Audio))); + pages.add(new Pair<>(MediaLoader.MediaType.ALL, getString(R.string.MediaOverviewActivity_All))); + } + + @Override + public @NonNull Fragment getItem(int position) { + MediaOverviewPageFragment.GridMode gridMode = allowGridSelectionOnPage(position) + ? MediaOverviewPageFragment.GridMode.FOLLOW_MODEL + : MediaOverviewPageFragment.GridMode.FIXED_DETAIL; + + return MediaOverviewPageFragment.newInstance(threadId, pages.get(position).first(), gridMode); + } + + @Override + public int getCount() { + return pages.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return pages.get(position).second(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java new file mode 100644 index 00000000..c59d556f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -0,0 +1,402 @@ +package org.thoughtcrime.securesms.mediaoverview; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.Observer; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.RecyclerView; + +import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.loaders.GroupedThreadMediaLoader; +import org.thoughtcrime.securesms.database.loaders.MediaLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.WindowUtil; + +public final class MediaOverviewPageFragment extends Fragment + implements MediaGalleryAllAdapter.ItemClickListener, + MediaGalleryAllAdapter.AudioItemListener, + LoaderManager.LoaderCallbacks +{ + + private static final String TAG = Log.tag(MediaOverviewPageFragment.class); + + private static final String THREAD_ID_EXTRA = "thread_id"; + private static final String MEDIA_TYPE_EXTRA = "media_type"; + private static final String GRID_MODE = "grid_mode"; + + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + private MediaDatabase.Sorting sorting = MediaDatabase.Sorting.Newest; + private MediaLoader.MediaType mediaType = MediaLoader.MediaType.GALLERY; + private long threadId; + private TextView noMedia; + private RecyclerView recyclerView; + private StickyHeaderGridLayoutManager gridManager; + private ActionMode actionMode; + private boolean detail; + private MediaGalleryAllAdapter adapter; + private GridMode gridMode; + private VoiceNoteMediaController voiceNoteMediaController; + + public static @NonNull Fragment newInstance(long threadId, + @NonNull MediaLoader.MediaType mediaType, + @NonNull GridMode gridMode) + { + MediaOverviewPageFragment mediaOverviewAllFragment = new MediaOverviewPageFragment(); + Bundle args = new Bundle(); + args.putLong(THREAD_ID_EXTRA, threadId); + args.putInt(MEDIA_TYPE_EXTRA, mediaType.ordinal()); + args.putInt(GRID_MODE, gridMode.ordinal()); + mediaOverviewAllFragment.setArguments(args); + + return mediaOverviewAllFragment; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + Bundle arguments = requireArguments(); + + threadId = arguments.getLong(THREAD_ID_EXTRA, Long.MIN_VALUE); + mediaType = MediaLoader.MediaType.values()[arguments.getInt(MEDIA_TYPE_EXTRA)]; + gridMode = GridMode.values()[arguments.getInt(GRID_MODE)]; + + if (threadId == Long.MIN_VALUE) throw new AssertionError(); + + LoaderManager.getInstance(this).initLoader(0, null, this); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + Context context = requireContext(); + View view = inflater.inflate(R.layout.media_overview_page_fragment, container, false); + + this.recyclerView = view.findViewById(R.id.media_grid); + this.noMedia = view.findViewById(R.id.no_images); + this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); + + this.adapter = new MediaGalleryAllAdapter(context, + GlideApp.with(this), + new GroupedThreadMediaLoader.EmptyGroupedThreadMedia(), + this, + this, + sorting.isRelatedToFileSize(), + threadId == MediaDatabase.ALL_THREADS); + this.recyclerView.setAdapter(adapter); + this.recyclerView.setLayoutManager(gridManager); + this.recyclerView.setHasFixedSize(true); + + MediaOverviewViewModel viewModel = MediaOverviewViewModel.getMediaOverviewViewModel(requireActivity()); + + viewModel.getSortOrder() + .observe(getViewLifecycleOwner(), sorting -> { + if (sorting != null) { + this.sorting = sorting; + adapter.setShowFileSizes(sorting.isRelatedToFileSize()); + LoaderManager.getInstance(this).restartLoader(0, null, this); + refreshActionModeTitle(); + } + }); + + if (gridMode == GridMode.FOLLOW_MODEL) { + viewModel.getDetailLayout() + .observe(getViewLifecycleOwner(), this::setDetailView); + } else { + setDetailView(gridMode == GridMode.FIXED_DETAIL); + } + + return view; + } + + private void setDetailView(boolean detail) { + this.detail = detail; + adapter.setDetailView(detail); + refreshLayoutManager(); + refreshActionModeTitle(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (gridManager != null) { + refreshLayoutManager(); + } + } + + private void refreshLayoutManager() { + this.gridManager = new StickyHeaderGridLayoutManager(detail ? 1 : getResources().getInteger(R.integer.media_overview_cols)); + this.recyclerView.setLayoutManager(gridManager); + } + + @Override + public @NonNull Loader onCreateLoader(int i, Bundle bundle) { + return new GroupedThreadMediaLoader(requireContext(), threadId, mediaType, sorting); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, GroupedThreadMediaLoader.GroupedThreadMedia groupedThreadMedia) { + ((MediaGalleryAllAdapter) recyclerView.getAdapter()).setMedia(groupedThreadMedia); + ((MediaGalleryAllAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged(); + + noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE); + getActivity().invalidateOptionsMenu(); + } + + @Override + public void onLoaderReset(@NonNull Loader cursorLoader) { + ((MediaGalleryAllAdapter) recyclerView.getAdapter()).setMedia(new GroupedThreadMediaLoader.EmptyGroupedThreadMedia()); + } + + @Override + public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + } else { + handleMediaPreviewClick(mediaRecord); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + int childCount = recyclerView.getChildCount(); + for (int i = 0; i < childCount; i++) { + adapter.detach(recyclerView.getChildViewHolder(recyclerView.getChildAt(i))); + } + } + + private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + MediaGalleryAllAdapter adapter = getListAdapter(); + + adapter.toggleSelection(mediaRecord); + if (adapter.getSelectedMediaCount() == 0) { + actionMode.finish(); + } else { + refreshActionModeTitle(); + } + } + + private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (mediaRecord.getAttachment().getUri() == null) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + DatabaseAttachment attachment = mediaRecord.getAttachment(); + + if (MediaUtil.isVideo(attachment) || MediaUtil.isImage(attachment)) { + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); + intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, threadId); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true); + intent.putExtra(MediaPreviewActivity.HIDE_ALL_MEDIA_EXTRA, true); + intent.putExtra(MediaPreviewActivity.SHOW_THREAD_EXTRA, threadId == MediaDatabase.ALL_THREADS); + intent.putExtra(MediaPreviewActivity.SORTING_EXTRA, sorting.ordinal()); + + intent.setDataAndType(mediaRecord.getAttachment().getUri(), mediaRecord.getContentType()); + context.startActivity(intent); + } else { + if (!MediaUtil.isAudio(attachment)) { + showFileExternally(context, mediaRecord); + } + } + } + + private static void showFileExternally(@NonNull Context context, @NonNull MediaDatabase.MediaRecord mediaRecord) { + Uri uri = mediaRecord.getAttachment().getUri(); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setDataAndType(PartAuthority.getAttachmentPublicUri(uri), mediaRecord.getContentType()); + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "No activity existed to view the media."); + Toast.makeText(context, R.string.ConversationItem_unable_to_open_media, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) { + ((MediaGalleryAllAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); + recyclerView.getAdapter().notifyDataSetChanged(); + + if (actionMode == null) { + enterMultiSelect(); + } + } + + private void handleSelectAllMedia() { + getListAdapter().selectAllMedia(); + refreshActionModeTitle(); + } + + private void refreshActionModeTitle() { + if (actionMode != null) { + actionMode.setTitle(getActionModeTitle()); + } + } + + private String getActionModeTitle() { + MediaGalleryAllAdapter adapter = getListAdapter(); + int mediaCount = adapter.getSelectedMediaCount(); + boolean showTotalFileSize = detail || + mediaType != MediaLoader.MediaType.GALLERY || + sorting == MediaDatabase.Sorting.Largest; + + if (showTotalFileSize) { + long totalFileSize = adapter.getSelectedMediaTotalFileSize(); + return getResources().getQuantityString(R.plurals.MediaOverviewActivity_d_items_s, + mediaCount, + mediaCount, + Util.getPrettyFileSize(totalFileSize)); + } else { + return getResources().getQuantityString(R.plurals.MediaOverviewActivity_d_items, + mediaCount, + mediaCount); + } + } + + private MediaGalleryAllAdapter getListAdapter() { + return (MediaGalleryAllAdapter) recyclerView.getAdapter(); + } + + private void enterMultiSelect() { + FragmentActivity activity = requireActivity(); + actionMode = ((AppCompatActivity) activity).startSupportActionMode(actionModeCallback); + ((MediaOverviewActivity) activity).onEnterMultiSelect(); + } + + @Override + public void onPlay(@NonNull Uri audioUri, double progress, long messageId) { + voiceNoteMediaController.startSinglePlayback(audioUri, messageId, progress); + } + + @Override + public void onPause(@NonNull Uri audioUri) { + voiceNoteMediaController.pausePlayback(audioUri); + } + + @Override + public void onSeekTo(@NonNull Uri audioUri, double progress) { + voiceNoteMediaController.seekToPosition(audioUri, progress); + } + + @Override + public void onStopAndReset(@NonNull Uri audioUri) { + voiceNoteMediaController.stopPlaybackAndReset(audioUri); + } + + @Override + public void registerPlaybackStateObserver(@NonNull Observer observer) { + voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), observer); + } + + @Override + public void unregisterPlaybackStateObserver(@NonNull Observer observer) { + voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(observer); + } + + private class ActionModeCallback implements ActionMode.Callback { + + private int originalStatusBarColor; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.media_overview_context, menu); + mode.setTitle(getActionModeTitle()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Window window = requireActivity().getWindow(); + originalStatusBarColor = window.getStatusBarColor(); + WindowUtil.setStatusBarColor(requireActivity().getWindow(), getResources().getColor(R.color.action_mode_status_bar)); + } + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.save: + MediaActions.handleSaveMedia(MediaOverviewPageFragment.this, + getListAdapter().getSelectedMedia(), + () -> actionMode.finish()); + return true; + case R.id.delete: + MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia()); + actionMode.finish(); + return true; + case R.id.select_all: + handleSelectAllMedia(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().clearSelection(); + + FragmentActivity activity = requireActivity(); + + ((MediaOverviewActivity) activity).onExitMultiSelect(); + + WindowUtil.setStatusBarColor(requireActivity().getWindow(), originalStatusBarColor); + } + } + + public enum GridMode { + FIXED_DETAIL, + FOLLOW_MODEL + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewViewModel.java new file mode 100644 index 00000000..e5cd0c22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewViewModel.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.mediaoverview; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.SavedStateViewModelFactory; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.database.MediaDatabase.Sorting; + +public class MediaOverviewViewModel extends ViewModel { + + private final MutableLiveData sortOrder; + private final MutableLiveData detailLayout; + + public MediaOverviewViewModel(@NonNull SavedStateHandle savedStateHandle) { + sortOrder = savedStateHandle.getLiveData("SORT_ORDER", Sorting.Newest); + detailLayout = savedStateHandle.getLiveData("DETAIL_LAYOUT", false); + } + + public LiveData getSortOrder() { + return sortOrder; + } + + public LiveData getDetailLayout() { + return detailLayout; + } + + public void setSortOrder(@NonNull Sorting sortOrder) { + this.sortOrder.setValue(sortOrder); + } + + public void setDetailLayout(boolean detailLayout) { + this.detailLayout.setValue(detailLayout); + } + + static MediaOverviewViewModel getMediaOverviewViewModel(@NonNull FragmentActivity activity) { + SavedStateViewModelFactory savedStateViewModelFactory = new SavedStateViewModelFactory(activity.getApplication(), activity); + + return ViewModelProviders.of(activity, savedStateViewModelFactory).get(MediaOverviewViewModel.class); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java new file mode 100644 index 00000000..bb38d80a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ZoomingImageView; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.MediaUtil; + +public final class ImageMediaPreviewFragment extends MediaPreviewFragment { + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + ZoomingImageView zoomingImageView = (ZoomingImageView) inflater.inflate(R.layout.media_preview_image_fragment, container, false); + GlideRequests glideRequests = GlideApp.with(requireActivity()); + Bundle arguments = requireArguments(); + Uri uri = arguments.getParcelable(DATA_URI); + String contentType = arguments.getString(DATA_CONTENT_TYPE); + + if (!MediaUtil.isImageType(contentType)) { + throw new AssertionError("This fragment can only display images"); + } + + //noinspection ConstantConditions + zoomingImageView.setImageUri(glideRequests, uri, contentType); + + zoomingImageView.setOnClickListener(v -> events.singleTapOnMedia()); + + return zoomingImageView; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java new file mode 100644 index 00000000..8962e736 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.PartUriParser; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.Objects; + +public abstract class MediaPreviewFragment extends Fragment { + + static final String DATA_URI = "DATA_URI"; + static final String DATA_SIZE = "DATA_SIZE"; + static final String DATA_CONTENT_TYPE = "DATA_CONTENT_TYPE"; + static final String AUTO_PLAY = "AUTO_PLAY"; + + private AttachmentId attachmentId; + protected Events events; + + public static MediaPreviewFragment newInstance(@NonNull Attachment attachment, boolean autoPlay) { + return newInstance(attachment.getUri(), attachment.getContentType(), attachment.getSize(), autoPlay); + } + + public static MediaPreviewFragment newInstance(@NonNull Uri dataUri, @NonNull String contentType, long size, boolean autoPlay) { + Bundle args = new Bundle(); + + args.putParcelable(MediaPreviewFragment.DATA_URI, dataUri); + args.putString(MediaPreviewFragment.DATA_CONTENT_TYPE, contentType); + args.putLong(MediaPreviewFragment.DATA_SIZE, size); + args.putBoolean(MediaPreviewFragment.AUTO_PLAY, autoPlay); + + MediaPreviewFragment fragment = createCorrectFragmentType(contentType); + + fragment.setArguments(args); + + return fragment; + } + + private static MediaPreviewFragment createCorrectFragmentType(@NonNull String contentType) { + if (MediaUtil.isVideo(contentType)) { + return new VideoMediaPreviewFragment(); + } else if (MediaUtil.isImageType(contentType)) { + return new ImageMediaPreviewFragment(); + } else { + throw new AssertionError("Unexpected media type: " + contentType); + } + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (!(context instanceof Events)) { + throw new AssertionError("Activity must support " + Events.class); + } + + events = (Events) context; + } + + @Override + public void onResume() { + super.onResume(); + checkMediaStillAvailable(); + } + + public void cleanUp() { + } + + public void pause() { + } + + public @Nullable View getPlaybackControls() { + return null; + } + + public void checkMediaStillAvailable() { + if (attachmentId == null) { + attachmentId = new PartUriParser(Objects.requireNonNull(requireArguments().getParcelable(DATA_URI))).getPartId(); + } + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), + () -> DatabaseFactory.getAttachmentDatabase(requireContext()).hasAttachment(attachmentId), + hasAttachment -> { if (!hasAttachment) events.mediaNotAvailable(); }); + } + + public interface Events { + boolean singleTapOnMedia(); + void mediaNotAvailable(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java new file mode 100644 index 00000000..322542eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; +import org.thoughtcrime.securesms.mediasend.Media; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class MediaPreviewViewModel extends ViewModel { + + private final MutableLiveData previewData = new MutableLiveData<>(); + + private boolean leftIsRecent; + + private @Nullable Cursor cursor; + + public void setCursor(@NonNull Context context, @Nullable Cursor cursor, boolean leftIsRecent) { + boolean firstLoad = (this.cursor == null) && (cursor != null); + + this.cursor = cursor; + this.leftIsRecent = leftIsRecent; + + if (firstLoad) { + setActiveAlbumRailItem(context, 0); + } + } + + public void setActiveAlbumRailItem(@NonNull Context context, int activePosition) { + if (cursor == null) { + previewData.postValue(new PreviewData(Collections.emptyList(), null, 0)); + return; + } + + activePosition = getCursorPosition(activePosition); + + cursor.moveToPosition(activePosition); + + MediaRecord activeRecord = MediaRecord.from(context, cursor); + LinkedList rail = new LinkedList<>(); + + Media activeMedia = toMedia(activeRecord); + if (activeMedia != null) rail.add(activeMedia); + + while (cursor.moveToPrevious()) { + MediaRecord record = MediaRecord.from(context, cursor); + if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { + Media media = toMedia(record); + if (media != null) rail.addFirst(media); + } else { + break; + } + } + + cursor.moveToPosition(activePosition); + + while (cursor.moveToNext()) { + MediaRecord record = MediaRecord.from(context, cursor); + if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { + Media media = toMedia(record); + if (media != null) rail.addLast(media); + } else { + break; + } + } + + if (!leftIsRecent) { + Collections.reverse(rail); + } + + previewData.postValue(new PreviewData(rail.size() > 1 ? rail : Collections.emptyList(), + activeRecord.getAttachment().getCaption(), + rail.indexOf(activeMedia))); + } + + public void resubmitPreviewData() { + previewData.postValue(previewData.getValue()); + } + + private int getCursorPosition(int position) { + if (cursor == null) { + return 0; + } + + if (leftIsRecent) return position; + else return cursor.getCount() - 1 - position; + } + + private @Nullable Media toMedia(@NonNull MediaRecord mediaRecord) { + Uri uri = mediaRecord.getAttachment().getUri(); + + if (uri == null) { + return null; + } + + return new Media(uri, + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getWidth(), + mediaRecord.getAttachment().getHeight(), + mediaRecord.getAttachment().getSize(), + 0, + mediaRecord.getAttachment().isBorderless(), + Optional.absent(), + Optional.fromNullable(mediaRecord.getAttachment().getCaption()), + Optional.absent()); + } + + public LiveData getPreviewData() { + return previewData; + } + + public static class PreviewData { + private final List albumThumbnails; + private final String caption; + private final int activePosition; + + public PreviewData(@NonNull List albumThumbnails, @Nullable String caption, int activePosition) { + this.albumThumbnails = albumThumbnails; + this.caption = caption; + this.activePosition = activePosition; + } + + public @NonNull List getAlbumThumbnails() { + return albumThumbnails; + } + + public @Nullable String getCaption() { + return caption; + } + + public int getActivePosition() { + return activePosition; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java new file mode 100644 index 00000000..6161ff13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.List; + +public class MediaRailAdapter extends RecyclerView.Adapter { + + private static final int TYPE_MEDIA = 1; + private static final int TYPE_BUTTON = 2; + + private final GlideRequests glideRequests; + private final List media; + private final RailItemListener listener; + private final StableIdGenerator stableIdGenerator; + + private RailItemAddListener addListener; + private int activePosition; + private boolean editable; + private boolean interactive; + + public MediaRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemListener listener, boolean editable) { + this.glideRequests = glideRequests; + this.media = new ArrayList<>(); + this.listener = listener; + this.editable = editable; + this.stableIdGenerator = new StableIdGenerator<>(); + this.interactive = true; + + setHasStableIds(true); + } + + @NonNull + @Override + public MediaRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int type) { + switch (type) { + case TYPE_MEDIA: + return new MediaViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediarail_media_item, viewGroup, false)); + case TYPE_BUTTON: + return new ButtonViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediarail_button_item, viewGroup, false)); + default: + throw new UnsupportedOperationException("Unsupported view type: " + type); + } + } + + @Override + public void onBindViewHolder(@NonNull MediaRailViewHolder viewHolder, int i) { + switch (getItemViewType(i)) { + case TYPE_MEDIA: + ((MediaViewHolder) viewHolder).bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, editable, interactive); + break; + case TYPE_BUTTON: + ((ButtonViewHolder) viewHolder).bind(addListener); + break; + default: + throw new UnsupportedOperationException("Unsupported view type: " + getItemViewType(i)); + } + } + + @Override + public int getItemViewType(int position) { + if (editable && position == getItemCount() - 1) { + return TYPE_BUTTON; + } else { + return TYPE_MEDIA; + } + } + + @Override + public void onViewRecycled(@NonNull MediaRailViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return editable ? media.size() + 1 : media.size(); + } + + @Override + public long getItemId(int position) { + switch (getItemViewType(position)) { + case TYPE_MEDIA: + return stableIdGenerator.getId(media.get(position)); + case TYPE_BUTTON: + return Long.MAX_VALUE; + default: + throw new UnsupportedOperationException("Unsupported view type: " + getItemViewType(position)); + } + } + + public void setMedia(@NonNull List media) { + setMedia(media, activePosition); + } + + public void setMedia(@NonNull List records, int activePosition) { + this.activePosition = activePosition; + + this.media.clear(); + this.media.addAll(records); + + notifyDataSetChanged(); + } + + public void setActivePosition(int activePosition) { + this.activePosition = activePosition; + notifyDataSetChanged(); + } + + public void setAddButtonListener(@Nullable RailItemAddListener addListener) { + this.addListener = addListener; + notifyDataSetChanged(); + } + + public void setEditable(boolean editable) { + this.editable = editable; + notifyDataSetChanged(); + } + + public void setInteractive(boolean interactive) { + this.interactive = interactive; + notifyDataSetChanged(); + } + + static abstract class MediaRailViewHolder extends RecyclerView.ViewHolder { + public MediaRailViewHolder(@NonNull View itemView) { + super(itemView); + } + + abstract void recycle(); + } + + static class MediaViewHolder extends MediaRailViewHolder { + + private final ThumbnailView image; + private final View outline; + private final View deleteButton; + private final View captionIndicator; + + MediaViewHolder(@NonNull View itemView) { + super(itemView); + image = itemView.findViewById(R.id.rail_item_image); + outline = itemView.findViewById(R.id.rail_item_outline); + deleteButton = itemView.findViewById(R.id.rail_item_delete); + captionIndicator = itemView.findViewById(R.id.rail_item_caption); + } + + void bind(@NonNull Media media, boolean isActive, @NonNull GlideRequests glideRequests, + @NonNull RailItemListener railItemListener, int distanceFromActive, boolean editable, + boolean interactive) + { + image.setImageResource(glideRequests, media.getUri()); + image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive)); + + outline.setVisibility(isActive && interactive ? View.VISIBLE : View.GONE); + + captionIndicator.setVisibility(media.getCaption().isPresent() ? View.VISIBLE : View.GONE); + + if (editable && isActive && interactive) { + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive)); + } else { + deleteButton.setVisibility(View.GONE); + } + } + + void recycle() { + image.setOnClickListener(null); + deleteButton.setOnClickListener(null); + } + } + + static class ButtonViewHolder extends MediaRailViewHolder { + + public ButtonViewHolder(@NonNull View itemView) { + super(itemView); + } + + void bind(@Nullable RailItemAddListener addListener) { + if (addListener != null) { + itemView.setOnClickListener(v -> addListener.onRailItemAddClicked()); + } + } + + @Override + void recycle() { + itemView.setOnClickListener(null); + } + } + + public interface RailItemListener { + void onRailItemClicked(int distanceFromActive); + void onRailItemDeleteClicked(int distanceFromActive); + } + + public interface RailItemAddListener { + void onRailItemAddClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java new file mode 100644 index 00000000..80e82da8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.video.VideoPlayer; + +public final class VideoMediaPreviewFragment extends MediaPreviewFragment { + + private static final String TAG = Log.tag(VideoMediaPreviewFragment.class); + + private VideoPlayer videoView; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View itemView = inflater.inflate(R.layout.media_preview_video_fragment, container, false); + Bundle arguments = requireArguments(); + Uri uri = arguments.getParcelable(DATA_URI); + String contentType = arguments.getString(DATA_CONTENT_TYPE); + long size = arguments.getLong(DATA_SIZE); + boolean autoPlay = arguments.getBoolean(AUTO_PLAY); + + if (!MediaUtil.isVideo(contentType)) { + throw new AssertionError("This fragment can only display video"); + } + + videoView = itemView.findViewById(R.id.video_player); + + videoView.setWindow(requireActivity().getWindow()); + videoView.setVideoSource(new VideoSlide(getContext(), uri, size), autoPlay); + + videoView.setOnClickListener(v -> events.singleTapOnMedia()); + + return itemView; + } + + @Override + public void cleanUp() { + if (videoView != null) { + videoView.cleanup(); + } + } + + @Override + public void pause() { + if (videoView != null) { + videoView.pause(); + } + } + + @Override + public View getPlaybackControls() { + return videoView != null ? videoView.getControlView() : null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java new file mode 100644 index 00000000..267da517 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -0,0 +1,232 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.FileDescriptor; +import java.util.Collections; + +public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller { + + private static final Point AVATAR_DIMENSIONS = new Point(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS); + + private static final String IMAGE_CAPTURE = "IMAGE_CAPTURE"; + private static final String IMAGE_EDITOR = "IMAGE_EDITOR"; + private static final String ARG_GALLERY = "ARG_GALLERY"; + + public static final String EXTRA_MEDIA = "avatar.media"; + + private Media currentMedia; + + public static Intent getIntentForCameraCapture(@NonNull Context context) { + return new Intent(context, AvatarSelectionActivity.class); + } + + public static Intent getIntentForGallery(@NonNull Context context) { + Intent intent = getIntentForCameraCapture(context); + + intent.putExtra(ARG_GALLERY, true); + + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.avatar_selection_activity); + + MediaSendViewModel viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel.setTransport(TransportOptions.getPushTransportOption(this)); + + if (isGalleryFirst()) { + onGalleryClicked(); + } else { + onCameraSelected(); + } + } + + @Override + public void onCameraError() { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + finish(); + } + + @Override + public void onImageCaptured(@NonNull byte[] data, int width, int height) { + Uri blobUri = BlobProvider.getInstance() + .forData(data) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleSessionInMemory(); + + onMediaSelected(new Media(blobUri, + MediaUtil.IMAGE_JPEG, + System.currentTimeMillis(), + width, + height, + data.length, + 0, + false, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent(), + Optional.absent())); + } + + @Override + public void onVideoCaptured(@NonNull FileDescriptor fd) { + throw new UnsupportedOperationException("Cannot set profile as video"); + } + + @Override + public void onVideoCaptureError() { + throw new AssertionError("This should never happen"); + } + + @Override + public void onGalleryClicked() { + if (isGalleryFirst() && popToRoot()) { + return; + } + + MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, null); + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, fragment); + + if (isCameraFirst()) { + transaction.addToBackStack(null); + } + + transaction.commit(); + } + + @Override + public int getDisplayRotation() { + return getWindowManager().getDefaultDisplay().getRotation(); + } + + @Override + public void onCameraCountButtonClicked() { + throw new UnsupportedOperationException("Cannot select more than one photo"); + } + + @Override + public void onTouchEventsNeeded(boolean needed) { + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + } + + @Override + public void onFolderSelected(@NonNull MediaFolder folder) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false)) + .addToBackStack(null) + .commit(); + } + + @Override + public void onMediaSelected(@NonNull Media media) { + currentMedia = media; + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, ImageEditorFragment.newInstanceForAvatar(media.getUri()), IMAGE_EDITOR) + .addToBackStack(IMAGE_EDITOR) + .commit(); + } + + @Override + public void onCameraSelected() { + if (isCameraFirst() && popToRoot()) { + return; + } + + Fragment fragment = CameraFragment.newInstanceForAvatarCapture(); + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, fragment, IMAGE_CAPTURE); + + if (isGalleryFirst()) { + transaction.addToBackStack(null); + } + + transaction.commit(); + } + + @Override + public void onDoneEditing() { + handleSave(); + } + + public boolean popToRoot() { + final int backStackCount = getSupportFragmentManager().getBackStackEntryCount(); + if (backStackCount == 0) { + return false; + } + + for (int i = 0; i < backStackCount; i++) { + getSupportFragmentManager().popBackStack(); + } + + return true; + } + + private boolean isGalleryFirst() { + return getIntent().getBooleanExtra(ARG_GALLERY, false); + } + + private boolean isCameraFirst() { + return !isGalleryFirst(); + } + + private void handleSave() { + ImageEditorFragment fragment = (ImageEditorFragment) getSupportFragmentManager().findFragmentByTag(IMAGE_EDITOR); + if (fragment == null) { + throw new AssertionError(); + } + + ImageEditorFragment.Data data = (ImageEditorFragment.Data) fragment.saveState(); + + EditorModel model = data.readModel(); + if (model == null) { + throw new AssertionError(); + } + + MediaRepository.transformMedia(this, + Collections.singletonList(currentMedia), + Collections.singletonMap(currentMedia, new ImageEditorModelRenderMediaTransform(model, AVATAR_DIMENSIONS)), + output -> { + Media transformed = output.get(currentMedia); + + Intent result = new Intent(); + result.putExtra(EXTRA_MEDIA, transformed); + setResult(RESULT_OK, result); + finish(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java new file mode 100644 index 00000000..24f40bb3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; +import androidx.core.util.Consumer; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.ClearAvatarPromptActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.ArrayList; +import java.util.List; + +public class AvatarSelectionBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String ARG_OPTIONS = "options"; + private static final String ARG_REQUEST_CODE = "request_code"; + private static final String ARG_IS_GROUP = "is_group"; + + public static DialogFragment create(boolean includeClear, boolean includeCamera, short requestCode, boolean isGroup) { + DialogFragment fragment = new AvatarSelectionBottomSheetDialogFragment(); + List selectionOptions = new ArrayList<>(3); + Bundle args = new Bundle(); + + if (includeCamera) { + selectionOptions.add(SelectionOption.CAPTURE); + } + + selectionOptions.add(SelectionOption.GALLERY); + + if (includeClear) { + selectionOptions.add(SelectionOption.DELETE); + } + + String[] options = Stream.of(selectionOptions) + .map(SelectionOption::getCode) + .toArray(String[]::new); + + args.putStringArray(ARG_OPTIONS, options); + args.putShort(ARG_REQUEST_CODE, requestCode); + args.putBoolean(ARG_IS_GROUP, isGroup); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_BottomSheetDialog_Fixed + : R.style.Theme_Signal_Light_BottomSheetDialog_Fixed); + + super.onCreate(savedInstanceState); + + if (getOptionsCount() == 1) { + askForPermissionIfNeededAndLaunch(getOptionsFromArguments().get(0)); + } + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + RecyclerView recyclerView = view.findViewById(R.id.avatar_selection_bottom_sheet_dialog_fragment_recycler); + recyclerView.setAdapter(new SelectionOptionAdapter(getOptionsFromArguments(), this::askForPermissionIfNeededAndLaunch)); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @SuppressWarnings("ConstantConditions") + private int getOptionsCount() { + return requireArguments().getStringArray(ARG_OPTIONS).length; + } + + @SuppressWarnings("ConstantConditions") + private List getOptionsFromArguments() { + String[] optionCodes = requireArguments().getStringArray(ARG_OPTIONS); + + return Stream.of(optionCodes).map(SelectionOption::fromCode).toList(); + } + + private void askForPermissionIfNeededAndLaunch(@NonNull SelectionOption option) { + if (option == SelectionOption.CAPTURE) { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .onAllGranted(() -> launchOptionAndDismiss(option)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT) + .show()) + .execute(); + } else if (option == SelectionOption.GALLERY) { + Permissions.with(this) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> launchOptionAndDismiss(option)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT) + .show()) + .execute(); + } else { + launchOptionAndDismiss(option); + } + } + + private void launchOptionAndDismiss(@NonNull SelectionOption option) { + Intent intent = createIntent(requireContext(), option, requireArguments().getBoolean(ARG_IS_GROUP)); + + int requestCode = requireArguments().getShort(ARG_REQUEST_CODE); + if (getParentFragment() != null) { + requireParentFragment().startActivityForResult(intent, requestCode); + } else { + requireActivity().startActivityForResult(intent, requestCode); + } + + dismiss(); + } + + private static Intent createIntent(@NonNull Context context, @NonNull SelectionOption selectionOption, boolean isGroup) { + switch (selectionOption) { + case CAPTURE: + return AvatarSelectionActivity.getIntentForCameraCapture(context); + case GALLERY: + return AvatarSelectionActivity.getIntentForGallery(context); + case DELETE: + return isGroup ? ClearAvatarPromptActivity.createForGroupProfilePhoto() + : ClearAvatarPromptActivity.createForUserProfilePhoto(); + default: + throw new IllegalStateException("Unknown option: " + selectionOption); + } + } + + private enum SelectionOption { + CAPTURE("capture", R.string.AvatarSelectionBottomSheetDialogFragment__take_photo, R.drawable.ic_camera_24), + GALLERY("gallery", R.string.AvatarSelectionBottomSheetDialogFragment__choose_from_gallery, R.drawable.ic_photo_24), + DELETE("delete", R.string.AvatarSelectionBottomSheetDialogFragment__remove_photo, R.drawable.ic_trash_24); + + private final String code; + private final @StringRes int label; + private final @DrawableRes int icon; + + SelectionOption(@NonNull String code, @StringRes int label, @DrawableRes int icon) { + this.code = code; + this.label = label; + this.icon = icon; + } + + public @NonNull String getCode() { + return code; + } + + static SelectionOption fromCode(@NonNull String code) { + for (SelectionOption option : values()) { + if (option.code.equals(code)) { + return option; + } + } + + throw new IllegalStateException("Unknown option: " + code); + } + } + + private static class SelectionOptionViewHolder extends RecyclerView.ViewHolder { + + private final AppCompatTextView optionView; + + SelectionOptionViewHolder(@NonNull View itemView, @NonNull Consumer onClick) { + super(itemView); + itemView.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + onClick.accept(getAdapterPosition()); + } + }); + + optionView = (AppCompatTextView) itemView; + } + + void bind(@NonNull SelectionOption selectionOption) { + optionView.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(optionView.getContext(), selectionOption.icon), null, null, null); + optionView.setText(selectionOption.label); + } + } + + private static class SelectionOptionAdapter extends RecyclerView.Adapter { + + private final List options; + private final Consumer onOptionClicked; + + private SelectionOptionAdapter(@NonNull List options, @NonNull Consumer onOptionClicked) { + this.options = options; + this.onOptionClicked = onOptionClicked; + } + + @NonNull + @Override + public SelectionOptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment_option, parent, false); + return new SelectionOptionViewHolder(view, (position) -> onOptionClicked.accept(options.get(position))); + } + + @Override + public void onBindViewHolder(@NonNull SelectionOptionViewHolder holder, int position) { + holder.bind(options.get(position)); + } + + @Override + public int getItemCount() { + return options.size(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java new file mode 100644 index 00000000..193a0874 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java @@ -0,0 +1,261 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.view.Surface; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +class Camera1Controller { + + private static final String TAG = Camera1Controller.class.getSimpleName(); + + private final int screenWidth; + private final int screenHeight; + private final OrderEnforcer enforcer; + private final EventListener eventListener; + + private Camera camera; + private int cameraId; + private SurfaceTexture previewSurface; + private int screenRotation; + + Camera1Controller(int preferredDirection, int screenWidth, int screenHeight, @NonNull EventListener eventListener) { + this.eventListener = eventListener; + this.enforcer = new OrderEnforcer<>(Stage.INITIALIZED, Stage.PREVIEW_STARTED); + this.cameraId = Camera.getNumberOfCameras() > 1 ? preferredDirection : Camera.CameraInfo.CAMERA_FACING_BACK; + this.screenWidth = screenWidth; + this.screenHeight = screenHeight; + } + + void initialize() { + Log.d(TAG, "initialize()"); + + if (Camera.getNumberOfCameras() <= 0) { + Log.w(TAG, "Device doesn't have any cameras."); + onCameraUnavailable(); + return; + } + + try { + camera = Camera.open(cameraId); + } catch (Exception e) { + Log.w(TAG, "Failed to open camera.", e); + onCameraUnavailable(); + return; + } + + if (camera == null) { + Log.w(TAG, "Null camera instance."); + onCameraUnavailable(); + return; + } + + Camera.Parameters params = camera.getParameters(); + Camera.Size previewSize = getClosestSize(camera.getParameters().getSupportedPreviewSizes(), screenWidth, screenHeight); + Camera.Size pictureSize = getClosestSize(camera.getParameters().getSupportedPictureSizes(), screenWidth, screenHeight); + final List focusModes = params.getSupportedFocusModes(); + + Log.d(TAG, "Preview size: " + previewSize.width + "x" + previewSize.height + " Picture size: " + pictureSize.width + "x" + pictureSize.height); + + params.setPreviewSize(previewSize.width, previewSize.height); + params.setPictureSize(pictureSize.width, pictureSize.height); + params.setFlashMode(Camera.Parameters.FLASH_MODE_OFF); + params.setColorEffect(Camera.Parameters.EFFECT_NONE); + params.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO); + + if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } else if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { + params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); + } + + + camera.setParameters(params); + + enforcer.markCompleted(Stage.INITIALIZED); + + eventListener.onPropertiesAvailable(getProperties()); + } + + void release() { + Log.d(TAG, "release() called"); + enforcer.run(Stage.INITIALIZED, () -> { + Log.d(TAG, "release() executing"); + previewSurface = null; + camera.stopPreview(); + camera.release(); + enforcer.reset(); + }); + } + + void linkSurface(@NonNull SurfaceTexture surfaceTexture) { + Log.d(TAG, "linkSurface() called"); + enforcer.run(Stage.INITIALIZED, () -> { + try { + Log.d(TAG, "linkSurface() executing"); + previewSurface = surfaceTexture; + + camera.setPreviewTexture(surfaceTexture); + camera.startPreview(); + enforcer.markCompleted(Stage.PREVIEW_STARTED); + } catch (Exception e) { + Log.w(TAG, "Failed to start preview.", e); + eventListener.onCameraUnavailable(); + } + }); + } + + void capture(@NonNull CaptureCallback callback) { + enforcer.run(Stage.PREVIEW_STARTED, () -> { + camera.takePicture(null, null, null, (data, camera) -> { + callback.onCaptureAvailable(data, cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT); + }); + }); + } + + int flip() { + Log.d(TAG, "flip()"); + SurfaceTexture surfaceTexture = previewSurface; + cameraId = (cameraId == Camera.CameraInfo.CAMERA_FACING_BACK) ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK; + + release(); + initialize(); + linkSurface(surfaceTexture); + setScreenRotation(screenRotation); + + return cameraId; + } + + void setScreenRotation(int screenRotation) { + Log.d(TAG, "setScreenRotation(" + screenRotation + ") called"); + enforcer.run(Stage.PREVIEW_STARTED, () -> { + Log.d(TAG, "setScreenRotation(" + screenRotation + ") executing"); + this.screenRotation = screenRotation; + + int previewRotation = getPreviewRotation(screenRotation); + int outputRotation = getOutputRotation(screenRotation); + + Log.d(TAG, "Preview rotation: " + previewRotation + " Output rotation: " + outputRotation); + + camera.setDisplayOrientation(previewRotation); + + Camera.Parameters params = camera.getParameters(); + params.setRotation(outputRotation); + camera.setParameters(params); + }); + } + + private void onCameraUnavailable() { + enforcer.reset(); + eventListener.onCameraUnavailable(); + } + + private Properties getProperties() { + Camera.Size previewSize = camera.getParameters().getPreviewSize(); + return new Properties(Camera.getNumberOfCameras(), previewSize.width, previewSize.height); + } + + private Camera.Size getClosestSize(List sizes, int width, int height) { + Collections.sort(sizes, ASC_SIZE_COMPARATOR); + + int i = 0; + while (i < sizes.size() && (sizes.get(i).width * sizes.get(i).height) < (width * height)) { + i++; + } + i++; + + return sizes.get(Math.min(i, sizes.size() - 1)); + } + + private int getOutputRotation(int displayRotationCode) { + int degrees = convertRotationToDegrees(displayRotationCode); + + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(cameraId, info); + + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + return (info.orientation + degrees) % 360; + } else { + return (info.orientation - degrees + 360) % 360; + } + } + + private int getPreviewRotation(int displayRotationCode) { + int degrees = convertRotationToDegrees(displayRotationCode); + + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(cameraId, info); + + int result; + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + result = (info.orientation + degrees) % 360; + result = (360 - result) % 360; + } else { + result = (info.orientation - degrees + 360) % 360; + } + + return result; + } + + private int convertRotationToDegrees(int screenRotation) { + switch (screenRotation) { + case Surface.ROTATION_0: return 0; + case Surface.ROTATION_90: return 90; + case Surface.ROTATION_180: return 180; + case Surface.ROTATION_270: return 270; + } + return 0; + } + + private final Comparator ASC_SIZE_COMPARATOR = (o1, o2) -> Integer.compare(o1.width * o1.height, o2.width * o2.height); + + private enum Stage { + INITIALIZED, PREVIEW_STARTED + } + + class Properties { + + private final int cameraCount; + private final int previewWidth; + private final int previewHeight; + + Properties(int cameraCount, int previewWidth, int previewHeight) { + this.cameraCount = cameraCount; + this.previewWidth = previewWidth; + this.previewHeight = previewHeight; + } + + int getCameraCount() { + return cameraCount; + } + + int getPreviewWidth() { + return previewWidth; + } + + int getPreviewHeight() { + return previewHeight; + } + + @Override + public @NonNull String toString() { + return "cameraCount: " + cameraCount + " previewWidth: " + previewWidth + " previewHeight: " + previewHeight; + } + } + + interface EventListener { + void onPropertiesAvailable(@NonNull Properties properties); + void onCameraUnavailable(); + } + + interface CaptureCallback { + void onCaptureAvailable(@NonNull byte[] jpegData, boolean frontFacing); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java new file mode 100644 index 00000000..e3d49c29 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -0,0 +1,354 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.SuppressLint; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.SurfaceTexture; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.Display; +import android.view.GestureDetector; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.RotateAnimation; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProviders; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.MultiTransformation; +import com.bumptech.glide.load.Transformation; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.request.target.SimpleTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.ByteArrayOutputStream; + +/** + * Camera capture implemented with the legacy camera API's. Should only be used if sdk < 21. + */ +public class Camera1Fragment extends LoggingFragment implements CameraFragment, + TextureView.SurfaceTextureListener, + Camera1Controller.EventListener +{ + + private static final String TAG = Camera1Fragment.class.getSimpleName(); + + private TextureView cameraPreview; + private ViewGroup controlsContainer; + private ImageButton flipButton; + private View captureButton; + private Camera1Controller camera; + private Controller controller; + private OrderEnforcer orderEnforcer; + private Camera1Controller.Properties properties; + private MediaSendViewModel viewModel; + + public static Camera1Fragment newInstance() { + return new Camera1Fragment(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement the Controller interface."); + } + + WindowManager windowManager = ServiceUtil.getWindowManager(getActivity()); + Display display = windowManager.getDefaultDisplay(); + Point displaySize = new Point(); + + display.getSize(displaySize); + + controller = (Controller) getActivity(); + camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this); + orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.camera_fragment, container, false); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + cameraPreview = view.findViewById(R.id.camera_preview); + controlsContainer = view.findViewById(R.id.camera_controls_container); + + onOrientationChanged(getResources().getConfiguration().orientation); + + cameraPreview.setSurfaceTextureListener(this); + + GestureDetector gestureDetector = new GestureDetector(flipGestureListener); + cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); + + viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail); + viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.onCameraStarted(); + camera.initialize(); + + if (cameraPreview.isAvailable()) { + orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); + } + + if (properties != null) { + orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); + } + + orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> { + camera.linkSurface(cameraPreview.getSurfaceTexture()); + camera.setScreenRotation(controller.getDisplayRotation()); + }); + + orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); + } + + @Override + public void onPause() { + super.onPause(); + camera.release(); + orderEnforcer.reset(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onOrientationChanged(newConfig.orientation); + } + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + Log.d(TAG, "onSurfaceTextureAvailable"); + orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> camera.setScreenRotation(controller.getDisplayRotation())); + orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + return false; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + } + + @Override + public void onPropertiesAvailable(@NonNull Camera1Controller.Properties properties) { + Log.d(TAG, "Got camera properties: " + properties); + this.properties = properties; + orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); + orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); + } + + @Override + public void onCameraUnavailable() { + controller.onCameraError(); + } + + private void presentRecentItemThumbnail(Optional media) { + if (media == null) { + return; + } + + ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button); + + if (media.isPresent()) { + thumbnail.setVisibility(View.VISIBLE); + Glide.with(this) + .load(new DecryptableUri(media.get().getUri())) + .centerCrop() + .into(thumbnail); + } else { + thumbnail.setVisibility(View.GONE); + thumbnail.setImageResource(0); + } + } + + private void presentHud(@Nullable MediaSendViewModel.HudState state) { + if (state == null) return; + + View countButton = controlsContainer.findViewById(R.id.camera_count_button); + TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text); + + if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) { + countButton.setVisibility(View.VISIBLE); + countButtonText.setText(String.valueOf(state.getSelectionCount())); + } else { + countButton.setVisibility(View.GONE); + } + } + + private void initControls() { + flipButton = requireView().findViewById(R.id.camera_flip_button); + captureButton = requireView().findViewById(R.id.camera_capture_button); + + View galleryButton = requireView().findViewById(R.id.camera_gallery_button); + View countButton = requireView().findViewById(R.id.camera_count_button); + + captureButton.setOnClickListener(v -> { + captureButton.setEnabled(false); + onCaptureClicked(); + }); + + orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, () -> { + if (properties.getCameraCount() > 1) { + flipButton.setVisibility(properties.getCameraCount() > 1 ? View.VISIBLE : View.GONE); + flipButton.setOnClickListener(v -> { + int newCameraId = camera.flip(); + TextSecurePreferences.setDirectCaptureCameraId(getContext(), newCameraId); + + Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); + animation.setDuration(200); + animation.setInterpolator(new DecelerateInterpolator()); + flipButton.startAnimation(animation); + }); + } else { + flipButton.setVisibility(View.GONE); + } + }); + + galleryButton.setOnClickListener(v -> controller.onGalleryClicked()); + countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked()); + + viewModel.onCameraControlsInitialized(); + } + + private void onCaptureClicked() { + orderEnforcer.reset(); + + Stopwatch fastCaptureTimer = new Stopwatch("Capture"); + + camera.capture((jpegData, frontFacing) -> { + fastCaptureTimer.split("captured"); + + Transformation transformation = frontFacing ? new MultiTransformation<>(new CenterCrop(), new FlipTransformation()) + : new CenterCrop(); + + GlideApp.with(this) + .asBitmap() + .load(jpegData) + .transform(transformation) + .override(cameraPreview.getWidth(), cameraPreview.getHeight()) + .into(new SimpleTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + fastCaptureTimer.split("transform"); + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + resource.compress(Bitmap.CompressFormat.JPEG, 80, stream); + fastCaptureTimer.split("compressed"); + + byte[] data = stream.toByteArray(); + fastCaptureTimer.split("bytes"); + fastCaptureTimer.stop(TAG); + + controller.onImageCaptured(data, resource.getWidth(), resource.getHeight()); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + controller.onCameraError(); + } + }); + }); + } + + private PointF getScaleTransform(float viewWidth, float viewHeight, int cameraWidth, int cameraHeight) { + float camWidth = isPortrait() ? Math.min(cameraWidth, cameraHeight) : Math.max(cameraWidth, cameraHeight); + float camHeight = isPortrait() ? Math.max(cameraWidth, cameraHeight) : Math.min(cameraWidth, cameraHeight); + + float scaleX = 1; + float scaleY = 1; + + if ((camWidth / viewWidth) > (camHeight / viewHeight)) { + float targetWidth = viewHeight * (camWidth / camHeight); + scaleX = targetWidth / viewWidth; + } else { + float targetHeight = viewWidth * (camHeight / camWidth); + scaleY = targetHeight / viewHeight; + } + + return new PointF(scaleX, scaleY); + } + + private void onOrientationChanged(int orientation) { + int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait + : R.layout.camera_controls_landscape; + + controlsContainer.removeAllViews(); + controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); + initControls(); + } + + private void updatePreviewScale() { + PointF scale = getScaleTransform(cameraPreview.getWidth(), cameraPreview.getHeight(), properties.getPreviewWidth(), properties.getPreviewHeight()); + Matrix matrix = new Matrix(); + + float camWidth = isPortrait() ? Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()); + float camHeight = isPortrait() ? Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()); + + matrix.setScale(scale.x, scale.y); + matrix.postTranslate((camWidth - (camWidth * scale.x)) / 2, (camHeight - (camHeight * scale.y)) / 2); + cameraPreview.setTransform(matrix); + } + + private boolean isPortrait() { + return getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; + } + + private final GestureDetector.OnGestureListener flipGestureListener = new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + flipButton.performClick(); + return true; + } + }; + + private enum Stage { + SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java new file mode 100644 index 00000000..c22a868f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java @@ -0,0 +1,302 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +public class CameraButtonView extends View { + + private enum CameraButtonMode { IMAGE, MIXED } + + private static final float CAPTURE_ARC_STROKE_WIDTH = 6f; + private static final float HALF_CAPTURE_ARC_STROKE_WIDTH = CAPTURE_ARC_STROKE_WIDTH / 2; + private static final float PROGRESS_ARC_STROKE_WIDTH = 12f; + private static final float HALF_PROGRESS_ARC_STROKE_WIDTH = PROGRESS_ARC_STROKE_WIDTH / 2; + private static final float MINIMUM_ALLOWED_ZOOM_STEP = 0.005f; + private static final float DEADZONE_REDUCTION_PERCENT = 0.35f; + private static final int DRAG_DISTANCE_MULTIPLIER = 3; + private static final Interpolator ZOOM_INTERPOLATOR = new DecelerateInterpolator(); + + private final @NonNull Paint outlinePaint = outlinePaint(); + private final @NonNull Paint backgroundPaint = backgroundPaint(); + private final @NonNull Paint arcPaint = arcPaint(); + private final @NonNull Paint recordPaint = recordPaint(); + private final @NonNull Paint progressPaint = progressPaint(); + + private Animation growAnimation; + private Animation shrinkAnimation; + + private boolean isRecordingVideo; + private float progressPercent = 0f; + + private @NonNull CameraButtonMode cameraButtonMode = CameraButtonMode.IMAGE; + private @Nullable VideoCaptureListener videoCaptureListener; + + private final float imageCaptureSize; + private final float recordSize; + private final RectF progressRect = new RectF(); + private final Rect deadzoneRect = new Rect(); + + private final @NonNull OnLongClickListener internalLongClickListener = v -> { + notifyVideoCaptureStarted(); + shrinkAnimation.cancel(); + setScaleX(1f); + setScaleY(1f); + isRecordingVideo = true; + return true; + }; + + public CameraButtonView(@NonNull Context context) { + this(context, null); + } + + public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CameraButtonView, defStyleAttr, 0); + + imageCaptureSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_imageCaptureSize, -1); + recordSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_recordSize, -1); + a.recycle(); + + initializeImageAnimations(); + } + + private static Paint recordPaint() { + Paint recordPaint = new Paint(); + recordPaint.setColor(0xFFF44336); + recordPaint.setAntiAlias(true); + recordPaint.setStyle(Paint.Style.FILL); + return recordPaint; + } + + private static Paint outlinePaint() { + Paint outlinePaint = new Paint(); + outlinePaint.setColor(0x26000000); + outlinePaint.setAntiAlias(true); + outlinePaint.setStyle(Paint.Style.STROKE); + outlinePaint.setStrokeWidth(1.5f); + return outlinePaint; + } + + private static Paint backgroundPaint() { + Paint backgroundPaint = new Paint(); + backgroundPaint.setColor(0x4CFFFFFF); + backgroundPaint.setAntiAlias(true); + backgroundPaint.setStyle(Paint.Style.FILL); + return backgroundPaint; + } + + private static Paint arcPaint() { + Paint arcPaint = new Paint(); + arcPaint.setColor(0xFFFFFFFF); + arcPaint.setAntiAlias(true); + arcPaint.setStyle(Paint.Style.STROKE); + arcPaint.setStrokeWidth(CAPTURE_ARC_STROKE_WIDTH); + return arcPaint; + } + + private static Paint progressPaint() { + Paint progressPaint = new Paint(); + progressPaint.setColor(0xFFFFFFFF); + progressPaint.setAntiAlias(true); + progressPaint.setStyle(Paint.Style.STROKE); + progressPaint.setStrokeWidth(PROGRESS_ARC_STROKE_WIDTH); + progressPaint.setShadowLayer(4, 0, 2, 0x40000000); + return progressPaint; + } + + private void initializeImageAnimations() { + shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink); + growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow); + + shrinkAnimation.setFillAfter(true); + shrinkAnimation.setFillEnabled(true); + growAnimation.setFillAfter(true); + growAnimation.setFillEnabled(true); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (isRecordingVideo) { + drawForVideoCapture(canvas); + } else { + drawForImageCapture(canvas); + } + } + + private void drawForImageCapture(Canvas canvas) { + float centerX = getWidth() / 2f; + float centerY = getHeight() / 2f; + + float radius = imageCaptureSize / 2f; + canvas.drawCircle(centerX, centerY, radius, backgroundPaint); + canvas.drawCircle(centerX, centerY, radius, outlinePaint); + canvas.drawCircle(centerX, centerY, radius - HALF_CAPTURE_ARC_STROKE_WIDTH, arcPaint); + } + + private void drawForVideoCapture(Canvas canvas) { + float centerX = getWidth() / 2f; + float centerY = getHeight() / 2f; + + canvas.drawCircle(centerX, centerY, centerY, backgroundPaint); + canvas.drawCircle(centerX, centerY, centerY, outlinePaint); + + canvas.drawCircle(centerX, centerY, recordSize / 2f, recordPaint); + + progressRect.top = HALF_PROGRESS_ARC_STROKE_WIDTH; + progressRect.left = HALF_PROGRESS_ARC_STROKE_WIDTH; + progressRect.right = getWidth() - HALF_PROGRESS_ARC_STROKE_WIDTH; + progressRect.bottom = getHeight() - HALF_PROGRESS_ARC_STROKE_WIDTH; + + canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint); + } + + @Override + public void setOnLongClickListener(@Nullable OnLongClickListener listener) { + throw new IllegalStateException("Use setVideoCaptureListener instead"); + } + + public void setVideoCaptureListener(@Nullable VideoCaptureListener videoCaptureListener) { + if (isRecordingVideo) throw new IllegalStateException("Cannot set video capture listener while recording"); + + if (videoCaptureListener != null) { + this.cameraButtonMode = CameraButtonMode.MIXED; + this.videoCaptureListener = videoCaptureListener; + super.setOnLongClickListener(internalLongClickListener); + } else { + this.cameraButtonMode = CameraButtonMode.IMAGE; + this.videoCaptureListener = null; + super.setOnLongClickListener(null); + } + } + + public void setProgress(float percentage) { + progressPercent = Util.clamp(percentage, 0f, 1f); + invalidate(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (cameraButtonMode == CameraButtonMode.IMAGE) { + return handleImageModeTouchEvent(event); + } + + boolean eventWasHandled = handleVideoModeTouchEvent(event); + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + isRecordingVideo = false; + } + + return eventWasHandled; + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + getLocalVisibleRect(deadzoneRect); + deadzoneRect.left += (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f); + deadzoneRect.top += (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f); + deadzoneRect.right -= (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f); + deadzoneRect.bottom -= (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f); + } + + private boolean handleImageModeTouchEvent(MotionEvent event) { + int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (isEnabled()) { + startAnimation(shrinkAnimation); + performClick(); + } + return true; + case MotionEvent.ACTION_UP: + startAnimation(growAnimation); + return true; + default: + return super.onTouchEvent(event); + } + } + + private boolean handleVideoModeTouchEvent(MotionEvent event) { + int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (isEnabled()) { + startAnimation(shrinkAnimation); + } + case MotionEvent.ACTION_MOVE: + if (isRecordingVideo && eventIsNotInsideDeadzone(event)) { + + float maxRange = getHeight() * DRAG_DISTANCE_MULTIPLIER; + float deltaY = Math.abs(event.getY() - deadzoneRect.top); + float increment = Math.min(1f, deltaY / maxRange); + + notifyZoomPercent(ZOOM_INTERPOLATOR.getInterpolation(increment)); + invalidate(); + } + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (!isRecordingVideo) { + startAnimation(growAnimation); + } + notifyVideoCaptureEnded(); + break; + } + + return super.onTouchEvent(event); + } + + private boolean eventIsNotInsideDeadzone(MotionEvent event) { + return Math.round(event.getY()) < deadzoneRect.top; + } + + private void notifyVideoCaptureStarted() { + if (!isRecordingVideo && videoCaptureListener != null) { + videoCaptureListener.onVideoCaptureStarted(); + } + } + + private void notifyVideoCaptureEnded() { + if (isRecordingVideo && videoCaptureListener != null) { + videoCaptureListener.onVideoCaptureComplete(); + } + } + + private void notifyZoomPercent(float percent) { + if (isRecordingVideo && videoCaptureListener != null) { + videoCaptureListener.onZoomIncremented(percent); + } + } + + interface VideoCaptureListener { + void onVideoCaptureStarted(); + void onVideoCaptureComplete(); + void onZoomIncremented(float percent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java new file mode 100644 index 00000000..7f78f0df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java @@ -0,0 +1,252 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.adapter.SectionedRecyclerViewAdapter; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +class CameraContactAdapter extends SectionedRecyclerViewAdapter { + + private static final int TYPE_INVITE = 1337; + private static final long ID_INVITE = Long.MAX_VALUE; + + private static final String TAG_RECENT = "recent"; + private static final String TAG_ALL = "all"; + private static final String TAG_GROUPS = "groups"; + + private final GlideRequests glideRequests; + private final Set selected; + private final CameraContactListener cameraContactListener; + + + private final List sections = new ArrayList(3) {{ + ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, Collections.emptyList(), 0); + ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, Collections.emptyList(), recentContacts.size()); + ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, Collections.emptyList(), recentContacts.size() + allContacts.size()); + + add(recentContacts); + add(allContacts); + add(groups); + }}; + + CameraContactAdapter(@NonNull GlideRequests glideRequests, @NonNull CameraContactListener listener) { + this.glideRequests = glideRequests; + this.selected = new HashSet<>(); + this.cameraContactListener = listener; + } + + @Override + protected @NonNull List getSections() { + return sections; + } + + @Override + public long getItemId(int globalPosition) { + if (isInvitePosition(globalPosition)) { + return ID_INVITE; + } else { + return super.getItemId(globalPosition); + } + } + + @Override + public int getItemViewType(int globalPosition) { + if (isInvitePosition(globalPosition)) { + return TYPE_INVITE; + } else { + return super.getItemViewType(globalPosition); + } + } + + @Override + public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + if (viewType == TYPE_INVITE) { + return new InviteViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.camera_contact_invite_item, viewGroup, false)); + } else { + return super.onCreateViewHolder(viewGroup, viewType); + } + } + + @Override + protected @NonNull RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_header_item, parent, false)); + } + + @Override + protected @NonNull RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent) { + return new ContactViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_contact_item, parent, false)); + } + + @Override + protected @Nullable RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup) { + return null; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) { + if (isInvitePosition(globalPosition)) { + ((InviteViewHolder) holder).bind(cameraContactListener); + } else { + super.onBindViewHolder(holder, globalPosition); + } + } + + @Override + protected void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull ContactSection section, int localPosition) { + section.bind(holder, localPosition, selected, glideRequests, cameraContactListener); + } + + @Override + public int getItemCount() { + return super.getItemCount() + 1; + } + + public void setContacts(@NonNull CameraContacts contacts, @NonNull Collection selected) { + ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, contacts.getRecents(), 0); + ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, contacts.getContacts(), recentContacts.size()); + ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, contacts.getGroups(), recentContacts.size() + allContacts.size()); + + sections.clear(); + sections.add(recentContacts); + sections.add(allContacts); + sections.add(groups); + + this.selected.clear(); + this.selected.addAll(selected); + + notifyDataSetChanged(); + } + + private boolean isInvitePosition(int globalPosition) { + return globalPosition == getItemCount() - 1; + } + + public static class ContactSection extends SectionedRecyclerViewAdapter.Section { + + private final String tag; + private final int titleResId; + private final List recipients; + + public ContactSection(@NonNull String tag, @StringRes int titleResId, @NonNull List recipients, int offset) { + super(offset); + this.tag = tag; + this.titleResId = titleResId; + this.recipients = recipients; + } + + @Override + public boolean hasEmptyState() { + return false; + } + + @Override + public int getContentSize() { + return recipients.size(); + } + + @Override + public long getItemId(@NonNull StableIdGenerator idGenerator, int globalPosition) { + int localPosition = getLocalPosition(globalPosition); + + if (localPosition == 0) { + return idGenerator.getId(tag); + } else { + return idGenerator.getId(recipients.get(localPosition - 1).getId().serialize()); + } + } + + void bind(@NonNull RecyclerView.ViewHolder viewHolder, + int localPosition, + @NonNull Set selected, + @NonNull GlideRequests glideRequests, + @NonNull CameraContactListener cameraContactListener) + { + if (localPosition == 0) { + ((HeaderViewHolder) viewHolder).bind(titleResId); + } else { + Recipient recipient = recipients.get(localPosition - 1); + ((ContactViewHolder) viewHolder).bind(recipient, selected.contains(recipient), glideRequests, cameraContactListener); + } + } + } + + private static class HeaderViewHolder extends RecyclerView.ViewHolder { + + private final TextView title; + + HeaderViewHolder(@NonNull View itemView) { + super(itemView); + this.title = itemView.findViewById(R.id.camera_contact_header); + } + + void bind(@StringRes int titleResId) { + this.title.setText(titleResId); + } + } + + private static class ContactViewHolder extends RecyclerView.ViewHolder { + + private final AvatarImageView avatar; + private final FromTextView name; + private final CheckBox checkbox; + + ContactViewHolder(@NonNull View itemView) { + super(itemView); + + this.avatar = itemView.findViewById(R.id.camera_contact_item_avatar); + this.name = itemView.findViewById(R.id.camera_contact_item_name); + this.checkbox = itemView.findViewById(R.id.camera_contact_item_checkbox); + } + + void bind(@NonNull Recipient recipient, + boolean selected, + @NonNull GlideRequests glideRequests, + @NonNull CameraContactListener listener) + { + avatar.setAvatar(glideRequests, recipient, false); + name.setText(recipient); + itemView.setOnClickListener(v -> listener.onContactClicked(recipient)); + checkbox.setChecked(selected); + } + } + + private static class InviteViewHolder extends RecyclerView.ViewHolder { + + private final View inviteButton; + + public InviteViewHolder(@NonNull View itemView) { + super(itemView); + inviteButton = itemView.findViewById(R.id.camera_contact_invite); + } + + void bind(@NonNull CameraContactListener listener) { + inviteButton.setOnClickListener(v -> listener.onInviteContactsClicked()); + } + } + + interface CameraContactListener { + void onContactClicked(@NonNull Recipient recipient); + void onInviteContactsClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java new file mode 100644 index 00000000..752574f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.List; + +class CameraContactSelectionAdapter extends RecyclerView.Adapter { + + private final List recipients = new ArrayList<>(); + private final StableIdGenerator idGenerator = new StableIdGenerator<>(); + + CameraContactSelectionAdapter() { + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return idGenerator.getId(recipients.get(position).getId().serialize()); + } + + @Override + public @NonNull RecipientViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_selection_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull RecipientViewHolder holder, int position) { + holder.bind(recipients.get(position), position == recipients.size() - 1); + } + + @Override + public int getItemCount() { + return recipients.size(); + } + + void setRecipients(@NonNull List recipients) { + this.recipients.clear(); + this.recipients.addAll(recipients); + notifyDataSetChanged(); + } + + static class RecipientViewHolder extends RecyclerView.ViewHolder { + + private final FromTextView name; + + RecipientViewHolder(View itemView) { + super(itemView); + name = (FromTextView) itemView; + } + + void bind(@NonNull Recipient recipient, boolean isLast) { + name.setText(recipient, true, isLast ? null : ","); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java new file mode 100644 index 00000000..97d20554 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java @@ -0,0 +1,198 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.Group; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.InviteActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.List; + +/** + * Fragment that selects Signal contacts. Intended to be used in the camera-first capture flow. + */ +public class CameraContactSelectionFragment extends LoggingFragment implements CameraContactAdapter.CameraContactListener { + + private Controller controller; + private MediaSendViewModel mediaSendViewModel; + private CameraContactSelectionViewModel contactViewModel; + private RecyclerView contactList; + private CameraContactAdapter contactAdapter; + private RecyclerView selectionList; + private CameraContactSelectionAdapter selectionAdapter; + private Toolbar toolbar; + private View sendButton; + private Group selectionFooterGroup; + private ViewGroup cameraContactsEmpty; + private View inviteButton; + + public static Fragment newInstance() { + return new CameraContactSelectionFragment(); + } + + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + this.mediaSendViewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + this.contactViewModel = ViewModelProviders.of(requireActivity(), new CameraContactSelectionViewModel.Factory(new CameraContactsRepository(requireContext()))) + .get(CameraContactSelectionViewModel.class); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller interface."); + } + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + int theme = DynamicTheme.isDarkTheme(inflater.getContext()) ? R.style.TextSecure_DarkTheme + : R.style.TextSecure_LightTheme; + return ThemeUtil.getThemedInflater(inflater.getContext(), inflater, theme) + .inflate(R.layout.camera_contact_selection_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.contactList = view.findViewById(R.id.camera_contacts_list); + this.selectionList = view.findViewById(R.id.camera_contacts_selected_list); + this.toolbar = view.findViewById(R.id.camera_contacts_toolbar); + this.sendButton = view.findViewById(R.id.camera_contacts_send_button); + this.selectionFooterGroup = view.findViewById(R.id.camera_contacts_footer_group); + this.cameraContactsEmpty = view.findViewById(R.id.camera_contacts_empty); + this.inviteButton = view.findViewById(R.id.camera_contacts_invite_button); + this.contactAdapter = new CameraContactAdapter(GlideApp.with(this), this); + this.selectionAdapter = new CameraContactSelectionAdapter(); + + contactList.setLayoutManager(new LinearLayoutManager(requireContext())); + contactList.setAdapter(contactAdapter); + + selectionList.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + selectionList.setAdapter(selectionAdapter); + + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + + inviteButton.setOnClickListener(v -> onInviteContactsClicked()); + + initViewModel(); + } + + @Override + public void onResume() { + super.onResume(); + mediaSendViewModel.onContactSelectStarted(); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + requireActivity().getMenuInflater().inflate(R.menu.camera_contacts, menu); + + MenuItem searchViewItem = menu.findItem(R.id.menu_search); + SearchView searchView = (SearchView) searchViewItem.getActionView(); + SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + contactViewModel.onQueryUpdated(query); + return true; + } + + @Override + public boolean onQueryTextChange(String query) { + contactViewModel.onQueryUpdated(query); + return true; + } + }; + + searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + searchView.setOnQueryTextListener(queryListener); + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + searchView.setOnQueryTextListener(null); + contactViewModel.onSearchClosed(); + return true; + } + }); + } + + @Override + public void onContactClicked(@NonNull Recipient recipient) { + contactViewModel.onContactClicked(recipient); + } + + @Override + public void onInviteContactsClicked() { + startActivity(new Intent(requireContext(), InviteActivity.class)); + } + + private void initViewModel() { + contactViewModel.getContacts().observe(getViewLifecycleOwner(), contactState -> { + if (contactState == null) return; + + if (contactState.getContacts().isEmpty() && TextUtils.isEmpty(contactState.getQuery())) { + cameraContactsEmpty.setVisibility(View.VISIBLE); + contactList.setVisibility(View.GONE); + selectionFooterGroup.setVisibility(View.GONE); + } else { + cameraContactsEmpty.setVisibility(View.GONE); + contactList.setVisibility(View.VISIBLE); + + sendButton.setOnClickListener(v -> controller.onCameraContactsSendClicked(contactState.getSelected())); + + contactAdapter.setContacts(contactState.getContacts(), contactState.getSelected()); + selectionAdapter.setRecipients(contactState.getSelected()); + + selectionFooterGroup.setVisibility(contactState.getSelected().isEmpty() ? View.GONE : View.VISIBLE); + } + }); + + contactViewModel.getError().observe(getViewLifecycleOwner(), error -> { + if (error == null) return; + + if (error == CameraContactSelectionViewModel.Error.MAX_SELECTION) { + String message = getString(R.string.CameraContacts_you_can_share_with_a_maximum_of_n_conversations, CameraContactSelectionViewModel.MAX_SELECTION_COUNT); + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + } + }); + } + + public interface Controller { + void onCameraContactsSendClicked(@NonNull List recipients); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java new file mode 100644 index 00000000..d181618c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.mediasend; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +class CameraContactSelectionViewModel extends ViewModel { + + static final int MAX_SELECTION_COUNT = 16; + + private final CameraContactsRepository repository; + private final MutableLiveData contacts; + private final SingleLiveEvent error; + private final Set selected; + + private String currentQuery; + + private CameraContactSelectionViewModel(@NonNull CameraContactsRepository repository) { + this.repository = repository; + this.contacts = new MutableLiveData<>(); + this.error = new SingleLiveEvent<>(); + this.selected = new LinkedHashSet<>(); + + repository.getCameraContacts(cameraContacts -> { + Util.runOnMain(() -> { + contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), currentQuery)); + }); + }); + } + + LiveData getContacts() { + return contacts; + } + + LiveData getError() { + return error; + } + + void onSearchClosed() { + onQueryUpdated(""); + } + + void onQueryUpdated(String query) { + this.currentQuery = query; + + repository.getCameraContacts(query, cameraContacts -> { + Util.runOnMain(() -> { + contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), query)); + }); + }); + } + + void onRefresh() { + repository.getCameraContacts(cameraContacts -> { + Util.runOnMain(() -> { + contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), currentQuery)); + }); + }); + } + + void onContactClicked(@NonNull Recipient recipient) { + if (selected.contains(recipient)) { + selected.remove(recipient); + } else if (selected.size() < MAX_SELECTION_COUNT) { + selected.add(recipient); + } else { + error.postValue(Error.MAX_SELECTION); + } + + ContactState currentState = contacts.getValue(); + + if (currentState != null) { + contacts.setValue(new ContactState(currentState.getContacts(), new ArrayList<>(selected), currentQuery)); + } + } + + static class ContactState { + private final CameraContacts contacts; + private final List selected; + private final String query; + + ContactState(@NonNull CameraContacts contacts, @NonNull List selected, @Nullable String query) { + this.contacts = contacts; + this.selected = selected; + this.query = query; + } + + public CameraContacts getContacts() { + return contacts; + } + + public List getSelected() { + return selected; + } + + public @Nullable String getQuery() { + return query; + } + } + + enum Error { + MAX_SELECTION + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final CameraContactsRepository repository; + + Factory(CameraContactsRepository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new CameraContactSelectionViewModel(repository)); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContacts.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContacts.java new file mode 100644 index 00000000..3e6c526a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContacts.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.mediasend; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +/** + * Represents the list of results to display in the {@link CameraContactSelectionFragment}. + */ +public class CameraContacts { + + private final List recents; + private final List contacts; + private final List groups; + + public CameraContacts(@NonNull List recents, @NonNull List contacts, @NonNull List groups) { + this.recents = recents; + this.contacts = contacts; + this.groups = groups; + } + + public @NonNull List getRecents() { + return recents; + } + + public @NonNull List getContacts() { + return contacts; + } + + public @NonNull List getGroups() { + return groups; + } + + public boolean isEmpty() { + return recents.isEmpty() && contacts.isEmpty() && groups.isEmpty(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java new file mode 100644 index 00000000..e03c27ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.ContactRepository; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +/** + * Handles retrieving the data to be shown in {@link CameraContactSelectionFragment}. + */ +class CameraContactsRepository { + + private static final String TAG = Log.tag(CameraContactsRepository.class); + + private static final int RECENT_MAX = 25; + + private final Context context; + private final ThreadDatabase threadDatabase; + private final GroupDatabase groupDatabase; + private final RecipientDatabase recipientDatabase; + private final ContactRepository contactRepository; + private final Executor serialExecutor; + private final ExecutorService parallelExecutor; + + CameraContactsRepository(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.threadDatabase = DatabaseFactory.getThreadDatabase(context); + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.contactRepository = new ContactRepository(context); + this.serialExecutor = SignalExecutors.SERIAL; + this.parallelExecutor = SignalExecutors.BOUNDED; + } + + void getCameraContacts(@NonNull Callback callback) { + getCameraContacts("", callback); + } + + void getCameraContacts(@NonNull String query, @NonNull Callback callback) { + serialExecutor.execute(() -> { + Future> recents = parallelExecutor.submit(() -> getRecents(query)); + Future> contacts = parallelExecutor.submit(() -> getContacts(query)); + Future> groups = parallelExecutor.submit(() -> getGroups(query)); + + try { + long startTime = System.currentTimeMillis(); + CameraContacts result = new CameraContacts(recents.get(), contacts.get(), groups.get()); + + Log.d(TAG, "Total time: " + (System.currentTimeMillis() - startTime) + " ms"); + + callback.onComplete(result); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, "Failed to perform queries.", e); + callback.onComplete(new CameraContacts(Collections.emptyList(), Collections.emptyList(), Collections.emptyList())); + } + }); + } + + + @WorkerThread + private @NonNull List getRecents(@NonNull String query) { + if (!TextUtils.isEmpty(query)) { + return Collections.emptyList(); + } + + List recipients = new ArrayList<>(RECENT_MAX); + + try (ThreadDatabase.Reader threadReader = threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(RECENT_MAX, false))) { + ThreadRecord threadRecord; + while ((threadRecord = threadReader.getNext()) != null) { + recipients.add(threadRecord.getRecipient().resolve()); + } + } + + return recipients; + } + + @WorkerThread + private @NonNull List getContacts(@NonNull String query) { + List recipients = new ArrayList<>(); + + try (Cursor cursor = contactRepository.querySignalContacts(query)) { + while (cursor.moveToNext()) { + RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN))); + Recipient recipient = Recipient.resolved(id); + recipients.add(recipient); + } + } + + return recipients; + } + + @WorkerThread + private @NonNull List getGroups(@NonNull String query) { + if (TextUtils.isEmpty(query)) { + return Collections.emptyList(); + } + + List recipients = new ArrayList<>(); + + try (GroupDatabase.Reader reader = groupDatabase.getGroupsFilteredByTitle(query, false, FeatureFlags.groupsV1ForcedMigration())) { + GroupDatabase.GroupRecord groupRecord; + while ((groupRecord = reader.getNext()) != null) { + RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupRecord.getId()); + recipients.add(Recipient.resolved(recipientId)); + } + } + + return recipients; + } + + interface Callback { + void onComplete(E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java new file mode 100644 index 00000000..508ac58f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; + +import java.io.FileDescriptor; + +public interface CameraFragment { + + @SuppressLint("RestrictedApi") + static Fragment newInstance() { + if (CameraXUtil.isSupported()) { + return CameraXFragment.newInstance(); + } else { + return Camera1Fragment.newInstance(); + } + } + + @SuppressLint("RestrictedApi") + static Fragment newInstanceForAvatarCapture() { + if (CameraXUtil.isSupported()) { + return CameraXFragment.newInstanceForAvatarCapture(); + } else { + return Camera1Fragment.newInstance(); + } + } + + interface Controller { + void onCameraError(); + void onImageCaptured(@NonNull byte[] data, int width, int height); + void onVideoCaptured(@NonNull FileDescriptor fd); + void onVideoCaptureError(); + void onGalleryClicked(); + int getDisplayRotation(); + void onCameraCountButtonClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java new file mode 100644 index 00000000..9ceb9131 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -0,0 +1,422 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.view.GestureDetector; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.RotateAnimation; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCaptureException; +import androidx.camera.core.ImageProxy; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.view.PreviewView; +import androidx.camera.view.SignalCameraView; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.util.Executors; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.video.VideoUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.FileDescriptor; +import java.io.IOException; + +/** + * Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be + * preferred whenever possible. + */ +@RequiresApi(21) +public class CameraXFragment extends LoggingFragment implements CameraFragment { + + private static final String TAG = Log.tag(CameraXFragment.class); + private static final String IS_VIDEO_ENABLED = "is_video_enabled"; + + private SignalCameraView camera; + private ViewGroup controlsContainer; + private Controller controller; + private MediaSendViewModel viewModel; + private View selfieFlash; + private MemoryFileDescriptor videoFileDescriptor; + + public static CameraXFragment newInstanceForAvatarCapture() { + CameraXFragment fragment = new CameraXFragment(); + Bundle args = new Bundle(); + + args.putBoolean(IS_VIDEO_ENABLED, false); + fragment.setArguments(args); + + return fragment; + } + + public static CameraXFragment newInstance() { + CameraXFragment fragment = new CameraXFragment(); + + fragment.setArguments(new Bundle()); + + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller interface."); + } + + this.controller = (Controller) getActivity(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())) + .get(MediaSendViewModel.class); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.camerax_fragment, container, false); + } + + @SuppressLint("MissingPermission") + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.camera = view.findViewById(R.id.camerax_camera); + this.controlsContainer = view.findViewById(R.id.camerax_controls_container); + + camera.bindToLifecycle(getViewLifecycleOwner()); + camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); + + onOrientationChanged(getResources().getConfiguration().orientation); + + viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail); + viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud); + } + + @Override + public void onResume() { + super.onResume(); + + camera.bindToLifecycle(getViewLifecycleOwner()); + viewModel.onCameraStarted(); + requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + closeVideoFileDescriptor(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onOrientationChanged(newConfig.orientation); + } + + private void onOrientationChanged(int orientation) { + int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait + : R.layout.camera_controls_landscape; + + controlsContainer.removeAllViews(); + controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); + initControls(); + } + + private void presentRecentItemThumbnail(Optional media) { + if (media == null) { + return; + } + + ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button); + + if (media.isPresent()) { + thumbnail.setVisibility(View.VISIBLE); + Glide.with(this) + .load(new DecryptableUri(media.get().getUri())) + .centerCrop() + .into(thumbnail); + } else { + thumbnail.setVisibility(View.GONE); + thumbnail.setImageResource(0); + } + } + + private void presentHud(@Nullable MediaSendViewModel.HudState state) { + if (state == null) return; + + View countButton = controlsContainer.findViewById(R.id.camera_count_button); + TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text); + + if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) { + countButton.setVisibility(View.VISIBLE); + countButtonText.setText(String.valueOf(state.getSelectionCount())); + } else { + countButton.setVisibility(View.GONE); + } + } + + @SuppressLint({"ClickableViewAccessibility", "MissingPermission"}) + private void initControls() { + View flipButton = requireView().findViewById(R.id.camera_flip_button); + CameraButtonView captureButton = requireView().findViewById(R.id.camera_capture_button); + View galleryButton = requireView().findViewById(R.id.camera_gallery_button); + View countButton = requireView().findViewById(R.id.camera_count_button); + CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button); + + selfieFlash = requireView().findViewById(R.id.camera_selfie_flash); + + captureButton.setOnClickListener(v -> { + captureButton.setEnabled(false); + flipButton.setEnabled(false); + flashButton.setEnabled(false); + onCaptureClicked(); + }); + + camera.setScaleType(PreviewView.ScaleType.FILL_CENTER); + + ProcessCameraProvider.getInstance(requireContext()) + .addListener(() -> initializeFlipButton(flipButton, flashButton), + Executors.mainThreadExecutor()); + + flashButton.setAutoFlashEnabled(camera.hasFlash()); + flashButton.setFlash(camera.getFlash()); + flashButton.setOnFlashModeChangedListener(camera::setFlash); + + galleryButton.setOnClickListener(v -> controller.onGalleryClicked()); + countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked()); + + if (isVideoRecordingSupported(requireContext())) { + try { + closeVideoFileDescriptor(); + videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()); + + Animation inAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in); + Animation outAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_out); + + camera.setCaptureMode(SignalCameraView.CaptureMode.MIXED); + + int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints()); + Log.d(TAG, "Max duration: " + maxDuration + " sec"); + + captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper( + this, + captureButton, + camera, + videoFileDescriptor, + maxDuration, + new CameraXVideoCaptureHelper.Callback() { + @Override + public void onVideoRecordStarted() { + hideAndDisableControlsForVideoRecording(captureButton, flashButton, flipButton, outAnimation); + } + + @Override + public void onVideoSaved(@NonNull FileDescriptor fd) { + showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); + controller.onVideoCaptured(fd); + } + + @Override + public void onVideoError(@Nullable Throwable cause) { + showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); + controller.onVideoCaptureError(); + } + } + )); + displayVideoRecordingTooltipIfNecessary(captureButton); + } catch (IOException e) { + Log.w(TAG, "Video capture is not supported on this device.", e); + } + } else { + Log.i(TAG, "Video capture not supported. " + + "API: " + Build.VERSION.SDK_INT + ", " + + "MFD: " + MemoryFileDescriptor.supported() + ", " + + "Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " + + "MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints()) + " sec"); + } + + viewModel.onCameraControlsInitialized(); + } + + private boolean isVideoRecordingSupported(@NonNull Context context) { + return Build.VERSION.SDK_INT >= 26 && + requireArguments().getBoolean(IS_VIDEO_ENABLED, true) && + MediaConstraints.isVideoTranscodeAvailable() && + CameraXUtil.isMixedModeSupported(context) && + VideoUtil.getMaxVideoRecordDurationInSeconds(context, viewModel.getMediaConstraints()) > 0; + } + + private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) { + if (shouldDisplayVideoRecordingTooltip()) { + int displayRotation = requireActivity().getWindowManager().getDefaultDisplay().getRotation(); + + TooltipPopup.forTarget(captureButton) + .setOnDismissListener(this::neverDisplayVideoRecordingTooltipAgain) + .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_toolbar_title)) + .setText(R.string.CameraXFragment_tap_for_photo_hold_for_video) + .show(displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180 ? TooltipPopup.POSITION_ABOVE : TooltipPopup.POSITION_START); + } + } + + private boolean shouldDisplayVideoRecordingTooltip() { + return !TextSecurePreferences.hasSeenVideoRecordingTooltip(requireContext()) && MediaConstraints.isVideoTranscodeAvailable(); + } + + private void neverDisplayVideoRecordingTooltipAgain() { + Context context = getContext(); + if (context != null) { + TextSecurePreferences.setHasSeenVideoRecordingTooltip(requireContext(), true); + } + } + + private void hideAndDisableControlsForVideoRecording(@NonNull View captureButton, + @NonNull View flashButton, + @NonNull View flipButton, + @NonNull Animation outAnimation) + { + captureButton.setEnabled(false); + flashButton.startAnimation(outAnimation); + flashButton.setVisibility(View.INVISIBLE); + flipButton.startAnimation(outAnimation); + flipButton.setVisibility(View.INVISIBLE); + } + + private void showAndEnableControlsAfterVideoRecording(@NonNull View captureButton, + @NonNull View flashButton, + @NonNull View flipButton, + @NonNull Animation inAnimation) + { + requireActivity().runOnUiThread(() -> { + captureButton.setEnabled(true); + flashButton.startAnimation(inAnimation); + flashButton.setVisibility(View.VISIBLE); + flipButton.startAnimation(inAnimation); + flipButton.setVisibility(View.VISIBLE); + }); + } + + private void onCaptureClicked() { + Stopwatch stopwatch = new Stopwatch("Capture"); + + CameraXSelfieFlashHelper flashHelper = new CameraXSelfieFlashHelper( + requireActivity().getWindow(), + camera, + selfieFlash + ); + + camera.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedCallback() { + @Override + public void onCaptureSuccess(@NonNull ImageProxy image) { + flashHelper.endFlash(); + + SimpleTask.run(CameraXFragment.this.getViewLifecycleOwner().getLifecycle(), () -> { + stopwatch.split("captured"); + try { + return CameraXUtil.toJpeg(image, camera.getCameraLensFacing() == CameraSelector.LENS_FACING_FRONT); + } catch (IOException e) { + return null; + } finally { + image.close(); + } + }, result -> { + stopwatch.split("transformed"); + stopwatch.stop(TAG); + + if (result != null) { + controller.onImageCaptured(result.getData(), result.getWidth(), result.getHeight()); + } else { + controller.onCameraError(); + } + }); + } + + @Override + public void onError(ImageCaptureException exception) { + flashHelper.endFlash(); + controller.onCameraError(); + } + }); + + flashHelper.startFlash(); + } + + private void closeVideoFileDescriptor() { + if (videoFileDescriptor != null) { + try { + videoFileDescriptor.close(); + videoFileDescriptor = null; + } catch (IOException e) { + Log.w(TAG, "Failed to close video file descriptor", e); + } + } + } + + @SuppressLint({"MissingPermission"}) + private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) { + if (camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT) && camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) { + flipButton.setVisibility(View.VISIBLE); + flipButton.setOnClickListener(v -> { + camera.toggleCamera(); + TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(camera.getCameraLensFacing())); + + Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); + animation.setDuration(200); + animation.setInterpolator(new DecelerateInterpolator()); + flipButton.startAnimation(animation); + flashButton.setAutoFlashEnabled(camera.hasFlash()); + flashButton.setFlash(camera.getFlash()); + }); + + GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDoubleTap(MotionEvent e) { + if (flipButton.isEnabled()) { + flipButton.performClick(); + } + return true; + } + }); + + camera.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); + + } else { + flipButton.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java new file mode 100644 index 00000000..6248002c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.view.SignalCameraView; + +@RequiresApi(21) +final class CameraXSelfieFlashHelper { + + private static final float MAX_SCREEN_BRIGHTNESS = 1f; + private static final float MAX_SELFIE_FLASH_ALPHA = 0.75f; + private static final long SELFIE_FLASH_DURATION_MS = 250; + + private final Window window; + private final SignalCameraView camera; + private final View selfieFlash; + + private float brightnessBeforeFlash; + private boolean inFlash; + + CameraXSelfieFlashHelper(@NonNull Window window, + @NonNull SignalCameraView camera, + @NonNull View selfieFlash) + { + this.window = window; + this.camera = camera; + this.selfieFlash = selfieFlash; + } + + void startFlash() { + if (inFlash || !shouldUseViewBasedFlash()) return; + inFlash = true; + + WindowManager.LayoutParams params = window.getAttributes(); + + brightnessBeforeFlash = params.screenBrightness; + params.screenBrightness = MAX_SCREEN_BRIGHTNESS; + window.setAttributes(params); + + selfieFlash.animate() + .alpha(MAX_SELFIE_FLASH_ALPHA) + .setDuration(SELFIE_FLASH_DURATION_MS); + } + + void endFlash() { + if (!inFlash) return; + + WindowManager.LayoutParams params = window.getAttributes(); + + params.screenBrightness = brightnessBeforeFlash; + window.setAttributes(params); + + selfieFlash.animate() + .alpha(0f) + .setDuration(SELFIE_FLASH_DURATION_MS); + + inFlash = false; + } + + private boolean shouldUseViewBasedFlash() { + Integer cameraLensFacing = camera.getCameraLensFacing(); + + return camera.getFlash() == ImageCapture.FLASH_MODE_ON && + !camera.hasFlash() && + cameraLensFacing != null && + cameraLensFacing == CameraSelector.LENS_FACING_FRONT; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java new file mode 100644 index 00000000..ef17a9f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java @@ -0,0 +1,230 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.DisplayMetrics; +import android.util.Size; +import android.view.ViewGroup; +import android.view.animation.LinearInterpolator; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.core.VideoCapture; +import androidx.camera.view.SignalCameraView; +import androidx.fragment.app.Fragment; + +import com.bumptech.glide.util.Executors; +import com.nineoldandroids.animation.Animator; +import com.nineoldandroids.animation.ValueAnimator; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.video.VideoUtil; + +import java.io.FileDescriptor; +import java.io.IOException; + +@RequiresApi(26) +class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener { + + private static final String TAG = CameraXVideoCaptureHelper.class.getName(); + private static final String VIDEO_DEBUG_LABEL = "video-capture"; + private static final long VIDEO_SIZE = 10 * 1024 * 1024; + + private final @NonNull Fragment fragment; + private final @NonNull SignalCameraView camera; + private final @NonNull Callback callback; + private final @NonNull MemoryFileDescriptor memoryFileDescriptor; + private final @NonNull ValueAnimator updateProgressAnimator; + + private boolean isRecording; + private ValueAnimator cameraMetricsAnimator; + + private final VideoCapture.OnVideoSavedCallback videoSavedListener = new VideoCapture.OnVideoSavedCallback() { + @Override + public void onVideoSaved(@NonNull VideoCapture.OutputFileResults outputFileResults) { + try { + isRecording = false; + camera.setZoomRatio(camera.getMinZoomRatio()); + memoryFileDescriptor.seek(0); + callback.onVideoSaved(memoryFileDescriptor.getFileDescriptor()); + } catch (IOException e) { + callback.onVideoError(e); + } + } + + @Override + public void onError(int videoCaptureError, @NonNull String message, @Nullable Throwable cause) { + isRecording = false; + callback.onVideoError(cause); + } + }; + + CameraXVideoCaptureHelper(@NonNull Fragment fragment, + @NonNull CameraButtonView captureButton, + @NonNull SignalCameraView camera, + @NonNull MemoryFileDescriptor memoryFileDescriptor, + int maxVideoDurationSec, + @NonNull Callback callback) + { + this.fragment = fragment; + this.camera = camera; + this.memoryFileDescriptor = memoryFileDescriptor; + this.callback = callback; + this.updateProgressAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(maxVideoDurationSec * 1000); + + updateProgressAnimator.setInterpolator(new LinearInterpolator()); + updateProgressAnimator.addUpdateListener(anim -> captureButton.setProgress(anim.getAnimatedFraction())); + updateProgressAnimator.addListener(new AnimationEndCallback() { + @Override + public void onAnimationEnd(Animator animation) { + if (isRecording) onVideoCaptureComplete(); + } + }); + } + + @Override + public void onVideoCaptureStarted() { + Log.d(TAG, "onVideoCaptureStarted"); + + if (canRecordAudio()) { + isRecording = true; + beginCameraRecording(); + } else { + displayAudioRecordingPermissionsDialog(); + } + } + + private boolean canRecordAudio() { + return Permissions.hasAll(fragment.requireContext(), Manifest.permission.RECORD_AUDIO); + } + + private void displayAudioRecordingPermissionsDialog() { + Permissions.with(fragment) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(fragment.getString(R.string.ConversationActivity_enable_the_microphone_permission_to_capture_videos_with_sound), R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(fragment.getString(R.string.ConversationActivity_signal_needs_the_recording_permissions_to_capture_video)) + .onAnyDenied(() -> Toast.makeText(fragment.requireContext(), R.string.ConversationActivity_signal_needs_recording_permissions_to_capture_video, Toast.LENGTH_LONG).show()) + .execute(); + } + + @SuppressLint("RestrictedApi") + private void beginCameraRecording() { + this.camera.setZoomRatio(this.camera.getMinZoomRatio()); + callback.onVideoRecordStarted(); + shrinkCaptureArea(); + + VideoCapture.OutputFileOptions options = new VideoCapture.OutputFileOptions.Builder(memoryFileDescriptor.getFileDescriptor()).build(); + + camera.startRecording(options, Executors.mainThreadExecutor(), videoSavedListener); + updateProgressAnimator.start(); + } + + private void shrinkCaptureArea() { + Size screenSize = getScreenSize(); + Size videoRecordingSize = VideoUtil.getVideoRecordingSize(); + float scale = getSurfaceScaleForRecording(); + float targetWidthForAnimation = videoRecordingSize.getWidth() * scale; + float scaleX = targetWidthForAnimation / screenSize.getWidth(); + + if (scaleX == 1f) { + float targetHeightForAnimation = videoRecordingSize.getHeight() * scale; + + if (screenSize.getHeight() == targetHeightForAnimation) { + return; + } + + cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getHeight(), targetHeightForAnimation); + } else { + + if (screenSize.getWidth() == targetWidthForAnimation) { + return; + } + + cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getWidth(), targetWidthForAnimation); + } + + ViewGroup.LayoutParams params = camera.getLayoutParams(); + cameraMetricsAnimator.setInterpolator(new LinearInterpolator()); + cameraMetricsAnimator.setDuration(200); + cameraMetricsAnimator.addUpdateListener(animation -> { + if (scaleX == 1f) { + params.height = Math.round((float) animation.getAnimatedValue()); + } else { + params.width = Math.round((float) animation.getAnimatedValue()); + } + camera.setLayoutParams(params); + }); + cameraMetricsAnimator.start(); + } + + private Size getScreenSize() { + DisplayMetrics metrics = camera.getResources().getDisplayMetrics(); + return new Size(metrics.widthPixels, metrics.heightPixels); + } + + private float getSurfaceScaleForRecording() { + Size videoRecordingSize = VideoUtil.getVideoRecordingSize(); + Size screenSize = getScreenSize(); + return Math.min(screenSize.getHeight(), screenSize.getWidth()) / (float) Math.min(videoRecordingSize.getHeight(), videoRecordingSize.getWidth()); + } + + @Override + public void onVideoCaptureComplete() { + isRecording = false; + if (!canRecordAudio()) return; + + Log.d(TAG, "onVideoCaptureComplete"); + camera.stopRecording(); + + if (cameraMetricsAnimator != null && cameraMetricsAnimator.isRunning()) { + cameraMetricsAnimator.reverse(); + } + + updateProgressAnimator.cancel(); + } + + @Override + public void onZoomIncremented(float increment) { + float range = camera.getMaxZoomRatio() - camera.getMinZoomRatio(); + camera.setZoomRatio((range * increment) + camera.getMinZoomRatio()); + } + + static MemoryFileDescriptor createFileDescriptor(@NonNull Context context) throws MemoryFileDescriptor.MemoryFileException { + return MemoryFileDescriptor.newMemoryFileDescriptor( + context, + VIDEO_DEBUG_LABEL, + VIDEO_SIZE + ); + } + + private static abstract class AnimationEndCallback implements Animator.AnimatorListener { + + @Override + public final void onAnimationStart(Animator animation) { + + } + + @Override + public final void onAnimationCancel(Animator animation) { + + } + + @Override + public final void onAnimationRepeat(Animator animation) { + + } + } + + interface Callback { + void onVideoRecordStarted(); + void onVideoSaved(@NonNull FileDescriptor fd); + void onVideoError(@Nullable Throwable cause); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/FlipTransformation.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/FlipTransformation.java new file mode 100644 index 00000000..00588f19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/FlipTransformation.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; + +import java.security.MessageDigest; + +public class FlipTransformation extends BitmapTransformation { + + @Override + protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) { + Bitmap output = pool.get(toTransform.getWidth(), toTransform.getHeight(), toTransform.getConfig()); + + Canvas canvas = new Canvas(output); + Matrix matrix = new Matrix(); + matrix.setScale(-1, 1); + matrix.postTranslate(toTransform.getWidth(), 0); + + canvas.drawBitmap(toTransform, matrix, null); + + return output; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(FlipTransformation.class.getSimpleName().getBytes()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java new file mode 100644 index 00000000..9f1b2964 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public final class ImageEditorModelRenderMediaTransform implements MediaTransform { + + private static final String TAG = Log.tag(ImageEditorModelRenderMediaTransform.class); + + @NonNull private final EditorModel modelToRender; + @Nullable private final Point size; + + ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) { + this(modelToRender, null); + } + + ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) { + this.modelToRender = modelToRender; + this.size = size; + } + + @WorkerThread + @Override + public @NonNull Media transform(@NonNull Context context, @NonNull Media media) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + Bitmap bitmap = modelToRender.render(context, size); + try { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); + + Uri uri = BlobProvider.getInstance() + .forData(outputStream.toByteArray()) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleSessionOnDisk(context); + + return new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), 0, false, media.getBucketId(), media.getCaption(), Optional.absent()); + } catch (IOException e) { + Log.w(TAG, "Failed to render image. Using base image."); + return media; + } finally { + bitmap.recycle(); + StreamUtil.close(outputStream); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/LegacyCameraModels.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/LegacyCameraModels.java new file mode 100644 index 00000000..bae9f921 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/LegacyCameraModels.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.os.Build; + +import java.util.HashSet; +import java.util.Set; + +public final class LegacyCameraModels { + private static final Set LEGACY_MODELS = new HashSet() {{ + // Pixel 4 + add("Pixel 4"); + add("Pixel 4 XL"); + + // Huawei Mate 10 + add("ALP-L29"); + add("ALP-L09"); + add("ALP-AL00"); + + // Huawei Mate 10 Pro + add("BLA-L29"); + add("BLA-L09"); + add("BLA-AL00"); + add("BLA-A09"); + + // Huawei Mate 20 + add("HMA-L29"); + add("HMA-L09"); + add("HMA-LX9"); + add("HMA-AL00"); + + // Huawei Mate 20 Pro + add("LYA-L09"); + add("LYA-L29"); + add("LYA-AL00"); + add("LYA-AL10"); + add("LYA-TL00"); + add("LYA-L0C"); + + // Huawei P20 + add("EML-L29C"); + add("EML-L09C"); + add("EML-AL00"); + add("EML-TL00"); + add("EML-L29"); + add("EML-L09"); + + // Huawei P20 Pro + add("CLT-L29C"); + add("CLT-L29"); + add("CLT-L09C"); + add("CLT-L09"); + add("CLT-AL00"); + add("CLT-AL01"); + add("CLT-TL01"); + add("CLT-AL00L"); + add("CLT-L04"); + add("HW-01K"); + + // Huawei P30 + add("ELE-L29"); + add("ELE-L09"); + add("ELE-AL00"); + add("ELE-TL00"); + add("ELE-L04"); + + // Huawei P30 Pro + add("VOG-L29"); + add("VOG-L09"); + add("VOG-AL00"); + add("VOG-TL00"); + add("VOG-L04"); + add("VOG-AL10"); + + // Huawei Honor 10 + add("COL-AL10"); + add("COL-L29"); + add("COL-L19"); + + // Samsung Galaxy S6 + add("SM-G920F"); + + // Honor View 10 + add("BLK-L09"); + }}; + + private LegacyCameraModels() { + } + + public static boolean isLegacyCameraModel() { + return LEGACY_MODELS.contains(Build.MODEL); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java new file mode 100644 index 00000000..fbe25254 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.io.IOException; + +/** + * Represents a piece of media that the user has on their device. + */ +public class Media implements Parcelable { + + public static final String ALL_MEDIA_BUCKET_ID = "org.thoughtcrime.securesms.ALL_MEDIA"; + + private final Uri uri; + private final String mimeType; + private final long date; + private final int width; + private final int height; + private final long size; + private final long duration; + private final boolean borderless; + + private Optional bucketId; + private Optional caption; + private Optional transformProperties; + + public Media(@NonNull Uri uri, + @NonNull String mimeType, + long date, + int width, + int height, + long size, + long duration, + boolean borderless, + Optional bucketId, + Optional caption, + Optional transformProperties) + { + this.uri = uri; + this.mimeType = mimeType; + this.date = date; + this.width = width; + this.height = height; + this.size = size; + this.duration = duration; + this.borderless = borderless; + this.bucketId = bucketId; + this.caption = caption; + this.transformProperties = transformProperties; + } + + protected Media(Parcel in) { + uri = in.readParcelable(Uri.class.getClassLoader()); + mimeType = in.readString(); + date = in.readLong(); + width = in.readInt(); + height = in.readInt(); + size = in.readLong(); + duration = in.readLong(); + borderless = in.readInt() == 1; + bucketId = Optional.fromNullable(in.readString()); + caption = Optional.fromNullable(in.readString()); + try { + String json = in.readString(); + transformProperties = json == null ? Optional.absent() : Optional.fromNullable(JsonUtil.fromJson(json, AttachmentDatabase.TransformProperties.class)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public Uri getUri() { + return uri; + } + + public String getMimeType() { + return mimeType; + } + + public long getDate() { + return date; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public long getSize() { + return size; + } + + public long getDuration() { + return duration; + } + + public boolean isBorderless() { + return borderless; + } + + public Optional getBucketId() { + return bucketId; + } + + public Optional getCaption() { + return caption; + } + + public void setCaption(String caption) { + this.caption = Optional.fromNullable(caption); + } + + public Optional getTransformProperties() { + return transformProperties; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(uri, flags); + dest.writeString(mimeType); + dest.writeLong(date); + dest.writeInt(width); + dest.writeInt(height); + dest.writeLong(size); + dest.writeLong(duration); + dest.writeInt(borderless ? 1 : 0); + dest.writeString(bucketId.orNull()); + dest.writeString(caption.orNull()); + dest.writeString(transformProperties.transform(JsonUtil::toJson).orNull()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Media createFromParcel(Parcel in) { + return new Media(in); + } + + @Override + public Media[] newArray(int size) { + return new Media[size]; + } + }; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Media media = (Media) o; + + return uri.equals(media.uri); + } + + @Override + public int hashCode() { + return uri.hashCode(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java new file mode 100644 index 00000000..cfa48b31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +/** + * Represents a folder that's shown in {@link MediaPickerFolderFragment}. + */ +public class MediaFolder { + + private final Uri thumbnailUri; + private final String title; + private final int itemCount; + private final String bucketId; + private final FolderType folderType; + + MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId, @NonNull FolderType folderType) { + this.thumbnailUri = thumbnailUri; + this.title = title; + this.itemCount = itemCount; + this.bucketId = bucketId; + this.folderType = folderType; + } + + Uri getThumbnailUri() { + return thumbnailUri; + } + + public String getTitle() { + return title; + } + + int getItemCount() { + return itemCount; + } + + public String getBucketId() { + return bucketId; + } + + FolderType getFolderType() { + return folderType; + } + + enum FolderType { + NORMAL, CAMERA + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java new file mode 100644 index 00000000..23ddb0aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +class MediaPickerFolderAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List folders; + + MediaPickerFolderAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.folders = new ArrayList<>(); + } + + @NonNull + @Override + public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) { + folderViewHolder.bind(folders.get(i), glideRequests, eventListener); + } + + @Override + public void onViewRecycled(@NonNull FolderViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return folders.size(); + } + + void setFolders(@NonNull List folders) { + this.folders.clear(); + this.folders.addAll(folders); + notifyDataSetChanged(); + } + + static class FolderViewHolder extends RecyclerView.ViewHolder { + + private final ImageView thumbnail; + private final ImageView icon; + private final TextView title; + private final TextView count; + + FolderViewHolder(@NonNull View itemView) { + super(itemView); + + thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail); + icon = itemView.findViewById(R.id.mediapicker_folder_item_icon); + title = itemView.findViewById(R.id.mediapicker_folder_item_title); + count = itemView.findViewById(R.id.mediapicker_folder_item_count); + } + + void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + title.setText(folder.getTitle()); + count.setText(String.valueOf(folder.getItemCount())); + icon.setImageResource(folder.getFolderType() == MediaFolder.FolderType.CAMERA ? R.drawable.ic_camera_solid_white_24 : R.drawable.ic_folder_white_48dp); + + glideRequests.load(folder.getThumbnailUri()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(thumbnail); + + itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder)); + } + + void recycle() { + itemView.setOnClickListener(null); + } + } + + interface EventListener { + void onFolderClicked(@NonNull MediaFolder mediaFolder); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java new file mode 100644 index 00000000..3d47e47d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -0,0 +1,166 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Allows the user to select a media folder to explore. + */ +public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { + + private static final String KEY_TOOLBAR_TITLE = "toolbar_title"; + private static final String KEY_HIDE_CAMERA = "hide_camera"; + + private String toolbarTitle; + private boolean showCamera; + private MediaSendViewModel viewModel; + private Controller controller; + private GridLayoutManager layoutManager; + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) { + return newInstance(context, recipient, false); + } + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient, boolean hideCamera) { + String toolbarTitle; + + if (recipient != null) { + String name = recipient.getDisplayName(context); + toolbarTitle = context.getString(R.string.MediaPickerActivity_send_to, name); + } else { + toolbarTitle = ""; + } + + return newInstance(toolbarTitle, hideCamera); + } + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull String toolbarTitle, boolean hideCamera) { + Bundle args = new Bundle(); + args.putString(KEY_TOOLBAR_TITLE, toolbarTitle); + args.putBoolean(KEY_HIDE_CAMERA, hideCamera); + + MediaPickerFolderFragment fragment = new MediaPickerFolderFragment(); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + toolbarTitle = getArguments().getString(KEY_TOOLBAR_TITLE); + showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller class."); + } + + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + RecyclerView list = view.findViewById(R.id.mediapicker_folder_list); + MediaPickerFolderAdapter adapter = new MediaPickerFolderAdapter(GlideApp.with(this), this); + + layoutManager = new GridLayoutManager(requireContext(), 2); + onScreenWidthChanged(getScreenWidth()); + + list.setLayoutManager(layoutManager); + list.setAdapter(adapter); + + viewModel.getFolders(requireContext()).observe(getViewLifecycleOwner(), adapter::setFolders); + + initToolbar(view.findViewById(R.id.mediapicker_toolbar)); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.onFolderPickerStarted(); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + if (showCamera) { + requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; } + return false; + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + private void initToolbar(Toolbar toolbar) { + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(toolbarTitle); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + } + + private void onScreenWidthChanged(int newWidth) { + if (layoutManager != null) { + layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + requireActivity().getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } + + @Override + public void onFolderClicked(@NonNull MediaFolder folder) { + controller.onFolderSelected(folder); + } + + public interface Controller { + void onFolderSelected(@NonNull MediaFolder folder); + void onCameraSelected(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java new file mode 100644 index 00000000..bcf2b802 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +public class MediaPickerItemAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List media; + private final List selected; + private final int maxSelection; + private final StableIdGenerator stableIdGenerator; + + private boolean forcedMultiSelect; + + public MediaPickerItemAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, int maxSelection) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.media = new ArrayList<>(); + this.maxSelection = maxSelection; + this.stableIdGenerator = new StableIdGenerator<>(); + this.selected = new LinkedList<>(); + + setHasStableIds(true); + } + + @Override + public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) { + holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener); + } + + @Override + public void onViewRecycled(@NonNull ItemViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return media.size(); + } + + @Override + public long getItemId(int position) { + return stableIdGenerator.getId(media.get(position)); + } + + void setMedia(@NonNull List media) { + this.media.clear(); + this.media.addAll(media); + notifyDataSetChanged(); + } + + void setSelected(@NonNull Collection selected) { + this.selected.clear(); + this.selected.addAll(selected); + notifyDataSetChanged(); + } + + List getSelected() { + return selected; + } + + void setForcedMultiSelect(boolean forcedMultiSelect) { + this.forcedMultiSelect = forcedMultiSelect; + notifyDataSetChanged(); + } + + static class ItemViewHolder extends RecyclerView.ViewHolder { + + private final ImageView thumbnail; + private final View playOverlay; + private final View selectOn; + private final View selectOff; + private final View selectOverlay; + private final TextView selectOrder; + + ItemViewHolder(@NonNull View itemView) { + super(itemView); + thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail); + playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay); + selectOn = itemView.findViewById(R.id.mediapicker_select_on); + selectOff = itemView.findViewById(R.id.mediapicker_select_off); + selectOverlay = itemView.findViewById(R.id.mediapicker_select_overlay); + selectOrder = itemView.findViewById(R.id.mediapicker_select_order); + } + + void bind(@NonNull Media media, boolean multiSelect, List selected, int maxSelection, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + glideRequests.load(media.getUri()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(thumbnail); + + playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE); + + if (selected.isEmpty() && !multiSelect) { + itemView.setOnClickListener(v -> eventListener.onMediaChosen(media)); + selectOn.setVisibility(View.GONE); + selectOff.setVisibility(View.GONE); + selectOverlay.setVisibility(View.GONE); + + if (maxSelection > 1) { + itemView.setOnLongClickListener(v -> { + selected.add(media); + eventListener.onMediaSelectionStarted(); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + return true; + }); + } + } else if (selected.contains(media)) { + selectOff.setVisibility(View.VISIBLE); + selectOn.setVisibility(View.VISIBLE); + selectOverlay.setVisibility(View.VISIBLE); + selectOrder.setText(String.valueOf(selected.indexOf(media) + 1)); + itemView.setOnLongClickListener(null); + itemView.setOnClickListener(v -> { + selected.remove(media); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + }); + } else { + selectOff.setVisibility(View.VISIBLE); + selectOn.setVisibility(View.GONE); + selectOverlay.setVisibility(View.GONE); + itemView.setOnLongClickListener(null); + itemView.setOnClickListener(v -> { + if (selected.size() < maxSelection) { + selected.add(media); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + } else { + eventListener.onMediaSelectionOverflow(maxSelection); + } + }); + } + } + + void recycle() { + itemView.setOnClickListener(null); + } + + + } + + interface EventListener { + void onMediaChosen(@NonNull Media media); + void onMediaSelectionStarted(); + void onMediaSelectionChanged(@NonNull List media); + void onMediaSelectionOverflow(int maxSelection); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java new file mode 100644 index 00000000..8e9bb06d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; + +import java.util.List; + +/** + * Allows the user to select a set of media items from a specified folder. + */ +public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { + + private static final String KEY_BUCKET_ID = "bucket_id"; + private static final String KEY_FOLDER_TITLE = "folder_title"; + private static final String KEY_MAX_SELECTION = "max_selection"; + private static final String KEY_FORCE_MULTI_SELECT = "force_multi_select"; + private static final String KEY_HIDE_CAMERA = "hide_camera"; + + private String bucketId; + private String folderTitle; + private int maxSelection; + private boolean showCamera; + private MediaSendViewModel viewModel; + private MediaPickerItemAdapter adapter; + private Controller controller; + private GridLayoutManager layoutManager; + + public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) { + return newInstance(bucketId, folderTitle, maxSelection, true); + } + + public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect) { + return newInstance(bucketId, folderTitle, maxSelection, forceMultiSelect, false); + } + + public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect, boolean hideCamera) { + Bundle args = new Bundle(); + args.putString(KEY_BUCKET_ID, bucketId); + args.putString(KEY_FOLDER_TITLE, folderTitle); + args.putInt(KEY_MAX_SELECTION, maxSelection); + args.putBoolean(KEY_FORCE_MULTI_SELECT, forceMultiSelect); + args.putBoolean(KEY_HIDE_CAMERA, hideCamera); + + MediaPickerItemFragment fragment = new MediaPickerItemFragment(); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + bucketId = getArguments().getString(KEY_BUCKET_ID); + folderTitle = getArguments().getString(KEY_FOLDER_TITLE); + maxSelection = getArguments().getInt(KEY_MAX_SELECTION); + showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller class."); + } + + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediapicker_item_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list); + + adapter = new MediaPickerItemAdapter(GlideApp.with(this), this, maxSelection); + layoutManager = new GridLayoutManager(requireContext(), 4); + + imageList.setLayoutManager(layoutManager); + imageList.setAdapter(adapter); + + initToolbar(view.findViewById(R.id.mediapicker_toolbar)); + onScreenWidthChanged(getScreenWidth()); + + viewModel.getSelectedMedia().observe(getViewLifecycleOwner(), adapter::setSelected); + viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.onItemPickerStarted(); + if (requireArguments().getBoolean(KEY_FORCE_MULTI_SELECT)) { + adapter.setForcedMultiSelect(true); + viewModel.onMultiSelectStarted(); + } + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + if (showCamera) { + requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; } + return false; + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + @Override + public void onMediaChosen(@NonNull Media media) { + controller.onMediaSelected(media); + } + + @Override + public void onMediaSelectionStarted() { + viewModel.onMultiSelectStarted(); + } + + @Override + public void onMediaSelectionChanged(@NonNull List selected) { + adapter.notifyDataSetChanged(); + viewModel.onSelectedMediaChanged(requireContext(), selected); + } + + @Override + public void onMediaSelectionOverflow(int maxSelection) { + Toast.makeText(requireContext(), getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show(); + } + + private void initToolbar(Toolbar toolbar) { + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + } + + private void onScreenWidthChanged(int newWidth) { + if (layoutManager != null) { + layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + requireActivity().getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } + + public interface Controller { + void onMediaSelected(@NonNull Media media); + void onCameraSelected(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java new file mode 100644 index 00000000..226978e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -0,0 +1,439 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Video; +import android.provider.OpenableColumns; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Handles the retrieval of media present on the user's device. + */ +public class MediaRepository { + + private static final String TAG = Log.tag(MediaRepository.class); + private static final String CAMERA = "Camera"; + + /** + * Retrieves a list of folders that contain media. + */ + void getFolders(@NonNull Context context, @NonNull Callback> callback) { + if (!StorageUtil.canReadFromMediaStore()) { + Log.w(TAG, "No storage permissions!", new Throwable()); + callback.onComplete(Collections.emptyList()); + return; + } + + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getFolders(context))); + } + + /** + * Retrieves a list of media items (images and videos) that are present int he specified bucket. + */ + public void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback> callback) { + if (!StorageUtil.canReadFromMediaStore()) { + Log.w(TAG, "No storage permissions!", new Throwable()); + callback.onComplete(Collections.emptyList()); + return; + } + + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId))); + } + + /** + * Given an existing list of {@link Media}, this will ensure that the media is populate with as + * much data as we have, like width/height. + */ + void getPopulatedMedia(@NonNull Context context, @NonNull List media, @NonNull Callback> callback) { + if (Stream.of(media).allMatch(this::isPopulated)) { + callback.onComplete(media); + return; + } + + if (!StorageUtil.canReadFromMediaStore()) { + Log.w(TAG, "No storage permissions!", new Throwable()); + callback.onComplete(media); + return; + } + + + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getPopulatedMedia(context, media))); + } + + void getMostRecentItem(@NonNull Context context, @NonNull Callback> callback) { + if (!StorageUtil.canReadFromMediaStore()) { + Log.w(TAG, "No storage permissions!", new Throwable()); + callback.onComplete(Optional.absent()); + return; + } + + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMostRecentItem(context))); + } + + static void transformMedia(@NonNull Context context, + @NonNull List currentMedia, + @NonNull Map modelsToTransform, + @NonNull Callback> callback) + { + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(transformMedia(context, currentMedia, modelsToTransform))); + } + + @WorkerThread + private @NonNull List getFolders(@NonNull Context context) { + FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); + FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); + Map folders = new HashMap<>(imageFolders.getFolderData()); + + for (Map.Entry entry : videoFolders.getFolderData().entrySet()) { + if (folders.containsKey(entry.getKey())) { + folders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); + } else { + folders.put(entry.getKey(), entry.getValue()); + } + } + + String cameraBucketId = imageFolders.getCameraBucketId() != null ? imageFolders.getCameraBucketId() : videoFolders.getCameraBucketId(); + FolderData cameraFolder = cameraBucketId != null ? folders.remove(cameraBucketId) : null; + List mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), + folder.getTitle(), + folder.getCount(), + folder.getBucketId(), + MediaFolder.FolderType.NORMAL)) + .filter(folder -> folder.getTitle() != null) + .sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase())) + .toList(); + + Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); + + if (allMediaThumbnail != null) { + int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount()); + + if (cameraFolder != null) { + allMediaCount += cameraFolder.getCount(); + } + + mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.MediaRepository_all_media), allMediaCount, Media.ALL_MEDIA_BUCKET_ID, MediaFolder.FolderType.NORMAL)); + } + + if (cameraFolder != null) { + mediaFolders.add(0, new MediaFolder(cameraFolder.getThumbnail(), cameraFolder.getTitle(), cameraFolder.getCount(), cameraFolder.getBucketId(), MediaFolder.FolderType.CAMERA)); + } + + return mediaFolders; + } + + @WorkerThread + private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { + String cameraBucketId = null; + Uri globalThumbnail = null; + long thumbnailTimestamp = 0; + Map folders = new HashMap<>(); + + String[] projection = new String[] { Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED }; + String selection = isNotPending(); + String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_MODIFIED + " DESC"; + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri thumbnail = ContentUris.withAppendedId(contentUri, rowId); + String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); + String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); + long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])); + FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, localizeTitle(context, title), bucketId)); + + folder.incrementCount(); + folders.put(bucketId, folder); + + if (cameraBucketId == null && CAMERA.equals(title)) { + cameraBucketId = bucketId; + } + + if (timestamp > thumbnailTimestamp) { + globalThumbnail = thumbnail; + thumbnailTimestamp = timestamp; + } + } + } + + return new FolderResult(cameraBucketId, globalThumbnail, thumbnailTimestamp, folders); + } + + private @NonNull String localizeTitle(@NonNull Context context, @NonNull String title) { + if (CAMERA.equals(title)) { + return context.getString(R.string.MediaRepository__camera); + } else { + return title; + } + } + + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { + List images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI, true); + List videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI, false); + List media = new ArrayList<>(images.size() + videos.size()); + + media.addAll(images); + media.addAll(videos); + Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate())); + + return media; + } + + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) { + List media = new LinkedList<>(); + String selection = Images.Media.BUCKET_ID + " = ? AND " + isNotPending(); + String[] selectionArgs = new String[] { bucketId }; + String sortBy = Images.Media.DATE_MODIFIED + " DESC"; + + String[] projection; + + if (isImage) { + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; + } else { + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Video.Media.DURATION}; + } + + if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { + selection = isNotPending(); + selectionArgs = null; + } + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri uri = ContentUris.withAppendedId(contentUri, rowId); + String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); + long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)); + int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; + int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); + int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); + long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); + long duration = !isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Video.Media.DURATION)) : 0; + + media.add(new Media(uri, mimetype, date, width, height, size, duration, false, Optional.of(bucketId), Optional.absent(), Optional.absent())); + } + } + + return media; + } + + private @NonNull String isNotPending() { + return Build.VERSION.SDK_INT <= 28 ? Images.Media.DATA + " NOT NULL" : MediaStore.MediaColumns.IS_PENDING + " != 1"; + } + + @WorkerThread + private List getPopulatedMedia(@NonNull Context context, @NonNull List media) { + return Stream.of(media).map(m -> { + try { + if (isPopulated(m)) { + return m; + } else if (PartAuthority.isLocalUri(m.getUri())) { + return getLocallyPopulatedMedia(context, m); + } else { + return getContentResolverPopulatedMedia(context, m); + } + } catch (IOException e) { + return m; + } + }).toList(); + } + + @WorkerThread + private static LinkedHashMap transformMedia(@NonNull Context context, + @NonNull List currentMedia, + @NonNull Map modelsToTransform) + { + LinkedHashMap updatedMedia = new LinkedHashMap<>(currentMedia.size()); + + for (Media media : currentMedia) { + MediaTransform transformer = modelsToTransform.get(media); + if (transformer != null) { + updatedMedia.put(media, transformer.transform(context, media)); + } else { + updatedMedia.put(media, media); + } + } + return updatedMedia; + } + + @WorkerThread + private Optional getMostRecentItem(@NonNull Context context) { + List media = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, Images.Media.EXTERNAL_CONTENT_URI, true); + return media.size() > 0 ? Optional.of(media.get(0)) : Optional.absent(); + } + + @TargetApi(16) + @SuppressWarnings("SuspiciousNameCombination") + private String getWidthColumn(int orientation) { + if (orientation == 0 || orientation == 180) return Images.Media.WIDTH; + else return Images.Media.HEIGHT; + } + + @TargetApi(16) + @SuppressWarnings("SuspiciousNameCombination") + private String getHeightColumn(int orientation) { + if (orientation == 0 || orientation == 180) return Images.Media.HEIGHT; + else return Images.Media.WIDTH; + } + + private boolean isPopulated(@NonNull Media media) { + return media.getWidth() > 0 && media.getHeight() > 0 && media.getSize() > 0; + } + + private Media getLocallyPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); + + if (size <= 0) { + Optional optionalSize = Optional.fromNullable(PartAuthority.getAttachmentSize(context, media.getUri())); + size = optionalSize.isPresent() ? optionalSize.get() : 0; + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } + + return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, 0, media.isBorderless(), media.getBucketId(), media.getCaption(), Optional.absent()); + } + + private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); + + if (size <= 0) { + try (Cursor cursor = context.getContentResolver().query(media.getUri(), null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } + + return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, 0, media.isBorderless(), media.getBucketId(), media.getCaption(), Optional.absent()); + } + + private static class FolderResult { + private final String cameraBucketId; + private final Uri thumbnail; + private final long thumbnailTimestamp; + private final Map folderData; + + private FolderResult(@Nullable String cameraBucketId, + @Nullable Uri thumbnail, + long thumbnailTimestamp, + @NonNull Map folderData) + { + this.cameraBucketId = cameraBucketId; + this.thumbnail = thumbnail; + this.thumbnailTimestamp = thumbnailTimestamp; + this.folderData = folderData; + } + + @Nullable String getCameraBucketId() { + return cameraBucketId; + } + + @Nullable Uri getThumbnail() { + return thumbnail; + } + + long getThumbnailTimestamp() { + return thumbnailTimestamp; + } + + @NonNull Map getFolderData() { + return folderData; + } + } + + private static class FolderData { + private final Uri thumbnail; + private final String title; + private final String bucketId; + + private int count; + + private FolderData(Uri thumbnail, String title, String bucketId) { + this.thumbnail = thumbnail; + this.title = title; + this.bucketId = bucketId; + } + + Uri getThumbnail() { + return thumbnail; + } + + String getTitle() { + return title; + } + + String getBucketId() { + return bucketId; + } + + int getCount() { + return count; + } + + void incrementCount() { + incrementCount(1); + } + + void incrementCount(int amount) { + count += amount; + } + } + + public interface Callback { + void onComplete(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java new file mode 100644 index 00000000..d0b5f43d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -0,0 +1,1077 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Vibrator; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.inputmethod.EditorInfo; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.util.Pair; +import androidx.core.util.Supplier; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.components.SendButton; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; +import org.thoughtcrime.securesms.components.emoji.EmojiToggle; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.HudState; +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.ViewOnceState; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; +import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; +import org.thoughtcrime.securesms.util.Function3; +import org.thoughtcrime.securesms.util.IOFunction; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.video.VideoUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Encompasses the entire flow of sending media, starting from the selection process to the actual + * captioning and editing of the content. + * + * This activity is intended to be launched via {@link #startActivityForResult(Intent, int)}. + * It will return the {@link Media} that the user decided to send. + */ +public class MediaSendActivity extends PassphraseRequiredActivity implements MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller, + ImageEditorFragment.Controller, + MediaSendVideoFragment.Controller, + CameraFragment.Controller, + CameraContactSelectionFragment.Controller, + ViewTreeObserver.OnGlobalLayoutListener, + MediaRailAdapter.RailItemListener, + InputAwareLayout.OnKeyboardShownListener, + InputAwareLayout.OnKeyboardHiddenListener +{ + private static final String TAG = MediaSendActivity.class.getSimpleName(); + + public static final String EXTRA_RESULT = "result"; + + private static final String KEY_RECIPIENT = "recipient_id"; + private static final String KEY_RECIPIENTS = "recipient_ids"; + private static final String KEY_BODY = "body"; + private static final String KEY_MEDIA = "media"; + private static final String KEY_TRANSPORT = "transport"; + private static final String KEY_IS_CAMERA = "is_camera"; + + private static final String TAG_FOLDER_PICKER = "folder_picker"; + private static final String TAG_ITEM_PICKER = "item_picker"; + private static final String TAG_SEND = "send"; + private static final String TAG_CAMERA = "camera"; + private static final String TAG_CONTACTS = "contacts"; + + private @Nullable LiveRecipient recipient; + + private TransportOption transport; + private MediaSendViewModel viewModel; + private MentionsPickerViewModel mentionsViewModel; + + private InputAwareLayout hud; + private View captionAndRail; + private SendButton sendButton; + private ViewGroup sendButtonContainer; + private ComposeText composeText; + private ViewGroup composeRow; + private ViewGroup composeContainer; + private ViewGroup countButton; + private TextView countButtonText; + private View continueButton; + private ImageView revealButton; + private EmojiEditText captionText; + private EmojiToggle emojiToggle; + private Stub emojiDrawer; + private Stub mentionSuggestions; + private TextView charactersLeft; + private RecyclerView mediaRail; + private MediaRailAdapter mediaRailAdapter; + + private int visibleHeight; + + private final Rect visibleBounds = new Rect(); + + /** + * Get an intent to launch the media send flow starting with the picker. + */ + public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @Nullable CharSequence body, @NonNull TransportOption transport) { + Intent intent = new Intent(context, MediaSendActivity.class); + intent.putExtra(KEY_RECIPIENT, recipient.getId()); + intent.putExtra(KEY_TRANSPORT, transport); + intent.putExtra(KEY_BODY, body == null ? "" : body); + return intent; + } + + public static Intent buildCameraFirstIntent(@NonNull Context context) { + Intent intent = new Intent(context, MediaSendActivity.class); + intent.putExtra(KEY_TRANSPORT, TransportOptions.getPushTransportOption(context)); + intent.putExtra(KEY_BODY, ""); + intent.putExtra(KEY_IS_CAMERA, true); + return intent; + } + + /** + * Get an intent to launch the media send flow starting with the picker. + */ + public static Intent buildCameraIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull TransportOption transport) { + Intent intent = buildGalleryIntent(context, recipient, "", transport); + intent.putExtra(KEY_IS_CAMERA, true); + return intent; + } + + /** + * Get an intent to launch the media send flow with a specific list of media. Will jump right to + * the editor screen. + */ + public static Intent buildEditorIntent(@NonNull Context context, + @NonNull List media, + @NonNull Recipient recipient, + @NonNull CharSequence body, + @NonNull TransportOption transport) + { + Intent intent = buildGalleryIntent(context, recipient, body, transport); + intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); + return intent; + } + + public static Intent buildShareIntent(@NonNull Context context, + @NonNull List media, + @NonNull List recipientIds, + @Nullable CharSequence body, + @NonNull TransportOption transportOption) + { + Intent intent = new Intent(context, MediaSendActivity.class); + intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); + intent.putExtra(KEY_TRANSPORT, transportOption); + intent.putExtra(KEY_BODY, body == null ? "" : body); + intent.putParcelableArrayListExtra(KEY_RECIPIENTS, new ArrayList<>(recipientIds)); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.mediasend_activity); + setResult(RESULT_CANCELED); + + if (savedInstanceState != null) { + return; + } + + hud = findViewById(R.id.mediasend_hud); + captionAndRail = findViewById(R.id.mediasend_caption_and_rail); + sendButton = findViewById(R.id.mediasend_send_button); + sendButtonContainer = findViewById(R.id.mediasend_send_button_bkg); + composeText = findViewById(R.id.mediasend_compose_text); + composeRow = findViewById(R.id.mediasend_compose_row); + composeContainer = findViewById(R.id.mediasend_compose_container); + countButton = findViewById(R.id.mediasend_count_button); + countButtonText = findViewById(R.id.mediasend_count_button_text); + continueButton = findViewById(R.id.mediasend_continue_button); + revealButton = findViewById(R.id.mediasend_reveal_toggle); + captionText = findViewById(R.id.mediasend_caption); + emojiToggle = findViewById(R.id.mediasend_emoji_toggle); + charactersLeft = findViewById(R.id.mediasend_characters_left); + mediaRail = findViewById(R.id.mediasend_media_rail); + emojiDrawer = new Stub<>(findViewById(R.id.mediasend_emoji_drawer_stub)); + mentionSuggestions = new Stub<>(findViewById(R.id.mediasend_mention_suggestions_stub)); + + RecipientId recipientId = getIntent().getParcelableExtra(KEY_RECIPIENT); + if (recipientId != null) { + Log.i(TAG, "Preparing for " + recipientId); + recipient = Recipient.live(recipientId); + } + + List recipientIds = getIntent().getParcelableArrayListExtra(KEY_RECIPIENTS); + if (recipientIds != null && recipientIds.size() > 0) { + Log.i(TAG, "Preparing for " + recipientIds); + } + + + viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + transport = getIntent().getParcelableExtra(KEY_TRANSPORT); + + MeteredConnectivityObserver meteredConnectivityObserver = new MeteredConnectivityObserver(this, this); + meteredConnectivityObserver.isMetered().observe(this, viewModel::onMeteredConnectivityStatusChanged); + viewModel.onMeteredConnectivityStatusChanged(Optional.fromNullable(meteredConnectivityObserver.isMetered().getValue()).or(false)); + + viewModel.setTransport(transport); + viewModel.setRecipient(recipient != null ? recipient.get() : null); + viewModel.onBodyChanged(getIntent().getCharSequenceExtra(KEY_BODY)); + + List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); + boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); + + if (isCamera) { + Fragment fragment = CameraFragment.newInstance(); + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) + .commit(); + + } else if (!Util.isEmpty(media)) { + viewModel.onSelectedMediaChanged(this, media); + + Fragment fragment = MediaSendFragment.newInstance(); + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .commit(); + } else { + MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, recipient != null ? recipient.get() : null); + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) + .commit(); + } + + sendButton.setOnClickListener(v -> onSendClicked()); + + sendButton.setOnLongClickListener(v -> true); + + sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { + presentCharactersRemaining(); + composeText.setTransport(newTransport); + sendButtonContainer.getBackground().setColorFilter(newTransport.getBackgroundColor(), PorterDuff.Mode.MULTIPLY); + sendButtonContainer.getBackground().invalidateSelf(); + }); + + ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); + + composeText.setOnKeyListener(composeKeyPressedListener); + composeText.addTextChangedListener(composeKeyPressedListener); + composeText.setOnClickListener(composeKeyPressedListener); + composeText.setOnFocusChangeListener(composeKeyPressedListener); + + captionText.clearFocus(); + composeText.requestFocus(); + + mediaRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, true); + mediaRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); + mediaRail.setAdapter(mediaRailAdapter); + + hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this); + hud.addOnKeyboardShownListener(this); + hud.addOnKeyboardHiddenListener(this); + + captionText.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.onCaptionChanged(text); + } + }); + + sendButton.setTransport(transport); + sendButton.disableTransport(transport.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS); + + countButton.setOnClickListener(v -> navigateToMediaSend()); + + composeText.append(viewModel.getBody()); + + if (recipient != null) { + recipient.observe(this, this::presentRecipient); + } + + presentRecipient(recipient != null ? recipient.get() : null); + + composeText.setOnEditorActionListener((v, actionId, event) -> { + boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; + if (isSend) sendButton.performClick(); + return isSend; + }); + + if (TextSecurePreferences.isSystemEmojiPreferred(this)) { + emojiToggle.setVisibility(View.GONE); + } else { + emojiToggle.setOnClickListener(this::onEmojiToggleClicked); + } + + initializeMentionsViewModel(); + initViewModel(); + + revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled()); + + continueButton.setOnClickListener(v -> { + continueButton.setEnabled(false); + if (recipientIds == null || recipientIds.isEmpty()) { + navigateToContactSelect(); + } else { + SimpleTask.run(getLifecycle(), + () -> Stream.of(recipientIds).map(Recipient::resolved).toList(), + this::onCameraContactsSendClicked); + } + }); + } + + @Override + public void onBackPressed() { + MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + + if (sendFragment == null || !sendFragment.isVisible() || !hud.isInputOpen()) { + if (captionAndRail != null) { + captionAndRail.setVisibility(View.VISIBLE); + } + super.onBackPressed(); + } else { + hud.hideCurrentInput(composeText); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onFolderSelected(@NonNull MediaFolder folder) { + viewModel.onFolderSelected(folder.getBucketId()); + + MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), viewModel.getMaxSelection()); + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit(); + } + + @Override + public void onMediaSelected(@NonNull Media media) { + viewModel.onSingleMediaSelected(this, media); + navigateToMediaSend(); + } + + @Override + public void onVideoBeginEdit(@NonNull Uri uri) { + viewModel.onVideoBeginEdit(uri); + } + + @Override + public void onTouchEventsNeeded(boolean needed) { + MediaSendFragment fragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + if (fragment != null) { + fragment.onTouchEventsNeeded(needed); + } + } + + @Override + public void onCameraError() { + Toast.makeText(this, R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show(); + setResult(RESULT_CANCELED, new Intent()); + finish(); + } + + @Override + public void onVideoCaptureError() { + Vibrator vibrator = ServiceUtil.getVibrator(this); + vibrator.vibrate(50); + } + + @Override + public void onImageCaptured(@NonNull byte[] data, int width, int height) { + Log.i(TAG, "Camera image captured."); + onMediaCaptured(() -> data, + ignored -> (long) data.length, + (blobProvider, bytes, ignored) -> blobProvider.forData(bytes), + MediaUtil.IMAGE_JPEG, + width, + height); + } + + @Override + public void onVideoCaptured(@NonNull FileDescriptor fd) { + Log.i(TAG, "Camera video captured."); + onMediaCaptured(() -> new FileInputStream(fd), + fin -> fin.getChannel().size(), + BlobProvider::forData, + VideoUtil.RECORDED_VIDEO_CONTENT_TYPE, + 0, + 0); + } + + private void onMediaCaptured(Supplier dataSupplier, + IOFunction getLength, + Function3 createBlobBuilder, + String mimeType, + int width, + int height) + { + SimpleTask.run(getLifecycle(), () -> { + try { + T data = dataSupplier.get(); + long length = getLength.apply(data); + + Uri uri = createBlobBuilder.apply(BlobProvider.getInstance(), data, length) + .withMimeType(mimeType) + .createForSingleSessionOnDisk(this); + + return new Media(uri, + mimeType, + System.currentTimeMillis(), + width, + height, + length, + 0, + false, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent(), + Optional.absent()); + } catch (IOException e) { + return null; + } + }, media -> { + if (media == null) { + onNoMediaAvailable(); + return; + } + + Log.i(TAG, "Camera capture stored: " + media.getUri().toString()); + + viewModel.onMediaCaptured(media); + navigateToMediaSend(); + }); + } + + @Override + public int getDisplayRotation() { + return getWindowManager().getDefaultDisplay().getRotation(); + } + + @Override + public void onCameraCountButtonClicked() { + navigateToMediaSend(); + } + + @Override + public void onGalleryClicked() { + MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(this, recipient != null ? recipient.get() : null); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .setCustomAnimations(R.anim.slide_from_bottom, R.anim.stationary, R.anim.slide_to_bottom, R.anim.stationary) + .addToBackStack(null) + .commit(); + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + if (captionAndRail != null) { + captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE); + } + if (hideKeyboard && hud.isKeyboardOpen()) { + hud.hideSoftkey(composeText, null); + } + } + + @Override + public void onDoneEditing() { + } + + @Override + public void onGlobalLayout() { + hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds); + + int currentVisibleHeight = visibleBounds.height(); + + if (currentVisibleHeight != visibleHeight) { + hud.getLayoutParams().height = currentVisibleHeight; + hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom); + hud.requestLayout(); + + visibleHeight = currentVisibleHeight; + } + } + + @Override + public void onKeyboardHidden() { + viewModel.onKeyboardHidden(sendButton.getSelectedTransport().isSms()); + } + + @Override + public void onKeyboardShown() { + viewModel.onKeyboardShown(composeText.hasFocus(), captionText.hasFocus(), sendButton.getSelectedTransport().isSms()); + } + + @Override + public void onRailItemClicked(int distanceFromActive) { + if (getMediaSendFragment() != null) { + viewModel.onPageChanged(getMediaSendFragment().getCurrentImagePosition() + distanceFromActive); + } + } + + @Override + public void onRailItemDeleteClicked(int distanceFromActive) { + if (getMediaSendFragment() != null) { + viewModel.onMediaItemRemoved(this, getMediaSendFragment().getCurrentImagePosition() + distanceFromActive); + } + } + + @Override + public void onCameraSelected() { + navigateToCamera(); + } + + @Override + public void onCameraContactsSendClicked(@NonNull List recipients) { + onSend(recipients); + } + + private void onSendClicked() { + onSend(Collections.emptyList()); + } + + private void onSend(@NonNull List recipients) { + MediaSendFragment fragment = getMediaSendFragment(); + + if (fragment == null) { + throw new AssertionError("No editor fragment available!"); + } + + if (hud.isKeyboardOpen()) { + hud.hideSoftkey(composeText, null); + } + + sendButton.setEnabled(false); + + fragment.pausePlayback(); + + SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(this, 300, 0); + viewModel.onSendClicked(buildModelsToTransform(fragment), recipients, composeText.getMentions()) + .observe(this, result -> { + dialog.dismiss(); + if (recipients.size() > 1) { + finishWithoutSettingResults(); + } else { + setActivityResultAndFinish(result); + } + }); + } + + private static Map buildModelsToTransform(@NonNull MediaSendFragment fragment) { + List mediaList = fragment.getAllMedia(); + Map savedState = fragment.getSavedState(); + Map modelsToRender = new HashMap<>(); + + for (Media media : mediaList) { + Object state = savedState.get(media.getUri()); + + if (state instanceof ImageEditorFragment.Data) { + EditorModel model = ((ImageEditorFragment.Data) state).readModel(); + if (model != null && model.isChanged()) { + modelsToRender.put(media, new ImageEditorModelRenderMediaTransform(model)); + } + } + + if (state instanceof MediaSendVideoFragment.Data) { + MediaSendVideoFragment.Data data = (MediaSendVideoFragment.Data) state; + if (data.durationEdited) { + modelsToRender.put(media, new VideoTrimTransform(data)); + } + } + } + + return modelsToRender; + } + + + private void onAddMediaClicked(@NonNull String bucketId) { + hud.hideCurrentInput(composeText); + + // TODO: Get actual folder title somehow + MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(this, recipient != null ? recipient.get() : null); + MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection()); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .addToBackStack(null) + .commit(); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit(); + } + + private void onNoMediaAvailable() { + setResult(RESULT_CANCELED); + finish(); + } + + private void initViewModel() { + LiveData> hudStateAndMentionShowing = LiveDataUtil.combineLatest(viewModel.getHudState(), + mentionsViewModel != null ? mentionsViewModel.isShowing() + : new MutableLiveData<>(false), + Pair::new); + + hudStateAndMentionShowing.observe(this, p -> { + HudState state = Objects.requireNonNull(p.first); + boolean isMentionPickerShowing = Objects.requireNonNull(p.second); + int captionBackground = R.color.transparent_black_40; + + if (state.getRailState() == MediaSendViewModel.RailState.VIEWABLE) { + captionBackground = R.color.core_grey_90; + } else if (state.getViewOnceState() == ViewOnceState.ENABLED) { + captionBackground = 0; + } else if (isMentionPickerShowing){ + captionBackground = R.color.signal_background_dialog; + } + + captionAndRail.setBackgroundResource(captionBackground); + }); + + viewModel.getHudState().observe(this, state -> { + if (state == null) return; + + hud.setVisibility(state.isHudVisible() ? View.VISIBLE : View.GONE); + composeContainer.setVisibility(state.isComposeVisible() ? View.VISIBLE : (state.getViewOnceState() == ViewOnceState.GONE ? View.GONE : View.INVISIBLE)); + captionText.setVisibility(state.isCaptionVisible() ? View.VISIBLE : View.GONE); + + switch (state.getButtonState()) { + case SEND: + sendButtonContainer.setVisibility(View.VISIBLE); + continueButton.setVisibility(View.GONE); + countButton.setVisibility(View.GONE); + break; + case COUNT: + sendButtonContainer.setVisibility(View.GONE); + continueButton.setVisibility(View.GONE); + countButton.setVisibility(View.VISIBLE); + countButtonText.setText(String.valueOf(state.getSelectionCount())); + break; + case CONTINUE: + sendButtonContainer.setVisibility(View.GONE); + countButton.setVisibility(View.GONE); + continueButton.setVisibility(View.VISIBLE); + + if (!TextSecurePreferences.hasSeenCameraFirstTooltip(this) && !getIntent().hasExtra(KEY_RECIPIENTS)) { + TooltipPopup.forTarget(continueButton) + .setText(R.string.MediaSendActivity_select_recipients) + .show(TooltipPopup.POSITION_ABOVE); + TextSecurePreferences.setHasSeenCameraFirstTooltip(this, true); + } + break; + case GONE: + sendButtonContainer.setVisibility(View.GONE); + countButton.setVisibility(View.GONE); + break; + } + + switch (state.getViewOnceState()) { + case ENABLED: + revealButton.setVisibility(View.VISIBLE); + revealButton.setImageResource(R.drawable.ic_view_once_32); + break; + case DISABLED: + revealButton.setVisibility(View.VISIBLE); + revealButton.setImageResource(R.drawable.ic_view_infinite_32); + break; + case GONE: + revealButton.setVisibility(View.GONE); + break; + } + + switch (state.getRailState()) { + case INTERACTIVE: + mediaRail.setVisibility(View.VISIBLE); + mediaRailAdapter.setEditable(true); + mediaRailAdapter.setInteractive(true); + break; + case VIEWABLE: + mediaRail.setVisibility(View.VISIBLE); + mediaRailAdapter.setEditable(false); + mediaRailAdapter.setInteractive(false); + break; + case GONE: + mediaRail.setVisibility(View.GONE); + break; + } + + if (composeContainer.getVisibility() == View.GONE && sendButtonContainer.getVisibility() == View.GONE) { + composeRow.setVisibility(View.GONE); + } else { + composeRow.setVisibility(View.VISIBLE); + } + }); + + viewModel.getSelectedMedia().observe(this, media -> { + mediaRailAdapter.setMedia(media); + }); + + viewModel.getPosition().observe(this, position -> { + if (position == null || position < 0) return; + + MediaSendFragment fragment = getMediaSendFragment(); + if (fragment != null && fragment.getAllMedia().size() > position) { + captionText.setText(fragment.getAllMedia().get(position).getCaption().or("")); + } + + mediaRailAdapter.setActivePosition(position); + mediaRail.smoothScrollToPosition(position); + }); + + viewModel.getBucketId().observe(this, bucketId -> { + if (bucketId == null) return; + mediaRailAdapter.setAddButtonListener(() -> onAddMediaClicked(bucketId)); + }); + + viewModel.getError().observe(this, error -> { + if (error == null) return; + + switch (error) { + case NO_ITEMS: + onNoMediaAvailable(); + break; + case ITEM_TOO_LARGE: + Toast.makeText(this, R.string.MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit, Toast.LENGTH_LONG).show(); + break; + case ONLY_ITEM_TOO_LARGE: + Toast.makeText(this, R.string.MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit, Toast.LENGTH_LONG).show(); + onNoMediaAvailable(); + break; + case TOO_MANY_ITEMS: + int maxSelection = viewModel.getMaxSelection(); + Toast.makeText(this, getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show(); + break; + } + }); + + viewModel.getEvents().observe(this, event -> { + switch (event) { + case VIEW_ONCE_TOOLTIP: + TooltipPopup.forTarget(revealButton) + .setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) + .setTextColor(getResources().getColor(R.color.core_white)) + .setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true)) + .show(TooltipPopup.POSITION_ABOVE); + break; + } + }); + } + + private void initializeMentionsViewModel() { + if (recipient == null) { + return; + } + + mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); + + recipient.observe(this, mentionsViewModel::onRecipientChange); + composeText.setMentionQueryChangedListener(query -> { + if (recipient.get().isPushV2Group()) { + if (!mentionSuggestions.resolved()) { + mentionSuggestions.get(); + } + mentionsViewModel.onQueryChange(query); + } + }); + + composeText.setMentionValidator(annotations -> { + if (!recipient.get().isPushV2Group()) { + return annotations; + } + + Set validRecipientIds = Stream.of(recipient.get().getParticipants()) + .map(r -> MentionAnnotation.idToMentionAnnotationValue(r.getId())) + .collect(Collectors.toSet()); + + return Stream.of(annotations) + .filter(a -> !validRecipientIds.contains(a.getValue())) + .toList(); + }); + + mentionsViewModel.getSelectedRecipient().observe(this, recipient -> { + composeText.replaceTextWithMention(recipient.getDisplayName(this), recipient.getId()); + }); + + MentionPickerPlacer mentionPickerPlacer = new MentionPickerPlacer(); + + mentionsViewModel.isShowing().observe(this, isShowing -> { + if (isShowing) { + composeRow.getViewTreeObserver().addOnGlobalLayoutListener(mentionPickerPlacer); + } else { + composeRow.getViewTreeObserver().removeOnGlobalLayoutListener(mentionPickerPlacer); + } + mentionPickerPlacer.onGlobalLayout(); + }); + } + + private void presentRecipient(@Nullable Recipient recipient) { + if (recipient == null) { + composeText.setHint(R.string.MediaSendActivity_message); + } else if (recipient.isSelf()) { + composeText.setHint(getString(R.string.note_to_self), null); + } else { + composeText.setHint(getString(R.string.MediaSendActivity_message_to_s, recipient.getDisplayName(this)), null); + } + + } + + private void navigateToMediaSend() { + MediaSendFragment fragment = MediaSendFragment.newInstance(); + String backstackTag = null; + + if (getSupportFragmentManager().findFragmentByTag(TAG_SEND) != null) { + getSupportFragmentManager().popBackStack(TAG_SEND, FragmentManager.POP_BACK_STACK_INCLUSIVE); + backstackTag = TAG_SEND; + } + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .addToBackStack(backstackTag) + .commit(); + } + + private void navigateToCamera() { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) + .onAllGranted(() -> { + Fragment fragment = getOrCreateCameraFragment(); + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) + .addToBackStack(null) + .commit(); + }) + .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show()) + .execute(); + } + + private void navigateToContactSelect() { + if (hud.isInputOpen()) { + hud.hideCurrentInput(composeText); + } + + Permissions.with(this) + .request(Manifest.permission.READ_CONTACTS) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.MediaSendActivity_signal_needs_contacts_permission_in_order_to_show_your_contacts_but_it_has_been_permanently_denied)) + .onAllGranted(() -> { + Fragment contactFragment = CameraContactSelectionFragment.newInstance(); + Fragment editorFragment = getSupportFragmentManager().findFragmentByTag(TAG_SEND); + + if (editorFragment == null) { + throw new AssertionError("No editor fragment available!"); + } + + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .add(R.id.mediasend_fragment_container, contactFragment, TAG_CONTACTS) + .hide(editorFragment) + .addToBackStack(null) + .commit(); + }) + .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, R.string.MediaSendActivity_signal_needs_access_to_your_contacts, Toast.LENGTH_LONG).show()) + .execute(); + } + + private Fragment getOrCreateCameraFragment() { + Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_CAMERA); + return fragment != null ? fragment : CameraFragment.newInstance(); + } + + private EmojiEditText getActiveInputField() { + if (captionText.hasFocus()) return captionText; + else return composeText; + } + + + private void presentCharactersRemaining() { + String messageBody = composeText.getTextTrimmed().toString(); + TransportOption transportOption = sendButton.getSelectedTransport(); + CharacterState characterState = transportOption.calculateCharacters(messageBody); + + if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { + charactersLeft.setText(String.format(Locale.getDefault(), + "%d/%d (%d)", + characterState.charactersRemaining, + characterState.maxTotalMessageSize, + characterState.messagesSpent)); + charactersLeft.setVisibility(View.VISIBLE); + } else { + charactersLeft.setVisibility(View.GONE); + } + } + + + private void onEmojiToggleClicked(View v) { + if (!emojiDrawer.resolved()) { + emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(this, new EmojiKeyboardProvider.EmojiEventListener() { + @Override + public void onKeyEvent(KeyEvent keyEvent) { + getActiveInputField().dispatchKeyEvent(keyEvent); + } + + @Override + public void onEmojiSelected(String emoji) { + getActiveInputField().insertEmoji(emoji); + } + })); + emojiToggle.attach(emojiDrawer.get()); + } + + if (hud.getCurrentInput() == emojiDrawer.get()) { + hud.showSoftkey(composeText); + } else { + hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get()))); + } + } + + private @Nullable MediaSendFragment getMediaSendFragment() { + return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + } + + private void finishWithoutSettingResults() { + Intent intent = new Intent(); + setResult(RESULT_OK, intent); + + finish(); + overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom); + } + + private void setActivityResultAndFinish(@NonNull MediaSendActivityResult result) { + Intent intent = new Intent(); + intent.putExtra(EXTRA_RESULT, result); + setResult(RESULT_OK, intent); + + finish(); + overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom); + } + + private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener { + + int beforeLength; + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (TextSecurePreferences.isEnterSendsEnabled(getApplicationContext())) { + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); + return true; + } + } + } + return false; + } + + @Override + public void onClick(View v) { + hud.showSoftkey(composeText); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count,int after) { + beforeLength = composeText.getTextTrimmed().length(); + } + + @Override + public void afterTextChanged(Editable s) { + presentCharactersRemaining(); + viewModel.onBodyChanged(s); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before,int count) {} + + @Override + public void onFocusChange(View v, boolean hasFocus) {} + } + + private class MentionPickerPlacer implements ViewTreeObserver.OnGlobalLayoutListener { + + private final int composeMargin; + private final ViewGroup parent; + private final Rect composeCoordinates; + private int previousBottomMargin; + + public MentionPickerPlacer() { + parent = findViewById(android.R.id.content); + composeMargin = ViewUtil.dpToPx(12); + composeCoordinates = new Rect(); + } + + @Override + public void onGlobalLayout() { + composeRow.getDrawingRect(composeCoordinates); + parent.offsetDescendantRectToMyCoords(composeRow, composeCoordinates); + + int marginBottom = parent.getHeight() - composeCoordinates.top + composeMargin; + + if (marginBottom != previousBottomMargin) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) mentionSuggestions.get().getLayoutParams(); + params.setMargins(0, 0, 0, marginBottom); + mentionSuggestions.get().setLayoutParams(params); + + previousBottomMargin = marginBottom; + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java new file mode 100644 index 00000000..5989322a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * A class that lets us nicely format data that we'll send back to {@link ConversationActivity}. + */ +public class MediaSendActivityResult implements Parcelable { + private final RecipientId recipientId; + private final Collection uploadResults; + private final Collection nonUploadedMedia; + private final String body; + private final TransportOption transport; + private final boolean viewOnce; + private final Collection mentions; + private final ArrayList uriList; + + static @NonNull MediaSendActivityResult forPreUpload(@NonNull RecipientId recipientId, + @NonNull Collection uploadResults, + @NonNull String body, + @NonNull TransportOption transport, + boolean viewOnce, + @NonNull List mentions, + @NonNull ArrayList currentListUri) + { + Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!"); + return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions, currentListUri); + } + + static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId, + @NonNull List nonUploadedMedia, + @NonNull String body, + @NonNull TransportOption transport, + boolean viewOnce, + @NonNull List mentions, + @NonNull ArrayList currentListUri) + { + Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!"); + return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions, currentListUri); + } + + private MediaSendActivityResult(@NonNull RecipientId recipientId, + @NonNull Collection uploadResults, + @NonNull List nonUploadedMedia, + @NonNull String body, + @NonNull TransportOption transport, + boolean viewOnce, + @NonNull List mentions, + @NonNull ArrayList currentListUri) + { + this.recipientId = recipientId; + this.uploadResults = uploadResults; + this.nonUploadedMedia = nonUploadedMedia; + this.body = body; + this.transport = transport; + this.viewOnce = viewOnce; + this.mentions = mentions; + this.uriList = currentListUri; + } + + private MediaSendActivityResult(Parcel in) { + this.recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + this.uploadResults = ParcelUtil.readParcelableCollection(in, PreUploadResult.class); + this.nonUploadedMedia = ParcelUtil.readParcelableCollection(in, Media.class); + this.body = in.readString(); + this.transport = in.readParcelable(TransportOption.class.getClassLoader()); + this.viewOnce = ParcelUtil.readBoolean(in); + this.mentions = ParcelUtil.readParcelableCollection(in, Mention.class); + this.uriList = in.createStringArrayList(); + } + + + public ArrayList getUriList() { + return uriList; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public boolean isPushPreUpload() { + return uploadResults.size() > 0; + } + + public @NonNull Collection getPreUploadResults() { + return uploadResults; + } + + public @NonNull Collection getNonUploadedMedia() { + return nonUploadedMedia; + } + + public @NonNull String getBody() { + return body; + } + + public @NonNull TransportOption getTransport() { + return transport; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public @NonNull Collection getMentions() { + return mentions; + } + + public static final Creator CREATOR = new Creator() { + @Override + public MediaSendActivityResult createFromParcel(Parcel in) { + return new MediaSendActivityResult(in); + } + + @Override + public MediaSendActivityResult[] newArray(int size) { + return new MediaSendActivityResult[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(recipientId, 0); + ParcelUtil.writeParcelableCollection(dest, uploadResults); + ParcelUtil.writeParcelableCollection(dest, nonUploadedMedia); + dest.writeString(body); + dest.writeParcelable(transport, 0); + ParcelUtil.writeBoolean(dest, viewOnce); + ParcelUtil.writeParcelableCollection(dest, mentions); + dest.writeStringList(uriList); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java new file mode 100644 index 00000000..411fd9c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.mediasend; + +public class MediaSendConstants { + public static final int MAX_PUSH = 32; + public static final int MAX_SMS = 1; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java new file mode 100644 index 00000000..cfb1b9f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.viewpager.widget.ViewPager; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ControllableViewPager; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; +import java.util.Map; + +/** + * Allows the user to edit and caption a set of media items before choosing to send them. + */ +public class MediaSendFragment extends Fragment { + + private ViewGroup playbackControlsContainer; + private ControllableViewPager fragmentPager; + private MediaSendFragmentPagerAdapter fragmentPagerAdapter; + + private MediaSendViewModel viewModel; + + public static MediaSendFragment newInstance() { + Bundle args = new Bundle(); + + MediaSendFragment fragment = new MediaSendFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediasend_fragment, container, false); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initViewModel(); + fragmentPager = view.findViewById(R.id.mediasend_pager); + playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); + + fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager(), viewModel.isSms() ? MediaConstraints.getMmsMediaConstraints(-1) : MediaConstraints.getPushMediaConstraints()); + fragmentPager.setAdapter(fragmentPagerAdapter); + + FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener(); + fragmentPager.addOnPageChangeListener(pageChangeListener); + fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem())); + } + + @Override + public void onStart() { + super.onStart(); + + fragmentPagerAdapter.restoreState(viewModel.getDrawState()); + viewModel.onImageEditorStarted(); + } + + @Override + public void onHiddenChanged(boolean hidden) { + super.onHiddenChanged(hidden); + if (!hidden) { + viewModel.onImageEditorStarted(); + } else { + fragmentPagerAdapter.notifyHidden(); + } + } + + @Override + public void onPause() { + super.onPause(); + fragmentPagerAdapter.notifyHidden(); + } + + @Override + public void onStop() { + super.onStop(); + fragmentPagerAdapter.saveAllState(); + viewModel.saveDrawState(fragmentPagerAdapter.getSavedState()); + } + + public void onTouchEventsNeeded(boolean needed) { + if (fragmentPager != null) { + fragmentPager.setEnabled(!needed); + } + } + + public List getAllMedia() { + return fragmentPagerAdapter.getAllMedia(); + } + + public @NonNull Map getSavedState() { + return fragmentPagerAdapter.getSavedState(); + } + + public int getCurrentImagePosition() { + return fragmentPager.getCurrentItem(); + } + + private void initViewModel() { + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + + viewModel.getSelectedMedia().observe(getViewLifecycleOwner(), media -> { + if (Util.isEmpty(media)) { + return; + } + + fragmentPagerAdapter.setMedia(media); + }); + + viewModel.getPosition().observe(getViewLifecycleOwner(), position -> { + if (position == null || position < 0) return; + + fragmentPager.setCurrentItem(position, true); + + View playbackControls = fragmentPagerAdapter.getPlaybackControls(position); + + if (playbackControls != null) { + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + playbackControls.setLayoutParams(params); + playbackControlsContainer.removeAllViews(); + playbackControlsContainer.addView(playbackControls); + } else { + playbackControlsContainer.removeAllViews(); + } + }); + } + + void pausePlayback() { + fragmentPagerAdapter.pausePlayback(); + } + + private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + @Override + public void onPageSelected(int position) { + viewModel.onPageChanged(position); + fragmentPagerAdapter.notifyPageChanged(position); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java new file mode 100644 index 00000000..612fac7f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { + + private final List media; + private final Map fragments; + private final Map savedState; + private final MediaConstraints mediaConstraints; + + MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm, @NonNull MediaConstraints mediaConstraints) { + super(fm); + this.mediaConstraints = mediaConstraints; + this.media = new ArrayList<>(); + this.fragments = new HashMap<>(); + this.savedState = new HashMap<>(); + } + + @Override + public Fragment getItem(int i) { + Media mediaItem = media.get(i); + + if (MediaUtil.isGif(mediaItem.getMimeType())) { + return MediaSendGifFragment.newInstance(mediaItem.getUri()); + } else if (MediaUtil.isImageType(mediaItem.getMimeType())) { + return ImageEditorFragment.newInstance(mediaItem.getUri()); + } else if (MediaUtil.isVideoType(mediaItem.getMimeType())) { + return MediaSendVideoFragment.newInstance(mediaItem.getUri(), + mediaConstraints.getCompressedVideoMaxSize(ApplicationDependencies.getApplication()), + mediaConstraints.getVideoMaxSize(ApplicationDependencies.getApplication())); + } else { + throw new UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.getMimeType() + "'"); + } + } + + @Override + public int getItemPosition(@NonNull Object object) { + return POSITION_NONE; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup container, int position) { + MediaSendPageFragment fragment = (MediaSendPageFragment) super.instantiateItem(container, position); + fragments.put(position, fragment); + + Object state = savedState.get(fragment.getUri()); + if (state != null) { + fragment.restoreState(state); + } + + return fragment; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + MediaSendPageFragment fragment = (MediaSendPageFragment) object; + + Object state = fragment.saveState(); + if (state != null) { + savedState.put(fragment.getUri(), state); + } + + super.destroyItem(container, position, object); + fragments.remove(position); + } + + @Override + public int getCount() { + return media.size(); + } + + List getAllMedia() { + return media; + } + + void setMedia(@NonNull List media) { + this.media.clear(); + this.media.addAll(media); + notifyDataSetChanged(); + } + + Map getSavedState() { + for (MediaSendPageFragment fragment : fragments.values()) { + Object state = fragment.saveState(); + if (state != null) { + savedState.put(fragment.getUri(), state); + } + } + return new HashMap<>(savedState); + } + + void saveAllState() { + for (MediaSendPageFragment fragment : fragments.values()) { + Object state = fragment.saveState(); + if (state != null) { + savedState.put(fragment.getUri(), state); + } + } + } + + void restoreState(@NonNull Map state) { + savedState.clear(); + savedState.putAll(state); + } + + @Nullable View getPlaybackControls(int position) { + return fragments.containsKey(position) ? fragments.get(position).getPlaybackControls() : null; + } + + void pausePlayback() { + for (MediaSendPageFragment fragment : fragments.values()) { + if (fragment instanceof MediaSendVideoFragment) { + ((MediaSendVideoFragment)fragment).pausePlayback(); + } + } + } + + void notifyHidden() { + Stream.of(fragments.values()).forEach(MediaSendPageFragment::notifyHidden); + } + + void notifyPageChanged(int currentPage) { + notifyHiddenIfExists(currentPage - 1); + notifyHiddenIfExists(currentPage + 1); + } + + private void notifyHiddenIfExists(int position) { + MediaSendPageFragment fragment = fragments.get(position); + + if (fragment != null) { + fragment.notifyHidden(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java new file mode 100644 index 00000000..49266f07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; + +public class MediaSendGifFragment extends Fragment implements MediaSendPageFragment { + + private static final String KEY_URI = "uri"; + + private Uri uri; + + public static MediaSendGifFragment newInstance(@NonNull Uri uri) { + Bundle args = new Bundle(); + args.putParcelable(KEY_URI, uri); + + MediaSendGifFragment fragment = new MediaSendGifFragment(); + fragment.setArguments(args); + fragment.setUri(uri); + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediasend_image_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + uri = getArguments().getParcelable(KEY_URI); + GlideApp.with(this).load(new DecryptableStreamUriLoader.DecryptableUri(uri)).into((ImageView) view); + } + + @Override + public void setUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public @NonNull Uri getUri() { + return uri; + } + + @Override + public @Nullable View getPlaybackControls() { + return null; + } + + @Override + public @Nullable Object saveState() { + return null; + } + + @Override + public void restoreState(@NonNull Object state) { } + + @Override + public void notifyHidden() { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java new file mode 100644 index 00000000..4d3f3d8d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * A page that sits in the {@link MediaSendFragmentPagerAdapter}. + */ +public interface MediaSendPageFragment { + + @NonNull Uri getUri(); + + void setUri(@NonNull Uri uri); + + @Nullable View getPlaybackControls(); + + @Nullable Object saveState(); + + void restoreState(@NonNull Object state); + + void notifyHidden(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java new file mode 100644 index 00000000..3b2a8683 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.fragment.app.Fragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.scribbles.VideoEditorHud; +import org.thoughtcrime.securesms.util.Throttler; +import org.thoughtcrime.securesms.video.VideoBitRateCalculator; +import org.thoughtcrime.securesms.video.VideoPlayer; + +import java.io.IOException; + +public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.EventListener, + MediaSendPageFragment { + + private static final String TAG = Log.tag(MediaSendVideoFragment.class); + + private static final String KEY_URI = "uri"; + private static final String KEY_MAX_OUTPUT = "max_output_size"; + private static final String KEY_MAX_SEND = "max_send_size"; + + private final Throttler videoScanThrottle = new Throttler(150); + private final Handler handler = new Handler(Looper.getMainLooper()); + + private Controller controller; + private Data data = new Data(); + private Uri uri; + private VideoPlayer player; + @Nullable private VideoEditorHud hud; + private Runnable updatePosition; + + public static MediaSendVideoFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize) { + Bundle args = new Bundle(); + args.putParcelable(KEY_URI, uri); + args.putLong(KEY_MAX_OUTPUT, maxCompressedVideoSize); + args.putLong(KEY_MAX_SEND, maxAttachmentSize); + + MediaSendVideoFragment fragment = new MediaSendVideoFragment(); + fragment.setArguments(args); + fragment.setUri(uri); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement Controller interface."); + } + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediasend_video_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + player = view.findViewById(R.id.video_player); + + uri = requireArguments().getParcelable(KEY_URI); + long maxOutput = requireArguments().getLong(KEY_MAX_OUTPUT); + long maxSend = requireArguments().getLong(KEY_MAX_SEND); + VideoSlide slide = new VideoSlide(requireContext(), uri, 0); + + player.setWindow(requireActivity().getWindow()); + player.setVideoSource(slide, true); + + if (MediaConstraints.isVideoTranscodeAvailable()) { + hud = view.findViewById(R.id.video_editor_hud); + hud.setEventListener(this); + updateHud(data); + if (data.durationEdited) { + player.clip(data.startTimeUs, data.endTimeUs, true); + } + try { + hud.setVideoSource(slide, new VideoBitRateCalculator(maxOutput), maxSend); + hud.setVisibility(View.VISIBLE); + startPositionUpdates(); + } catch (IOException e) { + Log.w(TAG, e); + } + + player.setOnClickListener(v -> { + player.pause(); + hud.showPlayButton(); + }); + + player.setPlayerCallback(new VideoPlayer.PlayerCallback() { + + @Override + public void onPlaying() { + hud.fadePlayButton(); + } + + @Override + public void onStopped() { + hud.showPlayButton(); + } + }); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (player != null) { + player.cleanup(); + } + } + + @Override + public void onPause() { + super.onPause(); + notifyHidden(); + + stopPositionUpdates(); + } + + @Override + public void onResume() { + super.onResume(); + startPositionUpdates(); + } + + private void startPositionUpdates() { + if (hud != null && Build.VERSION.SDK_INT >= 23) { + stopPositionUpdates(); + updatePosition = new Runnable() { + @Override + public void run() { + hud.setPosition(player.getPlaybackPositionUs()); + handler.postDelayed(this, 100); + } + }; + handler.post(updatePosition); + } + } + + private void stopPositionUpdates() { + handler.removeCallbacks(updatePosition); + } + + @Override + public void onHiddenChanged(boolean hidden) { + if (hidden) { + notifyHidden(); + } + } + + @Override + public void setUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public @NonNull Uri getUri() { + return uri; + } + + @Override + public @Nullable View getPlaybackControls() { + if (hud != null && hud.getVisibility() == View.VISIBLE) return null; + + return player != null ? player.getControlView() : null; + } + + @Override + public @Nullable Object saveState() { + return data; + } + + @Override + public void restoreState(@NonNull Object state) { + if (state instanceof Data) { + data = (Data) state; + if (Build.VERSION.SDK_INT >= 23) { + updateHud(data); + } + } else { + Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); + } + } + + @RequiresApi(api = 23) + private void updateHud(Data data) { + if (hud != null && data.totalDurationUs > 0 && data.durationEdited) { + hud.setDurationRange(data.totalDurationUs, data.startTimeUs, data.endTimeUs); + } + } + + @Override + public void notifyHidden() { + pausePlayback(); + } + + public void pausePlayback() { + if (player != null) { + player.pause(); + if (hud != null) { + hud.showPlayButton(); + } + } + } + + @Override + public void onEditVideoDuration(long totalDurationUs, long startTimeUs, long endTimeUs, boolean fromEdited, boolean editingComplete) { + controller.onTouchEventsNeeded(!editingComplete); + + if (hud != null) { + hud.hidePlayButton(); + } + + boolean wasEdited = data.durationEdited; + boolean durationEdited = startTimeUs > 0 || endTimeUs < totalDurationUs; + + data.durationEdited = durationEdited; + data.totalDurationUs = totalDurationUs; + data.startTimeUs = startTimeUs; + data.endTimeUs = endTimeUs; + + if (editingComplete) { + videoScanThrottle.clear(); + } + + videoScanThrottle.publish(() -> { + player.pause(); + if (!editingComplete) { + player.removeClip(false); + } + player.setPlaybackPosition(fromEdited || editingComplete ? startTimeUs / 1000 : endTimeUs / 1000); + if (editingComplete) { + if (durationEdited) { + player.clip(startTimeUs, endTimeUs, true); + } else { + player.removeClip(true); + } + } + }); + + if (!wasEdited && durationEdited) { + controller.onVideoBeginEdit(uri); + } + } + + @Override + public void onPlay() { + player.play(); + } + + @Override + public void onSeek(long position, boolean dragComplete) { + if (dragComplete) { + videoScanThrottle.clear(); + } + + videoScanThrottle.publish(() -> { + player.pause(); + player.setPlaybackPosition(position); + }); + } + + static class Data { + boolean durationEdited; + long totalDurationUs; + long startTimeUs; + long endTimeUs; + } + + public interface Controller { + + void onTouchEventsNeeded(boolean needed); + + void onVideoBeginEdit(@NonNull Uri uri); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java new file mode 100644 index 00000000..109a3d40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -0,0 +1,776 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.app.Application; +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; +import org.thoughtcrime.securesms.util.DiffHelper; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MessageUtil; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Manages the observable datasets available in {@link MediaSendActivity}. + */ +class MediaSendViewModel extends ViewModel { + + private static final String TAG = MediaSendViewModel.class.getSimpleName(); + + private final Application application; + private final MediaRepository repository; + private final MediaUploadRepository uploadRepository; + private final MutableLiveData> selectedMedia; + private final MutableLiveData> bucketMedia; + private final MutableLiveData> mostRecentMedia; + private final MutableLiveData position; + private final MutableLiveData bucketId; + private final MutableLiveData> folders; + private final MutableLiveData hudState; + private final SingleLiveEvent error; + private final SingleLiveEvent event; + private final Map savedDrawState; + + private TransportOption transport; + private MediaConstraints mediaConstraints; + private CharSequence body; + private boolean sentMedia; + private int maxSelection; + private Page page; + private boolean isSms; + private boolean meteredConnection; + private Optional lastCameraCapture; + private boolean preUploadEnabled; + + private boolean hudVisible; + private boolean composeVisible; + private boolean captionVisible; + private ButtonState buttonState; + private RailState railState; + private ViewOnceState viewOnceState; + + + private @Nullable Recipient recipient; + + private MediaSendViewModel(@NonNull Application application, + @NonNull MediaRepository repository, + @NonNull MediaUploadRepository uploadRepository) + { + this.application = application; + this.repository = repository; + this.uploadRepository = uploadRepository; + this.selectedMedia = new MutableLiveData<>(); + this.bucketMedia = new MutableLiveData<>(); + this.mostRecentMedia = new MutableLiveData<>(); + this.position = new MutableLiveData<>(); + this.bucketId = new MutableLiveData<>(); + this.folders = new MutableLiveData<>(); + this.hudState = new MutableLiveData<>(); + this.error = new SingleLiveEvent<>(); + this.event = new SingleLiveEvent<>(); + this.savedDrawState = new HashMap<>(); + this.lastCameraCapture = Optional.absent(); + this.body = ""; + this.buttonState = ButtonState.GONE; + this.railState = RailState.GONE; + this.viewOnceState = ViewOnceState.GONE; + this.page = Page.UNKNOWN; + this.preUploadEnabled = true; + + position.setValue(-1); + } + + void setTransport(@NonNull TransportOption transport) { + this.transport = transport; + + if (transport.isSms()) { + isSms = true; + maxSelection = MediaSendConstants.MAX_SMS; + mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1)); + } else { + isSms = false; + maxSelection = MediaSendConstants.MAX_PUSH; + mediaConstraints = MediaConstraints.getPushMediaConstraints(); + } + + preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient); + } + + void setRecipient(@Nullable Recipient recipient) { + this.recipient = recipient; + this.preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient); + } + + void onSelectedMediaChanged(@NonNull Context context, @NonNull List newMedia) { + List originalMedia = getSelectedMediaOrDefault(); + + if (!newMedia.isEmpty()) { + selectedMedia.setValue(newMedia); + } + + repository.getPopulatedMedia(context, newMedia, populatedMedia -> { + Util.runOnMain(() -> { + List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); + + if (filteredMedia.size() != newMedia.size()) { + if (filteredMedia.isEmpty() && newMedia.size() == 1 && page == Page.UNKNOWN) { + error.setValue(Error.ONLY_ITEM_TOO_LARGE); + } else { + error.setValue(Error.ITEM_TOO_LARGE); + } + } + + if (filteredMedia.size() > maxSelection) { + filteredMedia = filteredMedia.subList(0, maxSelection); + error.setValue(Error.TOO_MANY_ITEMS); + } + + if (filteredMedia.size() > 0) { + String computedId = Stream.of(filteredMedia) + .skip(1) + .reduce(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID), (id, m) -> { + if (Util.equals(id, m.getBucketId().or(Media.ALL_MEDIA_BUCKET_ID))) { + return id; + } else { + return Media.ALL_MEDIA_BUCKET_ID; + } + }); + bucketId.setValue(computedId); + } else { + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); + } + + if (page == Page.EDITOR && filteredMedia.isEmpty()) { + error.postValue(Error.NO_ITEMS); + } else if (filteredMedia.isEmpty()) { + hudVisible = false; + selectedMedia.setValue(filteredMedia); + hudState.setValue(buildHudState()); + } else { + hudVisible = true; + selectedMedia.setValue(filteredMedia); + hudState.setValue(buildHudState()); + } + + updateAttachmentUploads(originalMedia, getSelectedMediaOrDefault()); + }); + }); + } + + void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) { + selectedMedia.setValue(Collections.singletonList(media)); + + repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> { + Util.runOnMain(() -> { + List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); + + if (filteredMedia.isEmpty()) { + error.setValue(Error.ITEM_TOO_LARGE); + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); + } else { + bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID)); + } + + selectedMedia.setValue(filteredMedia); + }); + }); + } + + void onMultiSelectStarted() { + hudVisible = true; + composeVisible = false; + captionVisible = false; + buttonState = ButtonState.COUNT; + railState = RailState.VIEWABLE; + viewOnceState = ViewOnceState.GONE; + + hudState.setValue(buildHudState()); + } + + void onImageEditorStarted() { + page = Page.EDITOR; + hudVisible = true; + captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent()); + buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE; + + if (viewOnceState == ViewOnceState.GONE && viewOnceSupported()) { + viewOnceState = ViewOnceState.DISABLED; + showViewOnceTooltipIfNecessary(viewOnceState); + } else if (!viewOnceSupported()) { + viewOnceState = ViewOnceState.GONE; + } + + railState = !isSms && viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; + composeVisible = viewOnceState != ViewOnceState.ENABLED; + + hudState.setValue(buildHudState()); + } + + void onCameraStarted() { + // TODO: Don't need this? + Page previous = page; + + page = Page.CAMERA; + hudVisible = false; + viewOnceState = ViewOnceState.GONE; + buttonState = ButtonState.COUNT; + + List selected = getSelectedMediaOrDefault(); + + if (previous == Page.EDITOR && lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) { + selected.remove(lastCameraCapture.get()); + selectedMedia.setValue(selected); + BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri()); + cancelUpload(lastCameraCapture.get()); + } + + hudState.setValue(buildHudState()); + } + + void onItemPickerStarted() { + page = Page.ITEM_PICKER; + hudVisible = true; + composeVisible = false; + captionVisible = false; + buttonState = ButtonState.COUNT; + viewOnceState = ViewOnceState.GONE; + railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE; + + lastCameraCapture = Optional.absent(); + + hudState.setValue(buildHudState()); + } + + void onFolderPickerStarted() { + page = Page.FOLDER_PICKER; + hudVisible = true; + composeVisible = false; + captionVisible = false; + buttonState = ButtonState.COUNT; + viewOnceState = ViewOnceState.GONE; + railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE; + + lastCameraCapture = Optional.absent(); + + hudState.setValue(buildHudState()); + } + + void onContactSelectStarted() { + hudVisible = false; + + hudState.setValue(buildHudState()); + } + + void onRevealButtonToggled() { + hudVisible = true; + viewOnceState = viewOnceState == ViewOnceState.ENABLED ? ViewOnceState.DISABLED : ViewOnceState.ENABLED; + composeVisible = viewOnceState != ViewOnceState.ENABLED; + railState = viewOnceState == ViewOnceState.ENABLED || isSms ? RailState.GONE : RailState.INTERACTIVE; + captionVisible = false; + + List uncaptioned = Stream.of(getSelectedMediaOrDefault()) + .map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getDuration(), m.isBorderless(), m.getBucketId(), Optional.absent(), Optional.absent())) + .toList(); + + selectedMedia.setValue(uncaptioned); + position.setValue(position.getValue() != null ? position.getValue() : 0); + hudState.setValue(buildHudState()); + } + + void onKeyboardHidden(boolean isSms) { + if (page != Page.EDITOR) return; + + composeVisible = (viewOnceState != ViewOnceState.ENABLED); + buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE; + + if (isSms) { + railState = RailState.GONE; + captionVisible = false; + } else { + railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; + + if (getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent())) { + captionVisible = true; + } + } + + hudState.setValue(buildHudState()); + } + + void onKeyboardShown(boolean isComposeFocused, boolean isCaptionFocused, boolean isSms) { + if (page != Page.EDITOR) return; + + if (isSms) { + railState = RailState.GONE; + composeVisible = (viewOnceState == ViewOnceState.GONE); + captionVisible = false; + buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE; + } else { + if (isCaptionFocused) { + railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; + composeVisible = false; + captionVisible = true; + buttonState = ButtonState.GONE; + } else if (isComposeFocused) { + railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE; + composeVisible = (viewOnceState != ViewOnceState.ENABLED); + captionVisible = false; + buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE; + } + } + + hudState.setValue(buildHudState()); + } + + void onBodyChanged(@NonNull CharSequence body) { + this.body = body; + } + + void onFolderSelected(@NonNull String bucketId) { + this.bucketId.setValue(bucketId); + bucketMedia.setValue(Collections.emptyList()); + } + + void onPageChanged(int position) { + if (position < 0 || position >= getSelectedMediaOrDefault().size()) { + Log.w(TAG, "Tried to move to an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); + return; + } + + this.position.setValue(position); + } + + void onMediaItemRemoved(@NonNull Context context, int position) { + if (position < 0 || position >= getSelectedMediaOrDefault().size()) { + Log.w(TAG, "Tried to remove an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); + return; + } + + Media removed = getSelectedMediaOrDefault().remove(position); + + if (removed != null && BlobProvider.isAuthority(removed.getUri())) { + BlobProvider.getInstance().delete(context, removed.getUri()); + } + + cancelUpload(removed); + + if (page == Page.EDITOR && getSelectedMediaOrDefault().isEmpty()) { + error.setValue(Error.NO_ITEMS); + } else { + selectedMedia.setValue(selectedMedia.getValue()); + } + + if (getSelectedMediaOrDefault().size() > 0) { + this.position.setValue(Math.min(position, getSelectedMediaOrDefault().size() - 1)); + } + + if (getSelectedMediaOrDefault().size() == 1) { + viewOnceState = viewOnceSupported() ? ViewOnceState.DISABLED : ViewOnceState.GONE; + } + + hudState.setValue(buildHudState()); + } + + void onVideoBeginEdit(@NonNull Uri uri) { + cancelUpload(new Media(uri, "", 0, 0, 0, 0, 0, false, Optional.absent(), Optional.absent(), Optional.absent())); + } + + void onMediaCaptured(@NonNull Media media) { + lastCameraCapture = Optional.of(media); + + List selected = selectedMedia.getValue(); + + if (selected == null) { + selected = new LinkedList<>(); + } + + if (selected.size() >= maxSelection) { + error.setValue(Error.TOO_MANY_ITEMS); + return; + } + + selected.add(media); + selectedMedia.setValue(selected); + position.setValue(selected.size() - 1); + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); + + startUpload(media); + } + + void onCaptionChanged(@NonNull String newCaption) { + if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) { + selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption); + } + } + + void onCameraControlsInitialized() { + repository.getMostRecentItem(application, mostRecentMedia::postValue); + } + + void onMeteredConnectivityStatusChanged(boolean metered) { + Log.i(TAG, "Metered connectivity status set to: " + metered); + + meteredConnection = metered; + preUploadEnabled = shouldPreUpload(application, metered, isSms, recipient); + } + + void saveDrawState(@NonNull Map state) { + savedDrawState.clear(); + savedDrawState.putAll(state); + } + + @NonNull LiveData onSendClicked(Map modelsToTransform, @NonNull List recipients, @NonNull List mentions) { + if (isSms && recipients.size() > 0) { + throw new IllegalStateException("Provided recipients to send to, but this is SMS!"); + } + + MutableLiveData result = new MutableLiveData<>(); + String trimmedBody = isViewOnce() ? "" : body.toString().trim(); + List initialMedia = getSelectedMediaOrDefault(); + List trimmedMentions = isViewOnce() ? Collections.emptyList() : mentions; + ArrayList currentListUri = new ArrayList<>(); + Preconditions.checkState(initialMedia.size() > 0, "No media to send!"); + + MediaRepository.transformMedia(application, initialMedia, modelsToTransform, (oldToNew) -> { + List updatedMedia = new ArrayList<>(oldToNew.values()); + + for (Media media : updatedMedia){ + currentListUri.add(media.getUri().toString()); + Log.w(TAG, media.getUri().toString() + " : " + media.getTransformProperties().transform(t->"" + t.isVideoTrim()).or("null")); + } + + if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) { + Log.i(TAG, "SMS or local self-send. Skipping pre-upload."); + result.postValue(MediaSendActivityResult.forTraditionalSend(recipient.getId(), updatedMedia, trimmedBody, transport, isViewOnce(), trimmedMentions, currentListUri)); + return; + } + + MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(application, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize); + String splitBody = splitMessage.getBody(); + + if (splitMessage.getTextSlide().isPresent()) { + Slide slide = splitMessage.getTextSlide().get(); + uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), 0, slide.isBorderless(), Optional.absent(), Optional.absent(), Optional.absent()), recipient); + } + + uploadRepository.applyMediaUpdates(oldToNew, recipient); + uploadRepository.updateCaptions(updatedMedia); + uploadRepository.updateDisplayOrder(updatedMedia); + uploadRepository.getPreUploadResults(uploadResults -> { + if (recipients.size() > 0) { + sendMessages(recipients, splitBody, uploadResults, trimmedMentions); + uploadRepository.deleteAbandonedAttachments(); + result.postValue(null); + } else { + result.postValue(MediaSendActivityResult.forPreUpload(recipient.getId(), uploadResults, splitBody, transport, isViewOnce(), trimmedMentions, currentListUri)); + } + }); + }); + + sentMedia = true; + + return result; + } + + @NonNull Map getDrawState() { + return savedDrawState; + } + + @NonNull LiveData> getSelectedMedia() { + return selectedMedia; + } + + @NonNull LiveData> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { + repository.getMediaInBucket(context, bucketId, bucketMedia::postValue); + return bucketMedia; + } + + @NonNull LiveData> getFolders(@NonNull Context context) { + repository.getFolders(context, folders::postValue); + return folders; + } + + @NonNull LiveData> getMostRecentMediaItem() { + return mostRecentMedia; + } + + @NonNull CharSequence getBody() { + return body; + } + + @NonNull LiveData getPosition() { + return position; + } + + @NonNull LiveData getBucketId() { + return bucketId; + } + + @NonNull LiveData getError() { + return error; + } + + @NonNull LiveData getEvents() { + return event; + } + + @NonNull LiveData getHudState() { + return hudState; + } + + int getMaxSelection() { + return maxSelection; + } + + boolean isViewOnce() { + return viewOnceState == ViewOnceState.ENABLED; + } + + @NonNull MediaConstraints getMediaConstraints() { + return mediaConstraints; + } + + private @NonNull List getSelectedMediaOrDefault() { + return selectedMedia.getValue() == null ? Collections.emptyList() + : selectedMedia.getValue(); + } + + private @NonNull List getFilteredMedia(@NonNull Context context, @NonNull List media, @NonNull MediaConstraints mediaConstraints) { + return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) || + MediaUtil.isImageType(m.getMimeType()) || + MediaUtil.isVideoType(m.getMimeType())) + .filter(m -> { + return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) || + (MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) || + (MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getUncompressedVideoMaxSize(context)); + }).toList(); + + } + + private HudState buildHudState() { + List selectedMedia = getSelectedMediaOrDefault(); + int selectionCount = selectedMedia.size(); + ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState; + boolean updatedCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent())); + + return new HudState(hudVisible, composeVisible, updatedCaptionVisible, selectionCount, updatedButtonState, railState, viewOnceState); + } + + private void clearPersistedMedia() { + Stream.of(getSelectedMediaOrDefault()) + .map(Media::getUri) + .filter(BlobProvider::isAuthority) + .forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri)); + } + + private boolean viewOnceSupported() { + return !isSms && (recipient == null || !recipient.isSelf()) && mediaSupportsRevealableMessage(getSelectedMediaOrDefault()); + } + + private boolean mediaSupportsRevealableMessage(@NonNull List media) { + if (media.size() != 1) return false; + return MediaUtil.isImageOrVideoType(media.get(0).getMimeType()); + } + + private void showViewOnceTooltipIfNecessary(@NonNull ViewOnceState viewOnceState) { + if (viewOnceState == ViewOnceState.DISABLED && !TextSecurePreferences.hasSeenViewOnceTooltip(application)) { + event.postValue(Event.VIEW_ONCE_TOOLTIP); + } + } + + private void updateAttachmentUploads(@NonNull List oldMedia, @NonNull List newMedia) { + if (!preUploadEnabled) return; + + DiffHelper.Result result = DiffHelper.calculate(oldMedia, newMedia); + + uploadRepository.cancelUpload(result.getRemoved()); + uploadRepository.startUpload(result.getInserted(), recipient); + } + + private void cancelUpload(@NonNull Media media) { + uploadRepository.cancelUpload(media); + } + + private void startUpload(@NonNull Media media) { + if (!preUploadEnabled) return; + uploadRepository.startUpload(media, recipient); + } + + @WorkerThread + private void sendMessages(@NonNull List recipients, @NonNull String body, @NonNull Collection preUploadResults, @NonNull List mentions) { + List messages = new ArrayList<>(recipients.size()); + + for (Recipient recipient : recipients) { + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, + body, + Collections.emptyList(), + System.currentTimeMillis(), + -1, + recipient.getExpireMessages() * 1000, + isViewOnce(), + ThreadDatabase.DistributionTypes.DEFAULT, + null, + Collections.emptyList(), + Collections.emptyList(), + mentions, + Collections.emptyList(), + Collections.emptyList()); + + messages.add(new OutgoingSecureMediaMessage(message)); + + // XXX We must do this to avoid sending out messages to the same recipient with the same + // sentTimestamp. If we do this, they'll be considered dupes by the receiver. + Util.sleep(5); + } + + MessageSender.sendMediaBroadcast(application, messages, preUploadResults); + } + + private static boolean shouldPreUpload(@NonNull Context context, boolean metered, boolean isSms, @Nullable Recipient recipient) { + return !metered && !isSms && !MessageSender.isLocalSelfSend(context, recipient, isSms); + } + + @Override + protected void onCleared() { + if (!sentMedia) { + clearPersistedMedia(); + uploadRepository.cancelAllUploads(); + uploadRepository.deleteAbandonedAttachments(); + } + } + + boolean isSms() { + return transport.isSms(); + } + + enum Error { + ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS, ONLY_ITEM_TOO_LARGE + } + + enum Event { + VIEW_ONCE_TOOLTIP + } + + enum Page { + CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, CONTACT_SELECT, UNKNOWN + } + + enum ButtonState { + COUNT, SEND, CONTINUE, GONE + } + + enum RailState { + INTERACTIVE, VIEWABLE, GONE + } + + enum ViewOnceState { + ENABLED, DISABLED, GONE + } + + static class HudState { + + private final boolean hudVisible; + private final boolean composeVisible; + private final boolean captionVisible; + private final int selectionCount; + private final ButtonState buttonState; + private final RailState railState; + private final ViewOnceState viewOnceState; + + HudState(boolean hudVisible, + boolean composeVisible, + boolean captionVisible, + int selectionCount, + @NonNull ButtonState buttonState, + @NonNull RailState railState, + @NonNull ViewOnceState viewOnceState) + { + this.hudVisible = hudVisible; + this.composeVisible = composeVisible; + this.captionVisible = captionVisible; + this.selectionCount = selectionCount; + this.buttonState = buttonState; + this.railState = railState; + this.viewOnceState = viewOnceState; + } + + public boolean isHudVisible() { + return hudVisible; + } + + public boolean isComposeVisible() { + return hudVisible && composeVisible; + } + + public boolean isCaptionVisible() { + return hudVisible && captionVisible; + } + + public int getSelectionCount() { + return selectionCount; + } + + public @NonNull ButtonState getButtonState() { + return buttonState; + } + + public @NonNull RailState getRailState() { + return hudVisible ? railState : RailState.GONE; + } + + public @NonNull ViewOnceState getViewOnceState() { + return hudVisible ? viewOnceState : ViewOnceState.GONE; + } + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final Application application; + private final MediaRepository repository; + + Factory(@NonNull Application application, @NonNull MediaRepository repository) { + this.application = application; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new MediaSendViewModel(application, repository, new MediaUploadRepository(application))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaTransform.java new file mode 100644 index 00000000..34a3e67a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaTransform.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +public interface MediaTransform { + + @WorkerThread + @NonNull Media transform(@NonNull Context context, @NonNull Media media); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java new file mode 100644 index 00000000..0f003d19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.TextSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +/** + * Manages the proactive upload of media during the selection process. Upload/cancel operations + * need to be serialized, because they're asynchronous operations that depend on ordered completion. + * + * For example, if we begin upload of a {@link Media) but then immediately cancel it (before it was + * enqueued on the {@link JobManager}), we need to wait until we have the jobId to cancel. This + * class manages everything by using a single thread executor. + * + * This also means that unlike most repositories, the class itself is stateful. Keep that in mind + * when using it. + */ +class MediaUploadRepository { + + private static final String TAG = Log.tag(MediaUploadRepository.class); + + private final Context context; + private final LinkedHashMap uploadResults; + private final Executor executor; + + MediaUploadRepository(@NonNull Context context) { + this.context = context; + this.uploadResults = new LinkedHashMap<>(); + this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-MediaUpload"); + } + + void startUpload(@NonNull Media media, @Nullable Recipient recipient) { + executor.execute(() -> uploadMediaInternal(media, recipient)); + } + + void startUpload(@NonNull Collection mediaItems, @Nullable Recipient recipient) { + executor.execute(() -> { + for (Media media : mediaItems) { + cancelUploadInternal(media); + uploadMediaInternal(media, recipient); + } + }); + } + + /** + * Given a map of old->new, cancel medias that were changed and upload their replacements. Will + * also upload any media in the map that wasn't yet uploaded. + */ + void applyMediaUpdates(@NonNull Map oldToNew, @Nullable Recipient recipient) { + executor.execute(() -> { + for (Map.Entry entry : oldToNew.entrySet()) { + + boolean same = entry.getKey().equals(entry.getValue()) && (!entry.getValue().getTransformProperties().isPresent() || !entry.getValue().getTransformProperties().get().isVideoEdited()); + if (!same || !uploadResults.containsKey(entry.getValue())) { + cancelUploadInternal(entry.getKey()); + uploadMediaInternal(entry.getValue(), recipient); + } + } + }); + } + + void cancelUpload(@NonNull Media media) { + executor.execute(() -> cancelUploadInternal(media)); + } + + void cancelUpload(@NonNull Collection mediaItems) { + executor.execute(() -> { + for (Media media : mediaItems) { + cancelUploadInternal(media); + } + }); + } + + void cancelAllUploads() { + executor.execute(() -> { + for (Media media : new HashSet<>(uploadResults.keySet())) { + cancelUploadInternal(media); + } + }); + } + + void getPreUploadResults(@NonNull Callback> callback) { + executor.execute(() -> callback.onResult(uploadResults.values())); + } + + void updateCaptions(@NonNull List updatedMedia) { + executor.execute(() -> updateCaptionsInternal(updatedMedia)); + } + + void updateDisplayOrder(@NonNull List mediaInOrder) { + executor.execute(() -> updateDisplayOrderInternal(mediaInOrder)); + } + + void deleteAbandonedAttachments() { + executor.execute(() -> { + int deleted = DatabaseFactory.getAttachmentDatabase(context).deleteAbandonedPreuploadedAttachments(); + Log.i(TAG, "Deleted " + deleted + " abandoned attachments."); + }); + } + + @WorkerThread + private void uploadMediaInternal(@NonNull Media media, @Nullable Recipient recipient) { + Attachment attachment = asAttachment(context, media); + PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient); + + if (result != null) { + uploadResults.put(media, result); + } else { + Log.w(TAG, "Failed to upload media with URI: " + media.getUri()); + } + } + + private void cancelUploadInternal(@NonNull Media media) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + PreUploadResult result = uploadResults.get(media); + + if (result != null) { + Stream.of(result.getJobIds()).forEach(jobManager::cancel); + uploadResults.remove(media); + } + } + + @WorkerThread + private void updateCaptionsInternal(@NonNull List updatedMedia) { + AttachmentDatabase db = DatabaseFactory.getAttachmentDatabase(context); + + for (Media updated : updatedMedia) { + PreUploadResult result = uploadResults.get(updated); + + if (result != null) { + db.updateAttachmentCaption(result.getAttachmentId(), updated.getCaption().orNull()); + } else { + Log.w(TAG,"When updating captions, no pre-upload result could be found for media with URI: " + updated.getUri()); + } + } + } + + @WorkerThread + private void updateDisplayOrderInternal(@NonNull List mediaInOrder) { + Map orderMap = new HashMap<>(); + Map orderedUploadResults = new LinkedHashMap<>(); + + for (int i = 0; i < mediaInOrder.size(); i++) { + Media media = mediaInOrder.get(i); + PreUploadResult result = uploadResults.get(media); + + if (result != null) { + orderMap.put(result.getAttachmentId(), i); + orderedUploadResults.put(media, result); + } else { + Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: " + media.getUri()); + } + } + + DatabaseFactory.getAttachmentDatabase(context).updateDisplayOrder(orderMap); + + if (orderedUploadResults.size() == uploadResults.size()) { + uploadResults.clear(); + uploadResults.putAll(orderedUploadResults); + } + } + + public static @NonNull Attachment asAttachment(@NonNull Context context, @NonNull Media media) { + if (MediaUtil.isVideoType(media.getMimeType())) { + return new VideoSlide(context, media.getUri(), media.getSize(), media.getWidth(), media.getHeight(), media.getCaption().orNull(), media.getTransformProperties().orNull()).asAttachment(); + } else if (MediaUtil.isGif(media.getMimeType())) { + return new GifSlide(context, media.getUri(), media.getSize(), media.getWidth(), media.getHeight(), media.isBorderless(), media.getCaption().orNull()).asAttachment(); + } else if (MediaUtil.isImageType(media.getMimeType())) { + return new ImageSlide(context, media.getUri(), media.getMimeType(), media.getSize(), media.getWidth(), media.getHeight(), media.isBorderless(), media.getCaption().orNull(), null).asAttachment(); + } else if (MediaUtil.isTextType(media.getMimeType())) { + return new TextSlide(context, media.getUri(), null, media.getSize()).asAttachment(); + } else { + throw new AssertionError("Unexpected mimeType: " + media.getMimeType()); + } + } + + interface Callback { + void onResult(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java new file mode 100644 index 00000000..54a15e70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.core.net.ConnectivityManagerCompat; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.thoughtcrime.securesms.util.ServiceUtil; + +/** + * Lifecycle-bound observer for whether or not the active network connection is metered. + */ +class MeteredConnectivityObserver extends BroadcastReceiver implements DefaultLifecycleObserver { + + private final Context context; + private final ConnectivityManager connectivityManager; + private final MutableLiveData metered; + + @MainThread + MeteredConnectivityObserver(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) { + this.context = context; + this.connectivityManager = ServiceUtil.getConnectivityManager(context); + this.metered = new MutableLiveData<>(); + + this.metered.setValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager)); + lifecycleOwner.getLifecycle().addObserver(this); + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) { + context.registerReceiver(this, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + context.unregisterReceiver(this); + } + + @Override + public void onReceive(Context context, Intent intent) { + metered.postValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager)); + } + + /** + * @return An observable value that is false when the network is unmetered, and true if the + * network is either metered or unavailable. + */ + @NonNull LiveData isMetered() { + return metered; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/OrderEnforcer.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/OrderEnforcer.java new file mode 100644 index 00000000..65974ac0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/OrderEnforcer.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.mediasend; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Stack; + +@SuppressWarnings("ConstantConditions") +public class OrderEnforcer { + + private final Map stages = new LinkedHashMap<>(); + + public OrderEnforcer(@NonNull E... stages) { + for (E stage : stages) { + this.stages.put(stage, new StageDetails()); + } + } + + public synchronized void run(@NonNull E stage, Runnable r) { + if (isCompletedThrough(stage)) { + r.run(); + } else { + stages.get(stage).addAction(r); + } + } + + public synchronized void markCompleted(@NonNull E stage) { + stages.get(stage).markCompleted(); + + for (E s : stages.keySet()) { + StageDetails details = stages.get(s); + + if (details.isCompleted()) { + while (details.hasAction()) { + details.popAction().run(); + } + } else { + break; + } + } + } + + public synchronized void reset() { + for (StageDetails details : stages.values()) { + details.reset(); + } + } + + private boolean isCompletedThrough(@NonNull E stage) { + for (E s : stages.keySet()) { + if (s.equals(stage)) { + return stages.get(s).isCompleted(); + } else if (!stages.get(s).isCompleted()) { + return false; + } + } + return false; + } + + private static class StageDetails { + private boolean completed = false; + private Stack actions = new Stack<>(); + + boolean hasAction() { + return !actions.isEmpty(); + } + + @Nullable Runnable popAction() { + return actions.pop(); + } + + void addAction(@NonNull Runnable runnable) { + actions.push(runnable); + } + + void reset() { + actions.clear(); + completed = false; + } + + boolean isCompleted() { + return completed; + } + + void markCompleted() { + completed = true; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/SimpleAnimationListener.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/SimpleAnimationListener.java new file mode 100644 index 00000000..d16be50e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/SimpleAnimationListener.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.view.animation.Animation; + +/** + * Basic implementation of {@link android.view.animation.Animation.AnimationListener} with empty + * implementation so you don't have to override every method. + */ +public class SimpleAnimationListener implements Animation.AnimationListener { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java new file mode 100644 index 00000000..58a5ed7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.whispersystems.libsignal.util.guava.Optional; + +public final class VideoTrimTransform implements MediaTransform { + + private final MediaSendVideoFragment.Data data; + + VideoTrimTransform(@NonNull MediaSendVideoFragment.Data data) { + this.data = data; + } + + @WorkerThread + @Override + public @NonNull Media transform(@NonNull Context context, @NonNull Media media) { + return new Media(media.getUri(), + media.getMimeType(), + media.getDate(), + media.getWidth(), + media.getHeight(), + media.getSize(), + media.getDuration(), + media.isBorderless(), + media.getBucketId(), + media.getCaption(), + Optional.of(new AttachmentDatabase.TransformProperties(false, data.durationEdited, data.startTimeUs, data.endTimeUs))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java new file mode 100644 index 00000000..28646bf9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.camera.core.ImageCapture; + +import org.thoughtcrime.securesms.R; + +import java.util.Arrays; +import java.util.List; + +public final class CameraXFlashToggleView extends AppCompatImageView { + + private static final String STATE_FLASH_INDEX = "flash.toggle.state.flash.index"; + private static final String STATE_SUPPORT_AUTO = "flash.toggle.state.support.auto"; + private static final String STATE_PARENT = "flash.toggle.state.parent"; + + private static final int[] FLASH_AUTO = { R.attr.state_flash_auto }; + private static final int[] FLASH_OFF = { R.attr.state_flash_off }; + private static final int[] FLASH_ON = { R.attr.state_flash_on }; + private static final int[][] FLASH_ENUM = { FLASH_AUTO, FLASH_OFF, FLASH_ON }; + private static final List FLASH_MODES = Arrays.asList(FlashMode.AUTO, FlashMode.OFF, FlashMode.ON); + private static final FlashMode FLASH_FALLBACK = FlashMode.OFF; + + private boolean supportsFlashModeAuto = true; + private int flashIndex; + private OnFlashModeChangedListener flashModeChangedListener; + + public CameraXFlashToggleView(Context context) { + this(context, null); + } + + public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + super.setOnClickListener((v) -> setFlash(FLASH_MODES.get((flashIndex + 1) % FLASH_ENUM.length).getFlashMode())); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] extra = FLASH_ENUM[flashIndex]; + final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length); + mergeDrawableStates(drawableState, extra); + return drawableState; + } + + @Override + public void setOnClickListener(@Nullable OnClickListener l) { + throw new IllegalStateException("This View does not support custom click listeners."); + } + + public void setAutoFlashEnabled(boolean isAutoEnabled) { + supportsFlashModeAuto = isAutoEnabled; + setFlash(FLASH_MODES.get(flashIndex).getFlashMode()); + } + + public void setFlash(@ImageCapture.FlashMode int mode) { + FlashMode flashMode = FlashMode.fromImageCaptureFlashMode(mode); + + flashIndex = resolveFlashIndex(FLASH_MODES.indexOf(flashMode), supportsFlashModeAuto); + refreshDrawableState(); + notifyListener(); + } + + public void setOnFlashModeChangedListener(@Nullable OnFlashModeChangedListener listener) { + this.flashModeChangedListener = listener; + } + + @Override + protected Parcelable onSaveInstanceState() { + Parcelable parentState = super.onSaveInstanceState(); + Bundle bundle = new Bundle(); + + bundle.putParcelable(STATE_PARENT, parentState); + bundle.putInt(STATE_FLASH_INDEX, flashIndex); + bundle.putBoolean(STATE_SUPPORT_AUTO, supportsFlashModeAuto); + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state instanceof Bundle) { + Bundle savedState = (Bundle) state; + + supportsFlashModeAuto = savedState.getBoolean(STATE_SUPPORT_AUTO); + setFlash(FLASH_MODES.get( + resolveFlashIndex(savedState.getInt(STATE_FLASH_INDEX), supportsFlashModeAuto)).getFlashMode() + ); + + super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT)); + } else { + super.onRestoreInstanceState(state); + } + } + + private void notifyListener() { + if (flashModeChangedListener == null) return; + + flashModeChangedListener.flashModeChanged(FLASH_MODES.get(flashIndex).getFlashMode()); + } + + private static int resolveFlashIndex(int desiredFlashIndex, boolean supportsFlashModeAuto) { + if (isIllegalFlashIndex(desiredFlashIndex)) { + throw new IllegalArgumentException("Unsupported index: " + desiredFlashIndex); + } + if (isUnsupportedFlashMode(desiredFlashIndex, supportsFlashModeAuto)) { + return FLASH_MODES.indexOf(FLASH_FALLBACK); + } + return desiredFlashIndex; + } + + private static boolean isIllegalFlashIndex(int desiredFlashIndex) { + return desiredFlashIndex < 0 || desiredFlashIndex > FLASH_ENUM.length; + } + + private static boolean isUnsupportedFlashMode(int desiredFlashIndex, boolean supportsFlashModeAuto) { + return FLASH_MODES.get(desiredFlashIndex) == FlashMode.AUTO && !supportsFlashModeAuto; + } + + public interface OnFlashModeChangedListener { + void flashModeChanged(@ImageCapture.CaptureMode int flashMode); + } + + private enum FlashMode { + + AUTO(ImageCapture.FLASH_MODE_AUTO), + OFF(ImageCapture.FLASH_MODE_OFF), + ON(ImageCapture.FLASH_MODE_ON); + + private final @ImageCapture.FlashMode int flashMode; + + FlashMode(@ImageCapture.FlashMode int flashMode) { + this.flashMode = flashMode; + } + + @ImageCapture.FlashMode int getFlashMode() { + return flashMode; + } + + private static FlashMode fromImageCaptureFlashMode(@ImageCapture.FlashMode int flashMode) { + for (FlashMode mode : values()) { + if (mode.getFlashMode() == flashMode) { + return mode; + } + } + + throw new AssertionError(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlacklist.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlacklist.java new file mode 100644 index 00000000..325743de --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlacklist.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.os.Build; + +import java.util.HashSet; +import java.util.Set; + +public final class CameraXModelBlacklist { + private static final Set BLACKLIST = new HashSet() {{ + // Pixel 4 + add("Pixel 4"); + add("Pixel 4 XL"); + + // Huawei Mate 10 + add("ALP-L29"); + add("ALP-L09"); + add("ALP-AL00"); + + // Huawei Mate 10 Pro + add("BLA-L29"); + add("BLA-L09"); + add("BLA-AL00"); + add("BLA-A09"); + + // Huawei Mate 20 + add("HMA-L29"); + add("HMA-L09"); + add("HMA-LX9"); + add("HMA-AL00"); + + // Huawei Mate 20 Pro + add("LYA-L09"); + add("LYA-L29"); + add("LYA-AL00"); + add("LYA-AL10"); + add("LYA-TL00"); + add("LYA-L0C"); + + // Huawei Mate 20 X + add("EVR-L29"); + add("EVR-AL00"); + add("EVR-TL00"); + + // Huawei P20 + add("EML-L29C"); + add("EML-L09C"); + add("EML-AL00"); + add("EML-TL00"); + add("EML-L29"); + add("EML-L09"); + + // Huawei P20 Pro + add("CLT-L29C"); + add("CLT-L29"); + add("CLT-L09C"); + add("CLT-L09"); + add("CLT-AL00"); + add("CLT-AL01"); + add("CLT-TL01"); + add("CLT-AL00L"); + add("CLT-L04"); + add("HW-01K"); + + // Huawei P30 + add("ELE-L29"); + add("ELE-L09"); + add("ELE-AL00"); + add("ELE-TL00"); + add("ELE-L04"); + + // Huawei P30 Pro + add("VOG-L29"); + add("VOG-L09"); + add("VOG-AL00"); + add("VOG-TL00"); + add("VOG-L04"); + add("VOG-AL10"); + + // Huawei Honor 10 + add("COL-AL10"); + add("COL-L29"); + add("COL-L19"); + + // Huawei Honor 20 + add("YAL-L21"); + add("YAL-AL00"); + add("YAL-TL00"); + + // Samsung Galaxy S6 + add("SM-G920F"); + + // Honor View 10 + add("BKL-AL20"); + add("BKL-L04"); + add("BKL-L09"); + add("BKL-AL00"); + + // Honor View 20 + add("PCT-AL10"); + add("PCT-TL10"); + add("PCT-L29"); + + // Honor Play + add("COR-L29"); + add("COR-L09"); + add("COR-AL00"); + add("COR-AL10"); + add("COR-TL10"); + }}; + + private CameraXModelBlacklist() { + } + + public static boolean isBlacklisted() { + return BLACKLIST.contains(Build.MODEL); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java new file mode 100644 index 00000000..45b6cd22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java @@ -0,0 +1,283 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.hardware.Camera; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; +import android.os.Build; +import android.util.Pair; +import android.util.Rational; +import android.util.Size; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.camera2.internal.compat.CameraManagerCompat; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageProxy; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.Stopwatch; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Locale; + +public class CameraXUtil { + + private static final String TAG = Log.tag(CameraXUtil.class); + + @RequiresApi(21) + private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL}; + + @RequiresApi(24) + private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_24 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3}; + + @RequiresApi(28) + private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_28 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3}; + + @SuppressWarnings("SuspiciousNameCombination") + @RequiresApi(21) + public static ImageResult toJpeg(@NonNull ImageProxy image, boolean flip) throws IOException { + ImageProxy.PlaneProxy[] planes = image.getPlanes(); + ByteBuffer buffer = planes[0].getBuffer(); + Rect cropRect = shouldCropImage(image) ? image.getCropRect() : null; + byte[] data = new byte[buffer.capacity()]; + int rotation = image.getImageInfo().getRotationDegrees(); + + buffer.get(data); + + try { + Pair dimens = BitmapUtil.getDimensions(new ByteArrayInputStream(data)); + + if (dimens.first != image.getWidth() && dimens.second != image.getHeight()) { + Log.w(TAG, String.format(Locale.ENGLISH, "Decoded image dimensions differed from stated dimensions! Stated: %d x %d, Decoded: %d x %d", + image.getWidth(), image.getHeight(), dimens.first, dimens.second)); + Log.w(TAG, "Ignoring the stated rotation and rotating the crop rect 90 degrees (stated rotation is " + rotation + " degrees)."); + + rotation = 0; + + if (cropRect != null) { + cropRect = new Rect(cropRect.top, cropRect.left, cropRect.bottom, cropRect.right); + } + } + } catch (BitmapDecodingException e) { + Log.w(TAG, "Failed to decode!", e); + } + + if (cropRect != null || rotation != 0 || flip) { + data = transformByteArray(data, cropRect, rotation, flip); + } + + int width = cropRect != null ? (cropRect.right - cropRect.left) : image.getWidth(); + int height = cropRect != null ? (cropRect.bottom - cropRect.top) : image.getHeight(); + + if (rotation == 90 || rotation == 270) { + int swap = width; + + width = height; + height = swap; + } + + return new ImageResult(data, width, height); + } + + public static boolean isSupported() { + return Build.VERSION.SDK_INT >= 21 && !CameraXModelBlacklist.isBlacklisted(); + } + + public static int toCameraDirectionInt(int facing) { + if (facing == CameraSelector.LENS_FACING_FRONT) { + return Camera.CameraInfo.CAMERA_FACING_FRONT; + } else { + return Camera.CameraInfo.CAMERA_FACING_BACK; + } + } + + public static int toLensFacing(@CameraSelector.LensFacing int cameraDirectionInt) { + if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) { + return CameraSelector.LENS_FACING_FRONT; + } else { + return CameraSelector.LENS_FACING_BACK; + } + } + + public static @NonNull @ImageCapture.CaptureMode int getOptimalCaptureMode() { + return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY + : ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY; + } + + public static int getIdealResolution(int displayWidth, int displayHeight) { + int maxDisplay = Math.max(displayWidth, displayHeight); + return Math.max(maxDisplay, 1920); + } + + @TargetApi(21) + public static @NonNull Size buildResolutionForRatio(int longDimension, @NonNull Rational ratio, boolean isPortrait) { + int shortDimension = longDimension * ratio.getDenominator() / ratio.getNumerator(); + + if (isPortrait) { + return new Size(shortDimension, longDimension); + } else { + return new Size(longDimension, shortDimension); + } + } + + private static byte[] transformByteArray(@NonNull byte[] data, @Nullable Rect cropRect, int rotation, boolean flip) throws IOException { + Stopwatch stopwatch = new Stopwatch("transform"); + Bitmap in; + + if (cropRect != null) { + BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(data, 0, data.length, false); + in = decoder.decodeRegion(cropRect, new BitmapFactory.Options()); + decoder.recycle(); + stopwatch.split("crop"); + } else { + in = BitmapFactory.decodeByteArray(data, 0, data.length); + } + + Bitmap out = in; + + if (rotation != 0 || flip) { + Matrix matrix = new Matrix(); + matrix.postRotate(rotation); + + if (flip) { + matrix.postScale(-1, 1); + matrix.postTranslate(in.getWidth(), 0); + } + + out = Bitmap.createBitmap(in, 0, 0, in.getWidth(), in.getHeight(), matrix, true); + } + + byte[] transformedData = toJpegBytes(out); + stopwatch.split("transcode"); + + in.recycle(); + out.recycle(); + + stopwatch.stop(TAG); + + return transformedData; + } + + @RequiresApi(21) + private static boolean shouldCropImage(@NonNull ImageProxy image) { + Size sourceSize = new Size(image.getWidth(), image.getHeight()); + Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height()); + + return !targetSize.equals(sourceSize); + } + + private static byte[] toJpegBytes(@NonNull Bitmap bitmap) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)) { + throw new IOException("Failed to compress bitmap."); + } + + return out.toByteArray(); + } + + @RequiresApi(21) + public static boolean isMixedModeSupported(@NonNull Context context) { + return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; + } + + @RequiresApi(21) + public static int getLowestSupportedHardwareLevel(@NonNull Context context) { + @SuppressLint("RestrictedApi") CameraManager cameraManager = CameraManagerCompat.from(context).unwrap(); + + try { + int supported = maxHardwareLevel(); + + for (String cameraId : cameraManager.getCameraIdList()) { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); + Integer hwLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL); + + if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; + } + + supported = smallerHardwareLevel(supported, hwLevel); + } + + return supported; + } catch (CameraAccessException e) { + Log.w(TAG, "Failed to enumerate cameras", e); + + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; + } + } + + @RequiresApi(21) + private static int maxHardwareLevel() { + if (Build.VERSION.SDK_INT >= 24) return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3; + else return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL; + } + + @RequiresApi(21) + private static int smallerHardwareLevel(int levelA, int levelB) { + + int[] hardwareInfoOrdering = getHardwareInfoOrdering(); + for (int hwInfo : hardwareInfoOrdering) { + if (levelA == hwInfo || levelB == hwInfo) return hwInfo; + } + + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; + } + + @RequiresApi(21) + private static int[] getHardwareInfoOrdering() { + if (Build.VERSION.SDK_INT >= 28) return CAMERA_HARDWARE_LEVEL_ORDERING_28; + else if (Build.VERSION.SDK_INT >= 24) return CAMERA_HARDWARE_LEVEL_ORDERING_24; + else return CAMERA_HARDWARE_LEVEL_ORDERING; + } + + public static class ImageResult { + private final byte[] data; + private final int width; + private final int height; + + public ImageResult(@NonNull byte[] data, int width, int height) { + this.data = data; + this.width = width; + this.height = height; + } + + public byte[] getData() { + return data; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java new file mode 100644 index 00000000..d0bb6e39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import androidx.annotation.NonNull; + +import java.util.HashSet; +import java.util.Set; + +/** + * A set of {@link android.os.Build#MODEL} that are known to both benefit from + * {@link androidx.camera.core.ImageCapture.CaptureMode#MAX_QUALITY} and execute it quickly. + * + */ +public class FastCameraModels { + + private static final Set MODELS = new HashSet() {{ + add("Pixel 2"); + add("Pixel 2 XL"); + add("Pixel 3"); + add("Pixel 3 XL"); + add("Pixel 3a"); + add("Pixel 3a XL"); + }}; + + /** + * @param model Should be a {@link android.os.Build#MODEL}. + */ + public static boolean contains(@NonNull String model) { + return MODELS.contains(model); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java new file mode 100644 index 00000000..28ffd79f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class BasicMegaphoneView extends FrameLayout { + + private ImageView image; + private TextView titleText; + private TextView bodyText; + private Button actionButton; + private Button secondaryButton; + + private Megaphone megaphone; + private MegaphoneActionController megaphoneListener; + + public BasicMegaphoneView(@NonNull Context context) { + super(context); + init(context); + } + + public BasicMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(@NonNull Context context) { + inflate(context, R.layout.basic_megaphone_view, this); + + this.image = findViewById(R.id.basic_megaphone_image); + this.titleText = findViewById(R.id.basic_megaphone_title); + this.bodyText = findViewById(R.id.basic_megaphone_body); + this.actionButton = findViewById(R.id.basic_megaphone_action); + this.secondaryButton = findViewById(R.id.basic_megaphone_secondary); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) { + megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener); + } + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController megaphoneListener) { + this.megaphone = megaphone; + this.megaphoneListener = megaphoneListener; + + if (megaphone.getImageRes() != 0) { + image.setVisibility(VISIBLE); + image.setImageResource(megaphone.getImageRes()); + } else if (megaphone.getImageRequest() != null) { + image.setVisibility(VISIBLE); + megaphone.getImageRequest().into(image); + } else { + image.setVisibility(GONE); + } + + if (megaphone.getTitle() != 0) { + titleText.setVisibility(VISIBLE); + titleText.setText(megaphone.getTitle()); + } else { + titleText.setVisibility(GONE); + } + + if (megaphone.getBody() != 0) { + bodyText.setVisibility(VISIBLE); + bodyText.setText(megaphone.getBody()); + } else { + bodyText.setVisibility(GONE); + } + + if (megaphone.hasButton()) { + actionButton.setVisibility(VISIBLE); + actionButton.setText(megaphone.getButtonText()); + actionButton.setOnClickListener(v -> { + if (megaphone.getButtonClickListener() != null) { + megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener); + } + }); + } else { + actionButton.setVisibility(GONE); + } + + if (megaphone.canSnooze() || megaphone.hasSecondaryButton()) { + secondaryButton.setVisibility(VISIBLE); + + if (megaphone.canSnooze()) { + secondaryButton.setOnClickListener(v -> { + megaphoneListener.onMegaphoneSnooze(megaphone.getEvent()); + + if (megaphone.getSnoozeListener() != null) { + megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener); + } + }); + } else { + secondaryButton.setText(megaphone.getSecondaryButtonText()); + secondaryButton.setOnClickListener(v -> { + if (megaphone.getSecondaryButtonClickListener() != null) { + megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener); + } + }); + } + } else { + secondaryButton.setVisibility(GONE); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ClientDeprecatedActivity.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ClientDeprecatedActivity.java new file mode 100644 index 00000000..7ab4bd66 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ClientDeprecatedActivity.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.os.Bundle; + +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.Util; + +/** + * Shown when a users build fully expires. Controlled by {@link Megaphones.Event#CLIENT_DEPRECATED}. + */ +public class ClientDeprecatedActivity extends PassphraseRequiredActivity { + + private final DynamicTheme theme = new DynamicNoActionBarTheme(); + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.client_deprecated_activity); + + findViewById(R.id.client_deprecated_update_button).setOnClickListener(v -> onUpdateClicked()); + findViewById(R.id.client_deprecated_dont_update_button).setOnClickListener(v -> onDontUpdateClicked()); + } + + @Override + protected void onPreCreate() { + theme.onCreate(this); + } + + @Override + protected void onResume() { + super.onResume(); + theme.onResume(this); + } + + @Override + public void onBackPressed() { + // Disabled + } + + private void onUpdateClicked() { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this); + } + + private void onDontUpdateClicked() { + new AlertDialog.Builder(this) + .setTitle(R.string.ClientDeprecatedActivity_warning) + .setMessage(R.string.ClientDeprecatedActivity_your_version_of_signal_has_expired_you_can_view_your_message_history) + .setPositiveButton(R.string.ClientDeprecatedActivity_dont_update, (dialog, which) -> { + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.CLIENT_DEPRECATED, () -> { + Util.runOnMain(this::finish); + }); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java new file mode 100644 index 00000000..d1f08a4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.megaphone; + +final class ForeverSchedule implements MegaphoneSchedule { + + private final boolean enabled; + + ForeverSchedule(boolean enabled) { + this.enabled = enabled; + } + + @Override + public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) { + return enabled; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java new file mode 100644 index 00000000..d3e8df29 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -0,0 +1,248 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Intent; +import android.graphics.drawable.Drawable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.megaphone.Megaphones.Event; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; + +/** + * For guidance on creating megaphones, see {@link Megaphones}. + */ +public class Megaphone { + + private final Event event; + private final Style style; + private final Priority priority; + private final boolean canSnooze; + private final int titleRes; + private final int bodyRes; + private final int imageRes; + private final GlideRequest imageRequest; + private final int buttonTextRes; + private final EventListener buttonListener; + private final EventListener snoozeListener; + private final int secondaryButtonTextRes; + private final EventListener secondaryButtonListener; + private final EventListener onVisibleListener; + + private Megaphone(@NonNull Builder builder) { + this.event = builder.event; + this.style = builder.style; + this.priority = builder.priority; + this.canSnooze = builder.canSnooze; + this.titleRes = builder.titleRes; + this.bodyRes = builder.bodyRes; + this.imageRes = builder.imageRes; + this.imageRequest = builder.imageRequest; + this.buttonTextRes = builder.buttonTextRes; + this.buttonListener = builder.buttonListener; + this.snoozeListener = builder.snoozeListener; + this.secondaryButtonTextRes = builder.secondaryButtonTextRes; + this.secondaryButtonListener = builder.secondaryButtonListener; + this.onVisibleListener = builder.onVisibleListener; + } + + public @NonNull Event getEvent() { + return event; + } + + public @NonNull Priority getPriority() { + return priority; + } + + public boolean canSnooze() { + return canSnooze; + } + + public @NonNull Style getStyle() { + return style; + } + + public @StringRes int getTitle() { + return titleRes; + } + + public @StringRes int getBody() { + return bodyRes; + } + + public @DrawableRes int getImageRes() { + return imageRes; + } + + public @Nullable GlideRequest getImageRequest() { + return imageRequest; + } + + public @StringRes int getButtonText() { + return buttonTextRes; + } + + public boolean hasButton() { + return buttonTextRes != 0; + } + + public @Nullable EventListener getButtonClickListener() { + return buttonListener; + } + + public @Nullable EventListener getSnoozeListener() { + return snoozeListener; + } + + public @StringRes int getSecondaryButtonText() { + return secondaryButtonTextRes; + } + + public boolean hasSecondaryButton() { + return secondaryButtonTextRes != 0; + } + + public @Nullable EventListener getSecondaryButtonClickListener() { + return secondaryButtonListener; + } + + public @Nullable EventListener getOnVisibleListener() { + return onVisibleListener; + } + + public static class Builder { + + private final Event event; + private final Style style; + + private Priority priority; + private boolean canSnooze; + private int titleRes; + private int bodyRes; + private int imageRes; + private GlideRequest imageRequest; + private int buttonTextRes; + private EventListener buttonListener; + private EventListener snoozeListener; + private int secondaryButtonTextRes; + private EventListener secondaryButtonListener; + private EventListener onVisibleListener; + + + public Builder(@NonNull Event event, @NonNull Style style) { + this.event = event; + this.style = style; + this.priority = Priority.DEFAULT; + } + + /** + * Prioritizes this megaphone over others that do not set this flag. + */ + public @NonNull Builder setPriority(@NonNull Priority priority) { + this.priority = priority; + return this; + } + + public @NonNull Builder enableSnooze(@Nullable EventListener listener) { + this.canSnooze = true; + this.snoozeListener = listener; + return this; + } + + public @NonNull Builder disableSnooze() { + this.canSnooze = false; + this.snoozeListener = null; + return this; + } + + public @NonNull Builder setTitle(@StringRes int titleRes) { + this.titleRes = titleRes; + return this; + } + + public @NonNull Builder setBody(@StringRes int bodyRes) { + this.bodyRes = bodyRes; + return this; + } + + public @NonNull Builder setImage(@DrawableRes int imageRes) { + this.imageRes = imageRes; + return this; + } + + public @NonNull Builder setImageRequest(@Nullable GlideRequest imageRequest) { + this.imageRequest = imageRequest; + return this; + } + + public @NonNull Builder setActionButton(@StringRes int buttonTextRes, @NonNull EventListener listener) { + this.buttonTextRes = buttonTextRes; + this.buttonListener = listener; + return this; + } + + public @NonNull Builder setSecondaryButton(@StringRes int secondaryButtonTextRes, @NonNull EventListener listener) { + this.secondaryButtonTextRes = secondaryButtonTextRes; + this.secondaryButtonListener = listener; + return this; + } + + public @NonNull Builder setOnVisibleListener(@Nullable EventListener listener) { + this.onVisibleListener = listener; + return this; + } + + public @NonNull Megaphone build() { + return new Megaphone(this); + } + } + + enum Style { + /** Specialized style for announcing reactions. */ + REACTIONS, + + /** Specialized style for announcing link previews. */ + LINK_PREVIEWS, + + /** Specialized style for onboarding. */ + ONBOARDING, + + /** Basic bottom of the screen megaphone with optional snooze and action buttons. */ + BASIC, + + /** + * Indicates megaphone does not have a view but will call {@link MegaphoneActionController#onMegaphoneNavigationRequested(Intent)} + * or {@link MegaphoneActionController#onMegaphoneNavigationRequested(Intent, int)} on the controller passed in + * via the {@link #onVisibleListener}. + */ + FULLSCREEN, + + /** + * Similar to {@link Style#BASIC} but only provides a close button that will call {@link #buttonListener} if set, + * otherwise, the event will be marked finished (it will not be shown again). + */ + POPUP + } + + enum Priority { + DEFAULT(0), HIGH(1), CLIENT_EXPIRATION(1000); + + int priorityValue; + + Priority(int priorityValue) { + this.priorityValue = priorityValue; + } + + public int getPriorityValue() { + return priorityValue; + } + } + + public interface EventListener { + void onEvent(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController listener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java new file mode 100644 index 00000000..0c52be96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.app.Activity; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +public interface MegaphoneActionController { + /** + * When a megaphone wants to navigate to a specific intent. + */ + void onMegaphoneNavigationRequested(@NonNull Intent intent); + + /** + * When a megaphone wants to navigate to a specific intent for a request code. + */ + void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode); + + /** + * When a megaphone wants to show a toast/snackbar. + */ + void onMegaphoneToastRequested(@NonNull String string); + + /** + * When a megaphone needs a raw activity reference. Favor more specific methods when possible. + */ + @NonNull Activity getMegaphoneActivity(); + + /** + * When a megaphone has been snoozed via "remind me later" or a similar option. + */ + void onMegaphoneSnooze(@NonNull Megaphones.Event event); + + /** + * Called when a megaphone completed its goal. + */ + void onMegaphoneCompleted(@NonNull Megaphones.Event event); + + /** + * When a megaphone wnats to show a dialog fragment. + */ + void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java new file mode 100644 index 00000000..81dd63d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MegaphoneDatabase; +import org.thoughtcrime.securesms.database.model.MegaphoneRecord; +import org.thoughtcrime.securesms.megaphone.Megaphones.Event; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; + +/** + * Synchronization of data structures is done using a serial executor. Do not access or change + * data structures or fields on anything except the executor. + */ +public class MegaphoneRepository { + + private final Application context; + private final Executor executor; + private final MegaphoneDatabase database; + private final Map databaseCache; + + private boolean enabled; + + public MegaphoneRepository(@NonNull Application context) { + this.context = context; + this.executor = SignalExecutors.SERIAL; + this.database = MegaphoneDatabase.getInstance(context); + this.databaseCache = new HashMap<>(); + + executor.execute(this::init); + } + + /** + * Marks any megaphones a new user shouldn't see as "finished". + */ + @AnyThread + public void onFirstEverAppLaunch() { + executor.execute(() -> { + database.markFinished(Event.REACTIONS); + database.markFinished(Event.MESSAGE_REQUESTS); + database.markFinished(Event.LINK_PREVIEWS); + database.markFinished(Event.RESEARCH); + database.markFinished(Event.GROUP_CALLING); + resetDatabaseCache(); + }); + } + + @AnyThread + public void onAppForegrounded() { + executor.execute(() -> enabled = true); + } + + @AnyThread + public void getNextMegaphone(@NonNull Callback callback) { + executor.execute(() -> { + if (enabled) { + init(); + callback.onResult(Megaphones.getNextMegaphone(context, databaseCache)); + } else { + callback.onResult(null); + } + }); + } + + @AnyThread + public void markVisible(@NonNull Megaphones.Event event) { + long time = System.currentTimeMillis(); + + executor.execute(() -> { + if (getRecord(event).getFirstVisible() == 0) { + database.markFirstVisible(event, time); + resetDatabaseCache(); + } + }); + } + + @AnyThread + public void markSeen(@NonNull Event event) { + long lastSeen = System.currentTimeMillis(); + + executor.execute(() -> { + MegaphoneRecord record = getRecord(event); + database.markSeen(event, record.getSeenCount() + 1, lastSeen); + enabled = false; + resetDatabaseCache(); + }); + } + + @AnyThread + public void markFinished(@NonNull Event event) { + markFinished(event, null); + } + + @AnyThread + public void markFinished(@NonNull Event event, @Nullable Runnable onComplete) { + executor.execute(() -> { + MegaphoneRecord record = databaseCache.get(event); + if (record != null && record.isFinished()) { + return; + } + + database.markFinished(event); + resetDatabaseCache(); + + if (onComplete != null) { + onComplete.run(); + } + }); + } + + @WorkerThread + private void init() { + List records = database.getAllAndDeleteMissing(); + Set events = Stream.of(records).map(MegaphoneRecord::getEvent).collect(Collectors.toSet()); + Set missing = Stream.of(Megaphones.Event.values()).filterNot(events::contains).collect(Collectors.toSet()); + + database.insert(missing); + resetDatabaseCache(); + } + + @WorkerThread + private @NonNull MegaphoneRecord getRecord(@NonNull Event event) { + //noinspection ConstantConditions + return databaseCache.get(event); + } + + @WorkerThread + private void resetDatabaseCache() { + databaseCache.clear(); + databaseCache.putAll(Stream.of(database.getAllAndDeleteMissing()).collect(Collectors.toMap(MegaphoneRecord::getEvent, m -> m))); + } + + public interface Callback { + void onResult(E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java new file mode 100644 index 00000000..09e7ecfa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.megaphone; + +public interface MegaphoneSchedule { + boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java new file mode 100644 index 00000000..49231853 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.linkpreview.LinkPreviewsMegaphoneView; +import org.thoughtcrime.securesms.reactions.ReactionsMegaphoneView; + +public class MegaphoneViewBuilder { + + public static @Nullable View build(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + switch (megaphone.getStyle()) { + case BASIC: + return buildBasicMegaphone(context, megaphone, listener); + case FULLSCREEN: + return null; + case REACTIONS: + return buildReactionsMegaphone(context, megaphone, listener); + case LINK_PREVIEWS: + return buildLinkPreviewsMegaphone(context, megaphone, listener); + case ONBOARDING: + return buildOnboardingMegaphone(context, megaphone, listener); + case POPUP: + return buildPopupMegaphone(context, megaphone, listener); + default: + throw new IllegalArgumentException("No view implemented for style!"); + } + } + + private static @NonNull View buildBasicMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + BasicMegaphoneView view = new BasicMegaphoneView(context); + view.present(megaphone, listener); + return view; + } + + private static @NonNull View buildReactionsMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + ReactionsMegaphoneView view = new ReactionsMegaphoneView(context); + view.present(megaphone, listener); + return view; + } + + private static @NonNull View buildLinkPreviewsMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + LinkPreviewsMegaphoneView view = new LinkPreviewsMegaphoneView(context); + view.present(megaphone, listener); + return view; + } + + private static @NonNull View buildOnboardingMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + OnboardingMegaphoneView view = new OnboardingMegaphoneView(context); + view.present(megaphone, listener); + return view; + } + + private static @NonNull View buildPopupMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + PopupMegaphoneView view = new PopupMegaphoneView(context); + view.present(megaphone, listener); + return view; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java new file mode 100644 index 00000000..dda5fd9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -0,0 +1,389 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.TranslationDetection; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.ConversationListFragment; +import org.thoughtcrime.securesms.database.model.MegaphoneRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.SignalPinReminderDialog; +import org.thoughtcrime.securesms.lock.SignalPinReminders; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity; +import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivity; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.PopulationFeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.VersionTracker; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Creating a new megaphone: + * - Add an enum to {@link Event} + * - Return a megaphone in {@link #forRecord(Context, MegaphoneRecord)} + * - Include the event in {@link #buildDisplayOrder(Context)} + * + * Common patterns: + * - For events that have a snooze-able recurring display schedule, use a {@link RecurringSchedule}. + * - For events guarded by feature flags, set a {@link ForeverSchedule} with false in + * {@link #buildDisplayOrder(Context)}. + * - For events that change, return different megaphones in {@link #forRecord(Context, MegaphoneRecord)} + * based on whatever properties you're interested in. + */ +public final class Megaphones { + + private static final String TAG = Log.tag(Megaphones.class); + + private static final MegaphoneSchedule ALWAYS = new ForeverSchedule(true); + private static final MegaphoneSchedule NEVER = new ForeverSchedule(false); + + private Megaphones() {} + + static @Nullable Megaphone getNextMegaphone(@NonNull Context context, @NonNull Map records) { + long currentTime = System.currentTimeMillis(); + + List megaphones = Stream.of(buildDisplayOrder(context)) + .filter(e -> { + MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey())); + MegaphoneSchedule schedule = e.getValue(); + + return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), record.getFirstVisible(), currentTime); + }) + .map(Map.Entry::getKey) + .map(records::get) + .map(record -> Megaphones.forRecord(context, record)) + .sortBy(m -> -m.getPriority().getPriorityValue()) + .toList(); + + if (megaphones.size() > 0) { + return megaphones.get(0); + } else { + return null; + } + } + + /** + * This is when you would hide certain megaphones based on {@link FeatureFlags}. You could + * conditionally set a {@link ForeverSchedule} set to false for disabled features. + */ + private static Map buildDisplayOrder(@NonNull Context context) { + return new LinkedHashMap() {{ + put(Event.REACTIONS, ALWAYS); + put(Event.PINS_FOR_ALL, new PinsForAllSchedule()); + put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); + put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER); + put(Event.LINK_PREVIEWS, shouldShowLinkPreviewsMegaphone(context) ? ALWAYS : NEVER); + put(Event.CLIENT_DEPRECATED, SignalStore.misc().isClientDeprecated() ? ALWAYS : NEVER); + put(Event.RESEARCH, shouldShowResearchMegaphone(context) ? ShowForDurationSchedule.showForDays(7) : NEVER); + put(Event.DONATE, shouldShowDonateMegaphone(context) ? ShowForDurationSchedule.showForDays(7) : NEVER); + put(Event.GROUP_CALLING, shouldShowGroupCallingMegaphone() ? ALWAYS : NEVER); + put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER); + put(Event.NOTIFICATIONS, shouldShowNotificationsMegaphone(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(30)) : NEVER); + }}; + } + + private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) { + switch (record.getEvent()) { + case REACTIONS: + return buildReactionsMegaphone(); + case PINS_FOR_ALL: + return buildPinsForAllMegaphone(record); + case PIN_REMINDER: + return buildPinReminderMegaphone(context); + case MESSAGE_REQUESTS: + return buildMessageRequestsMegaphone(context); + case LINK_PREVIEWS: + return buildLinkPreviewsMegaphone(); + case CLIENT_DEPRECATED: + return buildClientDeprecatedMegaphone(context); + case RESEARCH: + return buildResearchMegaphone(context); + case DONATE: + return buildDonateMegaphone(context); + case GROUP_CALLING: + return buildGroupCallingMegaphone(context); + case ONBOARDING: + return buildOnboardingMegaphone(); + case NOTIFICATIONS: + return buildNotificationsMegaphone(context); + default: + throw new IllegalArgumentException("Event not handled!"); + } + } + + private static @NonNull Megaphone buildReactionsMegaphone() { + return new Megaphone.Builder(Event.REACTIONS, Megaphone.Style.REACTIONS) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull MegaphoneRecord record) { + if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) { + return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN) + .setPriority(Megaphone.Priority.HIGH) + .enableSnooze(null) + .setOnVisibleListener((megaphone, listener) -> { + if (new NetworkConstraint.Factory(ApplicationDependencies.getApplication()).create().isMet()) { + listener.onMegaphoneNavigationRequested(KbsMigrationActivity.createIntent(), KbsMigrationActivity.REQUEST_NEW_PIN); + } + }) + .build(); + } else { + return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.BASIC) + .setPriority(Megaphone.Priority.HIGH) + .setImage(R.drawable.kbs_pin_megaphone) + .setTitle(R.string.KbsMegaphone__create_a_pin) + .setBody(R.string.KbsMegaphone__pins_keep_information_thats_stored_with_signal_encrytped) + .setActionButton(R.string.KbsMegaphone__create_pin, (megaphone, listener) -> { + Intent intent = CreateKbsPinActivity.getIntentForPinCreate(ApplicationDependencies.getApplication()); + + listener.onMegaphoneNavigationRequested(intent, CreateKbsPinActivity.REQUEST_NEW_PIN); + }) + .build(); + } + } + + @SuppressWarnings("CodeBlock2Expr") + private static @NonNull Megaphone buildPinReminderMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.PIN_REMINDER, Megaphone.Style.BASIC) + .setTitle(R.string.Megaphones_verify_your_signal_pin) + .setBody(R.string.Megaphones_well_occasionally_ask_you_to_verify_your_pin) + .setImage(R.drawable.kbs_pin_megaphone) + .setActionButton(R.string.Megaphones_verify_pin, (megaphone, controller) -> { + SignalPinReminderDialog.show(controller.getMegaphoneActivity(), controller::onMegaphoneNavigationRequested, new SignalPinReminderDialog.Callback() { + @Override + public void onReminderDismissed(boolean includedFailure) { + Log.i(TAG, "[PinReminder] onReminderDismissed(" + includedFailure + ")"); + if (includedFailure) { + SignalStore.pinValues().onEntrySkipWithWrongGuess(); + } + } + + @Override + public void onReminderCompleted(@NonNull String pin, boolean includedFailure) { + Log.i(TAG, "[PinReminder] onReminderCompleted(" + includedFailure + ")"); + if (includedFailure) { + SignalStore.pinValues().onEntrySuccessWithWrongGuess(pin); + } else { + SignalStore.pinValues().onEntrySuccess(pin); + } + + controller.onMegaphoneSnooze(Event.PIN_REMINDER); + controller.onMegaphoneToastRequested(context.getString(SignalPinReminders.getReminderString(SignalStore.pinValues().getCurrentInterval()))); + } + }); + }) + .build(); + } + + @SuppressWarnings("CodeBlock2Expr") + private static @NonNull Megaphone buildMessageRequestsMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.MESSAGE_REQUESTS, Megaphone.Style.FULLSCREEN) + .disableSnooze() + .setPriority(Megaphone.Priority.HIGH) + .setOnVisibleListener(((megaphone, listener) -> { + listener.onMegaphoneNavigationRequested(new Intent(context, MessageRequestMegaphoneActivity.class), + ConversationListFragment.MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME); + })) + .build(); + } + + private static @NonNull Megaphone buildLinkPreviewsMegaphone() { + return new Megaphone.Builder(Event.LINK_PREVIEWS, Megaphone.Style.LINK_PREVIEWS) + .setPriority(Megaphone.Priority.HIGH) + .build(); + } + + private static @NonNull Megaphone buildClientDeprecatedMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.CLIENT_DEPRECATED, Megaphone.Style.FULLSCREEN) + .disableSnooze() + .setPriority(Megaphone.Priority.HIGH) + .setOnVisibleListener((megaphone, listener) -> listener.onMegaphoneNavigationRequested(new Intent(context, ClientDeprecatedActivity.class))) + .build(); + } + + private static @NonNull Megaphone buildResearchMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.RESEARCH, Megaphone.Style.BASIC) + .disableSnooze() + .setTitle(R.string.ResearchMegaphone_tell_signal_what_you_think) + .setBody(R.string.ResearchMegaphone_to_make_signal_the_best_messaging_app_on_the_planet) + .setImage(R.drawable.ic_research_megaphone) + .setActionButton(R.string.ResearchMegaphone_learn_more, (megaphone, controller) -> { + controller.onMegaphoneCompleted(megaphone.getEvent()); + controller.onMegaphoneDialogFragmentRequested(new ResearchMegaphoneDialog()); + }) + .setSecondaryButton(R.string.ResearchMegaphone_dismiss, (megaphone, controller) -> controller.onMegaphoneCompleted(megaphone.getEvent())) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static @NonNull Megaphone buildDonateMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.DONATE, Megaphone.Style.BASIC) + .disableSnooze() + .setTitle(R.string.DonateMegaphone_donate_to_signal) + .setBody(R.string.DonateMegaphone_Signal_is_powered_by_people_like_you_show_your_support_today) + .setImage(R.drawable.ic_donate_megaphone) + .setActionButton(R.string.DonateMegaphone_donate, (megaphone, controller) -> { + controller.onMegaphoneCompleted(megaphone.getEvent()); + CommunicationActions.openBrowserLink(controller.getMegaphoneActivity(), context.getString(R.string.donate_url)); + }) + .setSecondaryButton(R.string.DonateMegaphone_no_thanks, (megaphone, controller) -> controller.onMegaphoneCompleted(megaphone.getEvent())) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static @NonNull Megaphone buildGroupCallingMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.GROUP_CALLING, Megaphone.Style.BASIC) + .disableSnooze() + .setTitle(R.string.GroupCallingMegaphone__introducing_group_calls) + .setBody(R.string.GroupCallingMegaphone__open_a_new_group_to_start) + .setImage(R.drawable.ic_group_calls_megaphone) + .setActionButton(android.R.string.ok, (megaphone, controller) -> { + controller.onMegaphoneCompleted(megaphone.getEvent()); + }) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static @NonNull Megaphone buildOnboardingMegaphone() { + return new Megaphone.Builder(Event.ONBOARDING, Megaphone.Style.ONBOARDING) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static @NonNull Megaphone buildNotificationsMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.NOTIFICATIONS, Megaphone.Style.BASIC) + .setTitle(R.string.NotificationsMegaphone_turn_on_notifications) + .setBody(R.string.NotificationsMegaphone_never_miss_a_message) + .setImage(R.drawable.megaphone_notifications_64) + .setActionButton(R.string.NotificationsMegaphone_turn_on, (megaphone, controller) -> { + controller.onMegaphoneSnooze(Event.NOTIFICATIONS); + + if (Build.VERSION.SDK_INT >= 26 && !NotificationChannels.isMessageChannelEnabled(context)) { + Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(context)); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + controller.onMegaphoneNavigationRequested(intent); + } else if (Build.VERSION.SDK_INT >= 26 && !NotificationChannels.areNotificationsEnabled(context)) { + Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + controller.onMegaphoneNavigationRequested(intent); + } else { + Intent intent = new Intent(context, ApplicationPreferencesActivity.class); + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_NOTIFICATIONS_FRAGMENT, true); + controller.onMegaphoneNavigationRequested(intent); + } + }) + .setSecondaryButton(R.string.NotificationsMegaphone_not_now, (megaphone, controller) -> controller.onMegaphoneSnooze(Event.NOTIFICATIONS)) + .setPriority(Megaphone.Priority.DEFAULT) + .build(); + } + + private static boolean shouldShowMessageRequestsMegaphone() { + return Recipient.self().getProfileName() == ProfileName.EMPTY; + } + + private static boolean shouldShowResearchMegaphone(@NonNull Context context) { + return VersionTracker.getDaysSinceFirstInstalled(context) > 7 && PopulationFeatureFlags.isInResearchMegaphone(); + } + + private static boolean shouldShowDonateMegaphone(@NonNull Context context) { + return VersionTracker.getDaysSinceFirstInstalled(context) > 7 && PopulationFeatureFlags.isInDonateMegaphone(); + } + + private static boolean shouldShowLinkPreviewsMegaphone(@NonNull Context context) { + return TextSecurePreferences.wereLinkPreviewsEnabled(context) && !SignalStore.settings().isLinkPreviewsEnabled(); + } + + private static boolean shouldShowGroupCallingMegaphone() { + return FeatureFlags.groupCalling(); + } + + private static boolean shouldShowOnboardingMegaphone(@NonNull Context context) { + return SignalStore.onboarding().hasOnboarding(context); + } + + private static boolean shouldShowNotificationsMegaphone(@NonNull Context context) { + boolean shouldShow = !TextSecurePreferences.isNotificationsEnabled(context) || + !NotificationChannels.isMessageChannelEnabled(context) || + !NotificationChannels.areNotificationsEnabled(context); + if (shouldShow) { + Locale locale = DynamicLanguageContextWrapper.getUsersSelectedLocale(context); + if (!new TranslationDetection(context, locale) + .textExistsInUsersLanguage(R.string.NotificationsMegaphone_turn_on_notifications, + R.string.NotificationsMegaphone_never_miss_a_message, + R.string.NotificationsMegaphone_turn_on, + R.string.NotificationsMegaphone_not_now)) { + Log.i(TAG, "Would show NotificationsMegaphone but is not yet translated in " + locale); + return false; + } + } + return shouldShow; + } + + public enum Event { + REACTIONS("reactions"), + PINS_FOR_ALL("pins_for_all"), + PIN_REMINDER("pin_reminder"), + MESSAGE_REQUESTS("message_requests"), + LINK_PREVIEWS("link_previews"), + CLIENT_DEPRECATED("client_deprecated"), + RESEARCH("research"), + DONATE("donate"), + GROUP_CALLING("group_calling"), + ONBOARDING("onboarding"), + NOTIFICATIONS("notifications"); + + private final String key; + + Event(@NonNull String key) { + this.key = key; + } + + public @NonNull String getKey() { + return key; + } + + public static Event fromKey(@NonNull String key) { + for (Event event : values()) { + if (event.getKey().equals(key)) { + return event; + } + } + throw new IllegalArgumentException("No event for key: " + key); + } + + public static boolean hasKey(@NonNull String key) { + for (Event event : values()) { + if (event.getKey().equals(key)) { + return true; + } + } + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java new file mode 100644 index 00000000..05c0494a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java @@ -0,0 +1,262 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.content.Intent; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.InviteActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.ConversationListFragment; +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.SmsUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows the a fun rail of cards that educate the user about some actions they can take right after + * they install the app. + */ +public class OnboardingMegaphoneView extends FrameLayout { + + private static final String TAG = Log.tag(OnboardingMegaphoneView.class); + + private RecyclerView cardList; + + public OnboardingMegaphoneView(Context context) { + super(context); + initialize(context); + } + + public OnboardingMegaphoneView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + private void initialize(@NonNull Context context) { + inflate(context, R.layout.onboarding_megaphone, this); + + this.cardList = findViewById(R.id.onboarding_megaphone_list); + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController listener) { + this.cardList.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false)); + this.cardList.setAdapter(new CardAdapter(getContext(), listener)); + } + + private static class CardAdapter extends RecyclerView.Adapter implements ActionClickListener { + + private static final int TYPE_GROUP = 0; + private static final int TYPE_INVITE = 1; + private static final int TYPE_SMS = 2; + + private final Context context; + private final MegaphoneActionController controller; + private final List data; + + CardAdapter(@NonNull Context context, @NonNull MegaphoneActionController controller) { + this.context = context; + this.controller = controller; + this.data = buildData(context); + + if (data.isEmpty()) { + Log.i(TAG, "Nothing to show (constructor)! Considering megaphone completed."); + controller.onMegaphoneCompleted(Megaphones.Event.ONBOARDING); + } + + setHasStableIds(true); + } + + @Override + public int getItemViewType(int position) { + return data.get(position); + } + + @Override + public long getItemId(int position) { + return data.get(position); + } + + @Override + public @NonNull CardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.onboarding_megaphone_list_item, parent, false); + switch (viewType) { + case TYPE_GROUP: return new GroupCardViewHolder(view); + case TYPE_INVITE: return new InviteCardViewHolder(view); + case TYPE_SMS: return new SmsCardViewHolder(view); + default: throw new IllegalStateException("Invalid viewType! " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull CardViewHolder holder, int position) { + holder.bind(this, controller); + } + + @Override + public int getItemCount() { + return data.size(); + } + + @Override + public void onClick() { + data.clear(); + data.addAll(buildData(context)); + if (data.isEmpty()) { + Log.i(TAG, "Nothing to show! Considering megaphone completed."); + controller.onMegaphoneCompleted(Megaphones.Event.ONBOARDING); + } + notifyDataSetChanged(); + } + + private static List buildData(@NonNull Context context) { + List data = new ArrayList<>(); + + if (SignalStore.onboarding().shouldShowNewGroup()) { + data.add(TYPE_GROUP); + } + + if (SignalStore.onboarding().shouldShowInviteFriends()) { + data.add(TYPE_INVITE); + } + + if (SignalStore.onboarding().shouldShowSms(context)) { + data.add(TYPE_SMS); + } + + return data; + } + } + + private interface ActionClickListener { + void onClick(); + } + + private static abstract class CardViewHolder extends RecyclerView.ViewHolder { + private final ImageView image; + private final TextView actionButton; + private final View closeButton; + + public CardViewHolder(@NonNull View itemView) { + super(itemView); + this.image = itemView.findViewById(R.id.onboarding_megaphone_item_image); + this.actionButton = itemView.findViewById(R.id.onboarding_megaphone_item_button); + this.closeButton = itemView.findViewById(R.id.onboarding_megaphone_item_close); + } + + public void bind(@NonNull ActionClickListener listener, @NonNull MegaphoneActionController controller) { + image.setImageResource(getImageRes()); + actionButton.setText(getButtonStringRes()); + actionButton.setOnClickListener(v -> { + onActionClicked(controller); + listener.onClick(); + }); + closeButton.setOnClickListener(v -> { + onCloseClicked(); + listener.onClick(); + }); + } + + abstract @StringRes int getButtonStringRes(); + abstract @DrawableRes int getImageRes(); + abstract void onActionClicked(@NonNull MegaphoneActionController controller); + abstract void onCloseClicked(); + } + + private static class GroupCardViewHolder extends CardViewHolder { + + public GroupCardViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + int getButtonStringRes() { + return R.string.Megaphones_new_group; + } + + @Override + int getImageRes() { + return R.drawable.ic_megaphone_start_group; + } + + @Override + void onActionClicked(@NonNull MegaphoneActionController controller) { + controller.onMegaphoneNavigationRequested(CreateGroupActivity.newIntent(controller.getMegaphoneActivity())); + } + + @Override + void onCloseClicked() { + SignalStore.onboarding().setShowNewGroup(false); + } + } + + private static class InviteCardViewHolder extends CardViewHolder { + + public InviteCardViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + int getButtonStringRes() { + return R.string.Megaphones_invite_friends; + } + + @Override + int getImageRes() { + return R.drawable.ic_megaphone_invite_friends; + } + + @Override + void onActionClicked(@NonNull MegaphoneActionController controller) { + controller.onMegaphoneNavigationRequested(new Intent(controller.getMegaphoneActivity(), InviteActivity.class)); + } + + @Override + void onCloseClicked() { + SignalStore.onboarding().setShowInviteFriends(false); + } + } + + private static class SmsCardViewHolder extends CardViewHolder { + + public SmsCardViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + int getButtonStringRes() { + return R.string.Megaphones_use_sms; + } + + @Override + int getImageRes() { + return R.drawable.ic_megaphone_use_sms; + } + + @Override + void onActionClicked(@NonNull MegaphoneActionController controller) { + Intent intent = SmsUtil.getSmsRoleIntent(controller.getMegaphoneActivity()); + controller.onMegaphoneNavigationRequested(intent, ConversationListFragment.SMS_ROLE_REQUEST_CODE); + SignalStore.onboarding().setShowSms(false); + } + + @Override + void onCloseClicked() { + SignalStore.onboarding().setShowSms(false); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java new file mode 100644 index 00000000..beecae8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.megaphone; + +import androidx.annotation.VisibleForTesting; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +class PinsForAllSchedule implements MegaphoneSchedule { + + private static final String TAG = Log.tag(PinsForAllSchedule.class); + + @VisibleForTesting + static final long DAYS_UNTIL_FULLSCREEN = 4L; + + private final MegaphoneSchedule schedule = new RecurringSchedule(TimeUnit.HOURS.toMillis(2)); + + static boolean shouldDisplayFullScreen(long firstVisible, long currentTime) { + if (firstVisible == 0L) { + return false; + } + + return currentTime - firstVisible >= TimeUnit.DAYS.toMillis(DAYS_UNTIL_FULLSCREEN); + } + + @Override + public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) { + if (!isEnabled()) { + return false; + } + + if (shouldDisplayFullScreen(firstVisible, currentTime)) { + return true; + } else { + boolean shouldDisplay = schedule.shouldDisplay(seenCount, lastSeen, firstVisible, currentTime); + Log.i(TAG, String.format(Locale.ENGLISH, "seenCount: %d, lastSeen: %d, firstVisible: %d, currentTime: %d, result: %b", seenCount, lastSeen, firstVisible, currentTime, shouldDisplay)); + return shouldDisplay; + } + } + + private static boolean isEnabled() { + if (SignalStore.kbsValues().hasOptedOut()) { + return false; + } + + if (SignalStore.kbsValues().hasPin()) { + return false; + } + + if (pinCreationFailedDuringRegistration()) { + return true; + } + + if (newlyRegisteredRegistrationLockV1User()) { + return true; + } + + if (SignalStore.registrationValues().pinWasRequiredAtRegistration()) { + return false; + } + + return true; + } + + private static boolean pinCreationFailedDuringRegistration() { + return SignalStore.registrationValues().pinWasRequiredAtRegistration() && + !SignalStore.kbsValues().hasPin() && + !TextSecurePreferences.isV1RegistrationLockEnabled(ApplicationDependencies.getApplication()); + } + + private static boolean newlyRegisteredRegistrationLockV1User() { + return SignalStore.registrationValues().pinWasRequiredAtRegistration() && TextSecurePreferences.isV1RegistrationLockEnabled(ApplicationDependencies.getApplication()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java new file mode 100644 index 00000000..e1fd330e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class PopupMegaphoneView extends FrameLayout { + + private ImageView image; + private TextView titleText; + private TextView bodyText; + private View xButton; + + private Megaphone megaphone; + private MegaphoneActionController megaphoneListener; + + public PopupMegaphoneView(@NonNull Context context) { + super(context); + init(context); + } + + public PopupMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(@NonNull Context context) { + inflate(context, R.layout.popup_megaphone_view, this); + + this.image = findViewById(R.id.popup_megaphone_image); + this.titleText = findViewById(R.id.popup_megaphone_title); + this.bodyText = findViewById(R.id.popup_megaphone_body); + this.xButton = findViewById(R.id.popup_x); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) { + megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener); + } + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController megaphoneListener) { + this.megaphone = megaphone; + this.megaphoneListener = megaphoneListener; + + if (megaphone.getImageRequest() != null) { + image.setVisibility(VISIBLE); + megaphone.getImageRequest().into(image); + } else { + image.setVisibility(GONE); + } + + if (megaphone.getTitle() != 0) { + titleText.setVisibility(VISIBLE); + titleText.setText(megaphone.getTitle()); + } else { + titleText.setVisibility(GONE); + } + + if (megaphone.getBody() != 0) { + bodyText.setVisibility(VISIBLE); + bodyText.setText(megaphone.getBody()); + } else { + bodyText.setVisibility(GONE); + } + + if (megaphone.hasButton()) { + xButton.setOnClickListener(v -> megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener)); + } else { + xButton.setOnClickListener(v -> megaphoneListener.onMegaphoneCompleted(megaphone.getEvent())); + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java new file mode 100644 index 00000000..d9a957ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.megaphone; + +import androidx.annotation.NonNull; + +/** + * A schedule that provides a high level of control, allowing you to specify an amount of time to + * wait based on how many times a user has seen the megaphone. + */ +class RecurringSchedule implements MegaphoneSchedule { + + private final long[] gaps; + + /** + * How long to wait after each time a user has seen the megaphone. Index 0 corresponds to how long + * to wait to show it again after the user has seen it once, index 1 is for after the user has + * seen it twice, etc. If the seen count is greater than the number of provided intervals, it will + * continue to use the last interval provided indefinitely. + * + * The schedule will always show the megaphone if the user has never seen it. + */ + RecurringSchedule(long... durationGaps) { + this.gaps = durationGaps; + } + + /** + * Shortcut for a recurring schedule with a single interval. + */ + public static @NonNull MegaphoneSchedule every(long interval) { + return new RecurringSchedule(interval); + } + + @Override + public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) { + if (seenCount == 0) { + return true; + } + + long gap = gaps[Math.min(seenCount - 1, gaps.length - 1)]; + + return lastSeen + gap <= currentTime ; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java new file mode 100644 index 00000000..12842b49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.os.Bundle; +import android.text.Html; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.FullScreenDialogFragment; +import org.thoughtcrime.securesms.util.CommunicationActions; + +public class ResearchMegaphoneDialog extends FullScreenDialogFragment { + + private static final String SURVEY_URL = "https://surveys.signalusers.org/s3"; + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + TextView content = view.findViewById(R.id.research_megaphone_content); + content.setText(Html.fromHtml(requireContext().getString(R.string.ResearchMegaphoneDialog_we_believe_in_privacy))); + + view.findViewById(R.id.research_megaphone_dialog_take_the_survey) + .setOnClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), SURVEY_URL)); + + view.findViewById(R.id.research_megaphone_dialog_no_thanks) + .setOnClickListener(v -> dismissAllowingStateLoss()); + } + + @Override + protected @StringRes int getTitle() { + return R.string.ResearchMegaphoneDialog_signal_research; + } + + @Override + protected int getDialogLayoutResource() { + return R.layout.research_megaphone_dialog; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java new file mode 100644 index 00000000..baf5114c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.megaphone; + +import java.util.concurrent.TimeUnit; + +/** + * Megaphone schedule that will always show for some duration after the first + * time the user sees it. + */ +public class ShowForDurationSchedule implements MegaphoneSchedule { + + private final long duration; + + public static MegaphoneSchedule showForDays(int days) { + return new ShowForDurationSchedule(TimeUnit.DAYS.toMillis(days)); + } + + public ShowForDurationSchedule(long duration) { + this.duration = duration; + } + + @Override + public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) { + return firstVisible == 0 || currentTime < firstVisible + duration; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/SignalPinReminderSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/SignalPinReminderSchedule.java new file mode 100644 index 00000000..6b0f3eea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/SignalPinReminderSchedule.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.megaphone; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +final class SignalPinReminderSchedule implements MegaphoneSchedule { + + @Override + public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) { + if (SignalStore.kbsValues().hasOptedOut()) { + return false; + } + + if (!SignalStore.kbsValues().hasPin()) { + return false; + } + + if (!SignalStore.pinValues().arePinRemindersEnabled()) { + return false; + } + + if (!TextSecurePreferences.isPushRegistered(ApplicationDependencies.getApplication())) { + return false; + } + + long lastSuccessTime = SignalStore.pinValues().getLastSuccessfulEntryTime(); + long interval = SignalStore.pinValues().getCurrentInterval(); + + return currentTime - lastSuccessTime >= interval; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java new file mode 100644 index 00000000..2fb76797 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.messagedetails; + +import androidx.annotation.NonNull; + +import com.annimon.stream.ComparatorCompat; + +import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.TreeSet; + +final class MessageDetails { + private static final Comparator HAS_DISPLAY_NAME = (r1, r2) -> Boolean.compare(r2.getRecipient().hasAUserSetDisplayName(ApplicationDependencies.getApplication()), r1.getRecipient().hasAUserSetDisplayName(ApplicationDependencies.getApplication())); + private static final Comparator ALPHABETICAL = (r1, r2) -> r1.getRecipient().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(r2.getRecipient().getDisplayName(ApplicationDependencies.getApplication())); + private static final Comparator RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL); + + private final ConversationMessage conversationMessage; + + private final Collection pending; + private final Collection sent; + private final Collection delivered; + private final Collection read; + private final Collection notSent; + + MessageDetails(@NonNull ConversationMessage conversationMessage, @NonNull List recipients) { + this.conversationMessage = conversationMessage; + + pending = new TreeSet<>(RECIPIENT_COMPARATOR); + sent = new TreeSet<>(RECIPIENT_COMPARATOR); + delivered = new TreeSet<>(RECIPIENT_COMPARATOR); + read = new TreeSet<>(RECIPIENT_COMPARATOR); + notSent = new TreeSet<>(RECIPIENT_COMPARATOR); + + if (conversationMessage.getMessageRecord().isOutgoing()) { + for (RecipientDeliveryStatus status : recipients) { + switch (status.getDeliveryStatus()) { + case UNKNOWN: + notSent.add(status); + break; + case PENDING: + pending.add(status); + break; + case SENT: + sent.add(status); + break; + case DELIVERED: + delivered.add(status); + break; + case READ: + read.add(status); + break; + } + } + } else { + sent.addAll(recipients); + } + } + + @NonNull ConversationMessage getConversationMessage() { + return conversationMessage; + } + + @NonNull Collection getPending() { + return pending; + } + + @NonNull Collection getSent() { + return sent; + } + + @NonNull Collection getDelivered() { + return delivered; + } + + @NonNull Collection getRead() { + return read; + } + + @NonNull Collection getNotSent() { + return notSent; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java new file mode 100644 index 00000000..c8417846 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState; +import org.thoughtcrime.securesms.messagedetails.MessageDetailsViewModel.Factory; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicDarkActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.WindowUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public final class MessageDetailsActivity extends PassphraseRequiredActivity { + + private static final String MESSAGE_ID_EXTRA = "message_id"; + private static final String THREAD_ID_EXTRA = "thread_id"; + private static final String TYPE_EXTRA = "type"; + private static final String RECIPIENT_EXTRA = "recipient_id"; + + private GlideRequests glideRequests; + private MessageDetailsViewModel viewModel; + private MessageDetailsAdapter adapter; + + private DynamicTheme dynamicTheme = new DynamicTheme(); + + public static @NonNull Intent getIntentForMessageDetails(@NonNull Context context, @NonNull MessageRecord message, @NonNull RecipientId recipientId, long threadId) { + Intent intent = new Intent(context, MessageDetailsActivity.class); + intent.putExtra(MESSAGE_ID_EXTRA, message.getId()); + intent.putExtra(THREAD_ID_EXTRA, threadId); + intent.putExtra(TYPE_EXTRA, message.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT); + intent.putExtra(RECIPIENT_EXTRA, recipientId); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.message_details_activity); + + glideRequests = GlideApp.with(this); + + initializeList(); + initializeViewModel(); + initializeActionBar(); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + adapter.resumeMessageExpirationTimer(); + } + + @Override + protected void onPause() { + super.onPause(); + adapter.pauseMessageExpirationTimer(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void initializeList() { + RecyclerView list = findViewById(R.id.message_details_list); + adapter = new MessageDetailsAdapter(this, glideRequests); + + list.setAdapter(adapter); + list.setItemAnimator(null); + } + + private void initializeViewModel() { + final RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_EXTRA); + final String type = getIntent().getStringExtra(TYPE_EXTRA); + final Long messageId = getIntent().getLongExtra(MESSAGE_ID_EXTRA, -1); + final Factory factory = new Factory(recipientId, type, messageId); + + viewModel = ViewModelProviders.of(this, factory).get(MessageDetailsViewModel.class); + viewModel.getMessageDetails().observe(this, details -> { + if (details == null) { + finish(); + } else { + adapter.submitList(convertToRows(details)); + } + }); + } + + private void initializeActionBar() { + requireSupportActionBar().setDisplayHomeAsUpEnabled(true); + requireSupportActionBar().setTitle(R.string.AndroidManifest__message_details); + } + + private List> convertToRows(MessageDetails details) { + List> list = new ArrayList<>(); + + list.add(new MessageDetailsViewState<>(details.getConversationMessage(), MessageDetailsViewState.MESSAGE_HEADER)); + + if (details.getConversationMessage().getMessageRecord().isOutgoing()) { + addRecipients(list, RecipientHeader.NOT_SENT, details.getNotSent()); + addRecipients(list, RecipientHeader.READ, details.getRead()); + addRecipients(list, RecipientHeader.DELIVERED, details.getDelivered()); + addRecipients(list, RecipientHeader.SENT_TO, details.getSent()); + addRecipients(list, RecipientHeader.PENDING, details.getPending()); + } else { + addRecipients(list, RecipientHeader.SENT_FROM, details.getSent()); + } + + return list; + } + + private boolean addRecipients(List> list, RecipientHeader header, Collection recipients) { + if (recipients.isEmpty()) { + return false; + } + + list.add(new MessageDetailsViewState<>(header, MessageDetailsViewState.RECIPIENT_HEADER)); + for (RecipientDeliveryStatus status : recipients) { + list.add(new MessageDetailsViewState<>(status, MessageDetailsViewState.RECIPIENT)); + } + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java new file mode 100644 index 00000000..a0b42dc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.List; + +final class MessageDetailsAdapter extends ListAdapter, RecyclerView.ViewHolder> { + + private static final Object EXPIRATION_TIMER_CHANGE_PAYLOAD = new Object(); + + private final LifecycleOwner lifecycleOwner; + private final GlideRequests glideRequests; + private boolean running; + + MessageDetailsAdapter(@NonNull LifecycleOwner lifecycleOwner, @NonNull GlideRequests glideRequests) { + super(new MessageDetailsDiffer()); + this.lifecycleOwner = lifecycleOwner; + this.glideRequests = glideRequests; + this.running = true; + } + + @Override + public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case MessageDetailsViewState.MESSAGE_HEADER: + return new MessageHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_header, parent, false), glideRequests); + case MessageDetailsViewState.RECIPIENT_HEADER: + return new RecipientHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient_header, parent, false)); + case MessageDetailsViewState.RECIPIENT: + return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient, parent, false)); + default: + throw new AssertionError("unknown view type"); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof MessageHeaderViewHolder) { + ((MessageHeaderViewHolder) holder).bind(lifecycleOwner, (ConversationMessage) getItem(position).data, running); + } else if (holder instanceof RecipientHeaderViewHolder) { + ((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data); + } else if (holder instanceof RecipientViewHolder) { + ((RecipientViewHolder) holder).bind((RecipientDeliveryStatus) getItem(position).data); + } else { + throw new AssertionError("unknown view holder"); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List payloads) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads); + } else if (holder instanceof MessageHeaderViewHolder) { + ((MessageHeaderViewHolder) holder).partialBind((ConversationMessage) getItem(position).data, running); + } + } + + @Override + public int getItemViewType(int position) { + return getItem(position).itemType; + } + + void resumeMessageExpirationTimer() { + running = true; + if (getItemCount() > 0) { + notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD); + } + } + + void pauseMessageExpirationTimer() { + running = false; + if (getItemCount() > 0) { + notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD); + } + } + + private static class MessageDetailsDiffer extends DiffUtil.ItemCallback> { + @Override + public boolean areItemsTheSame(@NonNull MessageDetailsViewState oldItem, @NonNull MessageDetailsViewState newItem) { + Object oldData = oldItem.data; + Object newData = newItem.data; + + if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) { + switch (oldItem.itemType) { + case MessageDetailsViewState.MESSAGE_HEADER: + return true; + case MessageDetailsViewState.RECIPIENT_HEADER: + return oldData == newData; + case MessageDetailsViewState.RECIPIENT: + return ((RecipientDeliveryStatus) oldData).getRecipient().getId().equals(((RecipientDeliveryStatus) newData).getRecipient().getId()); + } + } + + return false; + } + + @SuppressLint("DiffUtilEquals") + @Override + public boolean areContentsTheSame(@NonNull MessageDetailsViewState oldItem, @NonNull MessageDetailsViewState newItem) { + Object oldData = oldItem.data; + Object newData = newItem.data; + + if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) { + switch (oldItem.itemType) { + case MessageDetailsViewState.MESSAGE_HEADER: + return false; + case MessageDetailsViewState.RECIPIENT_HEADER: + return true; + case MessageDetailsViewState.RECIPIENT: + return ((RecipientDeliveryStatus) oldData).getDeliveryStatus() == ((RecipientDeliveryStatus) newData).getDeliveryStatus(); + } + } + + return false; + } + } + + static final class MessageDetailsViewState { + public static final int MESSAGE_HEADER = 0; + public static final int RECIPIENT_HEADER = 1; + public static final int RECIPIENT = 2; + + private final T data; + private int itemType; + + MessageDetailsViewState(T t, int itemType) { + this.data = t; + this.itemType = itemType; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java new file mode 100644 index 00000000..0ab36826 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.LinkedList; +import java.util.List; + +final class MessageDetailsRepository { + + private final Context context = ApplicationDependencies.getApplication(); + + @NonNull LiveData getMessageRecord(String type, Long messageId) { + return new MessageRecordLiveData(context, type, messageId); + } + + @NonNull LiveData getMessageDetails(@Nullable MessageRecord messageRecord) { + final MutableLiveData liveData = new MutableLiveData<>(); + + if (messageRecord != null) { + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(getRecipientDeliveryStatusesInternal(messageRecord))); + } else { + liveData.setValue(null); + } + + return liveData; + } + + @WorkerThread + private @NonNull MessageDetails getRecipientDeliveryStatusesInternal(@NonNull MessageRecord messageRecord) { + List recipients = new LinkedList<>(); + + if (!messageRecord.getRecipient().isGroup()) { + recipients.add(new RecipientDeliveryStatus(messageRecord, + messageRecord.getRecipient(), + getStatusFor(messageRecord), + messageRecord.isUnidentified(), + -1, + getNetworkFailure(messageRecord, messageRecord.getRecipient()), + getKeyMismatchFailure(messageRecord, messageRecord.getRecipient()))); + } else { + List receiptInfoList = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageRecord.getId()); + + if (receiptInfoList.isEmpty()) { + List group = DatabaseFactory.getGroupDatabase(context).getGroupMembers(messageRecord.getRecipient().requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + for (Recipient recipient : group) { + recipients.add(new RecipientDeliveryStatus(messageRecord, + recipient, + RecipientDeliveryStatus.Status.UNKNOWN, + false, + -1, + getNetworkFailure(messageRecord, recipient), + getKeyMismatchFailure(messageRecord, recipient))); + } + } else { + for (GroupReceiptDatabase.GroupReceiptInfo info : receiptInfoList) { + Recipient recipient = Recipient.resolved(info.getRecipientId()); + NetworkFailure failure = getNetworkFailure(messageRecord, recipient); + IdentityKeyMismatch mismatch = getKeyMismatchFailure(messageRecord, recipient); + boolean recipientFailure = failure != null || mismatch != null; + + recipients.add(new RecipientDeliveryStatus(messageRecord, + recipient, + getStatusFor(info.getStatus(), messageRecord.isPending(), recipientFailure), + info.isUnidentified(), + info.getTimestamp(), + failure, + mismatch)); + } + } + } + + return new MessageDetails(ConversationMessageFactory.createWithUnresolvedData(context, messageRecord), recipients); + } + + private @Nullable NetworkFailure getNetworkFailure(MessageRecord messageRecord, Recipient recipient) { + if (messageRecord.hasNetworkFailures()) { + for (final NetworkFailure failure : messageRecord.getNetworkFailures()) { + if (failure.getRecipientId(context).equals(recipient.getId())) { + return failure; + } + } + } + return null; + } + + private @Nullable IdentityKeyMismatch getKeyMismatchFailure(MessageRecord messageRecord, Recipient recipient) { + if (messageRecord.isIdentityMismatchFailure()) { + for (final IdentityKeyMismatch mismatch : messageRecord.getIdentityKeyMismatches()) { + if (mismatch.getRecipientId(context).equals(recipient.getId())) { + return mismatch; + } + } + } + return null; + } + + private @NonNull RecipientDeliveryStatus.Status getStatusFor(MessageRecord messageRecord) { + if (messageRecord.isRemoteRead()) return RecipientDeliveryStatus.Status.READ; + if (messageRecord.isDelivered()) return RecipientDeliveryStatus.Status.DELIVERED; + if (messageRecord.isSent()) return RecipientDeliveryStatus.Status.SENT; + if (messageRecord.isPending()) return RecipientDeliveryStatus.Status.PENDING; + + return RecipientDeliveryStatus.Status.UNKNOWN; + } + + private @NonNull RecipientDeliveryStatus.Status getStatusFor(int groupStatus, boolean pending, boolean failed) { + if (groupStatus == GroupReceiptDatabase.STATUS_READ) return RecipientDeliveryStatus.Status.READ; + else if (groupStatus == GroupReceiptDatabase.STATUS_DELIVERED) return RecipientDeliveryStatus.Status.DELIVERED; + else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && failed) return RecipientDeliveryStatus.Status.UNKNOWN; + else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && !pending) return RecipientDeliveryStatus.Status.SENT; + else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED) return RecipientDeliveryStatus.Status.PENDING; + else if (groupStatus == GroupReceiptDatabase.STATUS_UNKNOWN) return RecipientDeliveryStatus.Status.UNKNOWN; + throw new AssertionError(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsViewModel.java new file mode 100644 index 00000000..3c9affc2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.messagedetails; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +final class MessageDetailsViewModel extends ViewModel { + + private final LiveData recipient; + private final LiveData messageDetails; + + private MessageDetailsViewModel(RecipientId recipientId, String type, Long messageId) { + recipient = Recipient.live(recipientId).getLiveData(); + + MessageDetailsRepository repository = new MessageDetailsRepository(); + LiveData messageRecord = repository.getMessageRecord(type, messageId); + + messageDetails = Transformations.switchMap(messageRecord, repository::getMessageDetails); + } + + @NonNull LiveData getRecipientColor() { + return Transformations.distinctUntilChanged(Transformations.map(recipient, Recipient::getColor)); + } + + @NonNull LiveData getMessageDetails() { + return messageDetails; + } + + static final class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + private final String type; + private final Long messageId; + + Factory(RecipientId recipientId, String type, Long messageId) { + this.recipientId = recipientId; + this.type = type; + this.messageId = messageId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new MessageDetailsViewModel(recipientId, type, messageId))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java new file mode 100644 index 00000000..4f63908f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -0,0 +1,214 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.View; +import android.view.ViewStub; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationItem; +import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.sql.Date; +import java.text.SimpleDateFormat; +import java.util.HashSet; +import java.util.Locale; + +final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { + private final TextView sentDate; + private final TextView receivedDate; + private final TextView expiresIn; + private final TextView transport; + private final View expiresGroup; + private final View receivedGroup; + private final TextView errorText; + private final View resendButton; + private final View messageMetadata; + private final ViewStub updateStub; + private final ViewStub sentStub; + private final ViewStub receivedStub; + + private GlideRequests glideRequests; + private ConversationItem conversationItem; + private ExpiresUpdater expiresUpdater; + + MessageHeaderViewHolder(@NonNull View itemView, GlideRequests glideRequests) { + super(itemView); + this.glideRequests = glideRequests; + + sentDate = itemView.findViewById(R.id.message_details_header_sent_time); + receivedDate = itemView.findViewById(R.id.message_details_header_received_time); + receivedGroup = itemView.findViewById(R.id.message_details_header_received_group); + expiresIn = itemView.findViewById(R.id.message_details_header_expires_in); + expiresGroup = itemView.findViewById(R.id.message_details_header_expires_group); + transport = itemView.findViewById(R.id.message_details_header_transport); + errorText = itemView.findViewById(R.id.message_details_header_error_text); + resendButton = itemView.findViewById(R.id.message_details_header_resend_button); + messageMetadata = itemView.findViewById(R.id.message_details_header_message_metadata); + updateStub = itemView.findViewById(R.id.message_details_header_message_view_update); + sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia); + receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia); + } + + void bind(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage, boolean running) { + MessageRecord messageRecord = conversationMessage.getMessageRecord(); + bindMessageView(lifecycleOwner, conversationMessage); + bindErrorState(messageRecord); + bindSentReceivedDates(messageRecord); + bindExpirationTime(messageRecord, running); + bindTransport(messageRecord); + } + + void partialBind(ConversationMessage conversationMessage, boolean running) { + bindExpirationTime(conversationMessage.getMessageRecord(), running); + } + + private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage) { + if (conversationItem == null) { + if (conversationMessage.getMessageRecord().isGroupAction()) { + conversationItem = (ConversationItem) updateStub.inflate(); + } else if (conversationMessage.getMessageRecord().isOutgoing()) { + conversationItem = (ConversationItem) sentStub.inflate(); + } else { + conversationItem = (ConversationItem) receivedStub.inflate(); + } + } + conversationItem.bind(lifecycleOwner, conversationMessage, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), conversationMessage.getMessageRecord().getRecipient(), null, false, false, false); + } + + private void bindErrorState(MessageRecord messageRecord) { + if (messageRecord.hasFailedWithNetworkFailures()) { + errorText.setVisibility(View.VISIBLE); + resendButton.setVisibility(View.VISIBLE); + resendButton.setOnClickListener(unused -> { + resendButton.setOnClickListener(null); + SignalExecutors.BOUNDED.execute(() -> MessageSender.resend(itemView.getContext().getApplicationContext(), messageRecord)); + }); + messageMetadata.setVisibility(View.GONE); + } else if (messageRecord.isFailed()) { + errorText.setVisibility(View.VISIBLE); + resendButton.setVisibility(View.GONE); + resendButton.setOnClickListener(null); + messageMetadata.setVisibility(View.GONE); + } else { + errorText.setVisibility(View.GONE); + resendButton.setVisibility(View.GONE); + resendButton.setOnClickListener(null); + messageMetadata.setVisibility(View.VISIBLE); + } + } + + private void bindSentReceivedDates(MessageRecord messageRecord) { + sentDate.setOnLongClickListener(null); + receivedDate.setOnLongClickListener(null); + + if (messageRecord.isPending() || messageRecord.isFailed()) { + sentDate.setText("-"); + receivedGroup.setVisibility(View.GONE); + } else { + Locale dateLocale = Locale.getDefault(); + SimpleDateFormat dateFormatter = DateUtils.getDetailedDateFormatter(itemView.getContext(), dateLocale); + sentDate.setText(dateFormatter.format(new Date(messageRecord.getDateSent()))); + sentDate.setOnLongClickListener(v -> { + copyToClipboard(String.valueOf(messageRecord.getDateSent())); + return true; + }); + + if (messageRecord.getDateReceived() != messageRecord.getDateSent() && !messageRecord.isOutgoing()) { + receivedDate.setText(dateFormatter.format(new Date(messageRecord.getDateReceived()))); + receivedDate.setOnLongClickListener(v -> { + copyToClipboard(String.valueOf(messageRecord.getDateReceived())); + return true; + }); + receivedGroup.setVisibility(View.VISIBLE); + } else { + receivedGroup.setVisibility(View.GONE); + } + } + } + + private void bindExpirationTime(final MessageRecord messageRecord, boolean running) { + if (expiresUpdater != null) { + expiresUpdater.stop(); + expiresUpdater = null; + } + + if (messageRecord.getExpiresIn() <= 0 || messageRecord.getExpireStarted() <= 0) { + expiresGroup.setVisibility(View.GONE); + return; + } + + expiresGroup.setVisibility(View.VISIBLE); + if (running) { + expiresUpdater = new ExpiresUpdater(messageRecord); + Util.runOnMain(expiresUpdater); + } + } + + private void bindTransport(MessageRecord messageRecord) { + final String transportText; + if (messageRecord.isOutgoing() && messageRecord.isFailed()) { + transportText = "-"; + } else if (messageRecord.isPending()) { + transportText = itemView.getContext().getString(R.string.ConversationFragment_pending); + } else if (messageRecord.isPush()) { + transportText = itemView.getContext().getString(R.string.ConversationFragment_push); + } else if (messageRecord.isMms()) { + transportText = itemView.getContext().getString(R.string.ConversationFragment_mms); + } else { + transportText = itemView.getContext().getString(R.string.ConversationFragment_sms); + } + + transport.setText(transportText); + } + + private void copyToClipboard(String text) { + ((ClipboardManager) itemView.getContext().getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", text)); + } + + private class ExpiresUpdater implements Runnable { + + private final long expireStartedTimestamp; + private final long expiresInTimestamp; + private boolean running; + + ExpiresUpdater(MessageRecord messageRecord) { + expireStartedTimestamp = messageRecord.getExpireStarted(); + expiresInTimestamp = messageRecord.getExpiresIn(); + running = true; + } + + @Override + public void run() { + long elapsed = System.currentTimeMillis() - expireStartedTimestamp; + long remaining = expiresInTimestamp - elapsed; + int expirationTime = Math.max((int) (remaining / 1000), 1); + String duration = ExpirationUtil.getExpirationDisplayValue(itemView.getContext(), expirationTime); + + expiresIn.setText(duration); + + if (running && expirationTime > 1) { + Util.runOnMainDelayed(this, 500); + } + } + + void stop() { + running = false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageRecordLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageRecordLiveData.java new file mode 100644 index 00000000..791231dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageRecordLiveData.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; + +final class MessageRecordLiveData extends LiveData { + + private final Context context; + private final String type; + private final Long messageId; + private final ContentObserver obs; + + private @Nullable Cursor cursor; + + MessageRecordLiveData(Context context, String type, Long messageId) { + this.context = context; + this.type = type; + this.messageId = messageId; + + obs = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + SignalExecutors.BOUNDED.execute(() -> resetCursor()); + } + }; + } + + @Override + protected void onActive() { + retrieveMessageRecord(); + } + + @Override + protected void onInactive() { + SignalExecutors.BOUNDED.execute(this::destroyCursor); + } + + private void retrieveMessageRecord() { + SignalExecutors.BOUNDED.execute(this::retrieveMessageRecordActual); + } + + @WorkerThread + private synchronized void destroyCursor() { + if (cursor != null) { + cursor.unregisterContentObserver(obs); + cursor.close(); + cursor = null; + } + } + + @WorkerThread + private synchronized void resetCursor() { + destroyCursor(); + retrieveMessageRecord(); + } + + @WorkerThread + private synchronized void retrieveMessageRecordActual() { + if (cursor != null) { + return; + } + switch (type) { + case MmsSmsDatabase.SMS_TRANSPORT: + handleSms(); + break; + case MmsSmsDatabase.MMS_TRANSPORT: + handleMms(); + break; + default: + throw new AssertionError("no valid message type specified"); + } + } + + @WorkerThread + private synchronized void handleSms() { + final MessageDatabase db = DatabaseFactory.getSmsDatabase(context); + final Cursor cursor = db.getVerboseMessageCursor(messageId); + final MessageRecord record = SmsDatabase.readerFor(cursor).getNext(); + + postValue(record); + cursor.registerContentObserver(obs); + this.cursor = cursor; + } + + @WorkerThread + private synchronized void handleMms() { + final MessageDatabase db = DatabaseFactory.getMmsDatabase(context); + final Cursor cursor = db.getVerboseMessageCursor(messageId); + final MessageRecord record = MmsDatabase.readerFor(cursor).getNext(); + + postValue(record); + cursor.registerContentObserver(obs); + this.cursor = cursor; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientDeliveryStatus.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientDeliveryStatus.java new file mode 100644 index 00000000..49a8d493 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientDeliveryStatus.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.messagedetails; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +final class RecipientDeliveryStatus { + + enum Status { + UNKNOWN, PENDING, SENT, DELIVERED, READ + } + + private final MessageRecord messageRecord; + private final Recipient recipient; + private final Status deliveryStatus; + private final boolean isUnidentified; + private final long timestamp; + private final NetworkFailure networkFailure; + private final IdentityKeyMismatch keyMismatchFailure; + + RecipientDeliveryStatus(@NonNull MessageRecord messageRecord, @NonNull Recipient recipient, @NonNull Status deliveryStatus, boolean isUnidentified, long timestamp, @Nullable NetworkFailure networkFailure, @Nullable IdentityKeyMismatch keyMismatchFailure) { + this.messageRecord = messageRecord; + this.recipient = recipient; + this.deliveryStatus = deliveryStatus; + this.isUnidentified = isUnidentified; + this.timestamp = timestamp; + this.networkFailure = networkFailure; + this.keyMismatchFailure = keyMismatchFailure; + } + + @NonNull MessageRecord getMessageRecord() { + return messageRecord; + } + + @NonNull Status getDeliveryStatus() { + return deliveryStatus; + } + + boolean isUnidentified() { + return isUnidentified; + } + + long getTimestamp() { + return timestamp; + } + + @NonNull Recipient getRecipient() { + return recipient; + } + + @Nullable NetworkFailure getNetworkFailure() { + return networkFailure; + } + + @Nullable IdentityKeyMismatch getKeyMismatchFailure() { + return keyMismatchFailure; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeader.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeader.java new file mode 100644 index 00000000..600fe6ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeader.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.messagedetails; + +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +enum RecipientHeader { + PENDING(R.string.message_details_recipient_header__pending_send), + SENT_TO(R.string.message_details_recipient_header__sent_to), + SENT_FROM(R.string.message_details_recipient_header__sent_from), + DELIVERED(R.string.message_details_recipient_header__delivered_to), + READ(R.string.message_details_recipient_header__read_by), + NOT_SENT(R.string.message_details_recipient_header__not_sent); + + private final int headerText; + + RecipientHeader(@StringRes int headerText) { + this.headerText = headerText; + } + + @StringRes int getHeaderText() { + return headerText; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeaderViewHolder.java new file mode 100644 index 00000000..07a751da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientHeaderViewHolder.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.view.View; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.DeliveryStatusView; + +final class RecipientHeaderViewHolder extends RecyclerView.ViewHolder { + private final TextView header; + private final DeliveryStatusView deliveryStatus; + + RecipientHeaderViewHolder(View itemView) { + super(itemView); + + header = itemView.findViewById(R.id.recipient_header_text); + deliveryStatus = itemView.findViewById(R.id.recipient_header_delivery_status); + } + + void bind(RecipientHeader recipientHeader) { + header.setText(recipientHeader.getHeaderText()); + switch (recipientHeader) { + case PENDING: + deliveryStatus.setPending(); + break; + case SENT_TO: + deliveryStatus.setSent(); + break; + case DELIVERED: + deliveryStatus.setDelivered(); + break; + case READ: + deliveryStatus.setRead(); + break; + default: + deliveryStatus.setNone(); + break; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java new file mode 100644 index 00000000..0ef0e0ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/RecipientViewHolder.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.messagedetails; + +import android.view.View; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.ConfirmIdentityDialog; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; + +final class RecipientViewHolder extends RecyclerView.ViewHolder { + private final AvatarImageView avatar; + private final FromTextView fromView; + private final TextView timestamp; + private final TextView error; + private final View conflictButton; + private final View unidentifiedDeliveryIcon; + + RecipientViewHolder(View itemView) { + super(itemView); + + fromView = itemView.findViewById(R.id.message_details_recipient_name); + avatar = itemView.findViewById(R.id.message_details_recipient_avatar); + timestamp = itemView.findViewById(R.id.message_details_recipient_timestamp); + error = itemView.findViewById(R.id.message_details_recipient_error_description); + conflictButton = itemView.findViewById(R.id.message_details_recipient_conflict_button); + unidentifiedDeliveryIcon = itemView.findViewById(R.id.message_details_recipient_ud_indicator); + } + + void bind(RecipientDeliveryStatus data) { + unidentifiedDeliveryIcon.setVisibility(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(itemView.getContext()) && data.isUnidentified() ? View.VISIBLE : View.GONE); + fromView.setText(data.getRecipient()); + avatar.setRecipient(data.getRecipient()); + + if (data.getKeyMismatchFailure() != null) { + timestamp.setVisibility(View.GONE); + error.setVisibility(View.VISIBLE); + conflictButton.setVisibility(View.VISIBLE); + error.setText(itemView.getContext().getString(R.string.message_details_recipient__new_safety_number)); + conflictButton.setOnClickListener(unused -> new ConfirmIdentityDialog(itemView.getContext(), data.getMessageRecord(), data.getKeyMismatchFailure()).show()); + } else if ((data.getNetworkFailure() != null && !data.getMessageRecord().isPending()) || (!data.getMessageRecord().getRecipient().isPushGroup() && data.getMessageRecord().isFailed())) { + timestamp.setVisibility(View.GONE); + error.setVisibility(View.VISIBLE); + conflictButton.setVisibility(View.GONE); + error.setText(itemView.getContext().getString(R.string.message_details_recipient__failed_to_send)); + } else { + timestamp.setVisibility(View.VISIBLE); + error.setVisibility(View.GONE); + conflictButton.setVisibility(View.GONE); + + if (data.getTimestamp() > 0) { + Locale dateLocale = Locale.getDefault(); + timestamp.setText(DateUtils.getTimeString(itemView.getContext(), dateLocale, data.getTimestamp())); + } else { + timestamp.setText(""); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messageprocessingalarm/MessageProcessReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/messageprocessingalarm/MessageProcessReceiver.java new file mode 100644 index 00000000..b65ca288 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messageprocessingalarm/MessageProcessReceiver.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.messageprocessingalarm; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobTracker; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.concurrent.TimeUnit; + +/** + * On received message, runs a job to poll for messages. + */ +public final class MessageProcessReceiver extends BroadcastReceiver { + + private static final String TAG = Log.tag(MessageProcessReceiver.class); + + private static final long FIRST_RUN_DELAY = TimeUnit.MINUTES.toMillis(3); + private static final long FOREGROUND_DELAY = 300; + private static final long JOB_TIMEOUT = FOREGROUND_DELAY + 200; + + public static final String BROADCAST_ACTION = "org.thoughtcrime.securesms.action.PROCESS_MESSAGES"; + + @Override + @SuppressLint("StaticFieldLeak") + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + Log.i(TAG, String.format("onReceive(%s)", intent.getAction())); + + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + Log.i(TAG, "Starting Alarm because of boot receiver"); + startOrUpdateAlarm(context); + } else if (BROADCAST_ACTION.equals(intent.getAction())) { + PendingResult pendingResult = goAsync(); + + new Handler(Looper.getMainLooper()).postDelayed(pendingResult::finish, JOB_TIMEOUT); + + SignalExecutors.BOUNDED.submit(() -> { + Log.i(TAG, "Running PushNotificationReceiveJob"); + + Optional jobState = ApplicationDependencies.getJobManager() + .runSynchronously(PushNotificationReceiveJob.withDelayedForegroundService(FOREGROUND_DELAY), JOB_TIMEOUT); + + Log.i(TAG, "PushNotificationReceiveJob ended: " + (jobState.isPresent() ? jobState.get().toString() : "Job did not complete")); + }); + } + } + + public static void startOrUpdateAlarm(@NonNull Context context) { + Intent alarmIntent = new Intent(context, MessageProcessReceiver.class); + + alarmIntent.setAction(MessageProcessReceiver.BROADCAST_ACTION); + + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 123, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + long interval = FeatureFlags.getBackgroundMessageProcessDelay(); + + if (interval < 0) { + alarmManager.cancel(pendingIntent); + Log.i(TAG, "Alarm cancelled"); + } else { + alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + FIRST_RUN_DELAY, + interval, + pendingIntent); + Log.i(TAG, "Alarm scheduled to repeat at interval " + interval); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestActivity.java new file mode 100644 index 00000000..f800d51e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestActivity.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.concurrent.TimeUnit; + +import static android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION; + +public class CalleeMustAcceptMessageRequestActivity extends BaseActivity { + + private static final long TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10); + private static final String RECIPIENT_ID_EXTRA = "extra.recipient.id"; + + private TextView description; + private AvatarImageView avatar; + private View okay; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable finisher = this::finish; + + public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) { + Intent intent = new Intent(context, CalleeMustAcceptMessageRequestActivity.class); + intent.setFlags(FLAG_ACTIVITY_NO_ANIMATION); + intent.putExtra(RECIPIENT_ID_EXTRA, recipientId); + return intent; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.callee_must_accept_message_request_dialog_fragment); + + description = findViewById(R.id.description); + avatar = findViewById(R.id.avatar); + okay = findViewById(R.id.okay); + + avatar.setFallbackPhotoProvider(new FallbackPhotoProvider()); + okay.setOnClickListener(v -> finish()); + + RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_ID_EXTRA); + CalleeMustAcceptMessageRequestViewModel.Factory factory = new CalleeMustAcceptMessageRequestViewModel.Factory(recipientId); + CalleeMustAcceptMessageRequestViewModel viewModel = ViewModelProviders.of(this, factory).get(CalleeMustAcceptMessageRequestViewModel.class); + + viewModel.getRecipient().observe(this, recipient -> { + description.setText(getString(R.string.CalleeMustAcceptMessageRequestDialogFragment__s_will_get_a_message_request_from_you, recipient.getDisplayName(this))); + avatar.setAvatar(GlideApp.with(this), recipient, false); + }); + } + + @Override + public void onResume() { + super.onResume(); + + handler.postDelayed(finisher, TIMEOUT_MS); + } + + @Override + public void onPause() { + super.onPause(); + + handler.removeCallbacks(finisher); + } + + private static class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_80); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java new file mode 100644 index 00000000..587f9334 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.messagerequests; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public class CalleeMustAcceptMessageRequestViewModel extends ViewModel { + + private final LiveData recipient; + + private CalleeMustAcceptMessageRequestViewModel(@NonNull RecipientId recipientId) { + recipient = Recipient.live(recipientId).getLiveData(); + } + + public LiveData getRecipient() { + return recipient; + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new CalleeMustAcceptMessageRequestViewModel(recipientId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java new file mode 100644 index 00000000..028638ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.messagerequests; + +final class GroupMemberCount { + static final GroupMemberCount ZERO = new GroupMemberCount(0, 0); + + private final int fullMemberCount; + private final int pendingMemberCount; + + GroupMemberCount(int fullMemberCount, int pendingMemberCount) { + this.fullMemberCount = fullMemberCount; + this.pendingMemberCount = pendingMemberCount; + } + + int getFullMemberCount() { + return fullMemberCount; + } + + int getPendingMemberCount() { + return pendingMemberCount; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java new file mode 100644 index 00000000..ec29acca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class MessageRequestMegaphoneActivity extends PassphraseRequiredActivity { + + public static final short EDIT_PROFILE_REQUEST_CODE = 24563; + + private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState, boolean isReady) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.message_requests_megaphone_activity); + + + LottieAnimationView lottie = findViewById(R.id.message_requests_lottie); + TextView profileNameButton = findViewById(R.id.message_requests_confirm_profile_name); + + lottie.setAnimation(R.raw.lottie_message_requests_splash); + lottie.playAnimation(); + + profileNameButton.setOnClickListener(v -> { + final Intent profile = new Intent(this, EditProfileActivity.class); + + profile.putExtra(EditProfileActivity.SHOW_TOOLBAR, false); + profile.putExtra(EditProfileActivity.NEXT_BUTTON_TEXT, R.string.save); + + startActivityForResult(profile, EDIT_PROFILE_REQUEST_CODE); + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == EDIT_PROFILE_REQUEST_CODE && + resultCode == RESULT_OK && + Recipient.self().getProfileName() != ProfileName.EMPTY) { + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.MESSAGE_REQUESTS); + setResult(RESULT_OK); + finish(); + } + } + + @Override + public void onBackPressed() { + } + + @Override + protected void onResume() { + super.onResume(); + + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java new file mode 100644 index 00000000..b387df61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -0,0 +1,290 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; +import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.thoughtcrime.securesms.notifications.MarkReadReceiver; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.Executor; + +final class MessageRequestRepository { + + private static final String TAG = Log.tag(MessageRequestRepository.class); + + private final Context context; + private final Executor executor; + + MessageRequestRepository(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.executor = SignalExecutors.BOUNDED; + } + + void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer> onGroupsLoaded) { + executor.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + onGroupsLoaded.accept(groupDatabase.getPushGroupNamesContainingMember(recipientId)); + }); + } + + void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { + executor.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional groupRecord = groupDatabase.getGroup(recipientId); + onMemberCountLoaded.accept(groupRecord.transform(record -> { + if (record.isV2Group()) { + DecryptedGroup decryptedGroup = record.requireV2GroupProperties().getDecryptedGroup(); + return new GroupMemberCount(decryptedGroup.getMembersCount(), decryptedGroup.getPendingMembersCount()); + } else { + return new GroupMemberCount(record.getMembers().size(), 0); + } + }).or(GroupMemberCount.ZERO)); + }); + } + + @WorkerThread + @NonNull MessageRequestState getMessageRequestState(@NonNull Recipient recipient, long threadId) { + if (recipient.isBlocked()) { + if (recipient.isGroup()) { + return MessageRequestState.BLOCKED_GROUP; + } else { + return MessageRequestState.BLOCKED_INDIVIDUAL; + } + } else if (threadId <= 0) { + return MessageRequestState.NONE; + } else if (recipient.isPushV2Group()) { + switch (getGroupMemberLevel(recipient.getId())) { + case NOT_A_MEMBER: + return MessageRequestState.NONE; + case PENDING_MEMBER: + return MessageRequestState.GROUP_V2_INVITE; + default: + if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { + return MessageRequestState.NONE; + } else { + return MessageRequestState.GROUP_V2_ADD; + } + } + } else if (!RecipientUtil.isLegacyProfileSharingAccepted(recipient) && isLegacyThread(recipient)) { + if (recipient.isGroup()) { + return MessageRequestState.LEGACY_GROUP_V1; + } else { + return MessageRequestState.LEGACY_INDIVIDUAL; + } + } else if (recipient.isPushV1Group()) { + if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { + if (FeatureFlags.groupsV1ForcedMigration()) { + if (recipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + return MessageRequestState.DEPRECATED_GROUP_V1_TOO_LARGE; + } else { + return MessageRequestState.DEPRECATED_GROUP_V1; + } + } else { + return MessageRequestState.NONE; + } + } else if (!recipient.isActiveGroup()) { + return MessageRequestState.NONE; + } else { + return MessageRequestState.GROUP_V1; + } + } else { + if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { + return MessageRequestState.NONE; + } else { + return MessageRequestState.INDIVIDUAL; + } + } + } + + void acceptMessageRequest(@NonNull LiveRecipient liveRecipient, + long threadId, + @NonNull Runnable onMessageRequestAccepted, + @NonNull GroupChangeErrorCallback error) + { + executor.execute(()-> { + if (liveRecipient.get().isPushV2Group()) { + try { + Log.i(TAG, "GV2 accepting invite"); + GroupManager.acceptInvite(context, liveRecipient.get().requireGroupId().requireV2()); + + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setProfileSharing(liveRecipient.getId(), true); + + onMessageRequestAccepted.run(); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + } else { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + recipientDatabase.setProfileSharing(liveRecipient.getId(), true); + + MessageSender.sendProfileKey(context, threadId); + + List messageIds = DatabaseFactory.getThreadDatabase(context) + .setEntireThreadRead(threadId); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + + List viewedInfos = DatabaseFactory.getMmsDatabase(context) + .getViewedIncomingMessages(threadId); + + ApplicationDependencies.getJobManager() + .add(new SendViewedReceiptJob(threadId, + liveRecipient.getId(), + Stream.of(viewedInfos) + .map(info -> info.getSyncMessageId().getTimetamp()) + .toList())); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId())); + } + + onMessageRequestAccepted.run(); + } + }); + } + + void deleteMessageRequest(@NonNull LiveRecipient recipient, + long threadId, + @NonNull Runnable onMessageRequestDeleted, + @NonNull GroupChangeErrorCallback error) + { + executor.execute(() -> { + Recipient resolved = recipient.resolve(); + + if (resolved.isGroup() && resolved.requireGroupId().isPush()) { + try { + GroupManager.leaveGroupFromBlockOrMessageRequest(context, resolved.requireGroupId().requirePush()); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + return; + } + } + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forDelete(recipient.getId())); + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + threadDatabase.deleteConversation(threadId); + + onMessageRequestDeleted.run(); + }); + } + + void blockMessageRequest(@NonNull LiveRecipient liveRecipient, + @NonNull Runnable onMessageRequestBlocked, + @NonNull GroupChangeErrorCallback error) + { + executor.execute(() -> { + Recipient recipient = liveRecipient.resolve(); + try { + RecipientUtil.block(context, recipient); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + return; + } + liveRecipient.refresh(); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlock(liveRecipient.getId())); + } + + onMessageRequestBlocked.run(); + }); + } + + void blockAndDeleteMessageRequest(@NonNull LiveRecipient liveRecipient, + long threadId, + @NonNull Runnable onMessageRequestBlocked, + @NonNull GroupChangeErrorCallback error) + { + executor.execute(() -> { + Recipient recipient = liveRecipient.resolve(); + try{ + RecipientUtil.block(context, recipient); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + return; + } + liveRecipient.refresh(); + + DatabaseFactory.getThreadDatabase(context).deleteConversation(threadId); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlockAndDelete(liveRecipient.getId())); + } + + onMessageRequestBlocked.run(); + }); + } + + void unblockAndAccept(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestUnblocked) { + executor.execute(() -> { + Recipient recipient = liveRecipient.resolve(); + + RecipientUtil.unblock(context, recipient); + + List messageIds = DatabaseFactory.getThreadDatabase(context) + .setEntireThreadRead(threadId); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId())); + } + + onMessageRequestUnblocked.run(); + }); + } + + private GroupDatabase.MemberLevel getGroupMemberLevel(@NonNull RecipientId recipientId) { + return DatabaseFactory.getGroupDatabase(context) + .getGroup(recipientId) + .transform(g -> g.memberLevel(Recipient.self())) + .or(GroupDatabase.MemberLevel.NOT_A_MEMBER); + } + + + @WorkerThread + private boolean isLegacyThread(@NonNull Recipient recipient) { + Context context = ApplicationDependencies.getApplication(); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient.getId()); + + return threadId != null && + (RecipientUtil.hasSentMessageInThread(context, threadId) || RecipientUtil.isPreMessageRequestThread(context, threadId)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java new file mode 100644 index 00000000..d0d8e85b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.messagerequests; + +/** + * An enum representing the possible message request states a user can be in. + */ +public enum MessageRequestState { + /** No message request necessary */ + NONE, + + /** A user is blocked */ + BLOCKED_INDIVIDUAL, + + /** A group is blocked */ + BLOCKED_GROUP, + + /** An individual conversation that existed pre-message-requests but doesn't have profile sharing enabled */ + LEGACY_INDIVIDUAL, + + /** A V1 group conversation that existed pre-message-requests but doesn't have profile sharing enabled */ + LEGACY_GROUP_V1, + + /** A V1 group conversation that is no longer allowed, because we've forced GV2 on. */ + DEPRECATED_GROUP_V1, + + /** A V1 group conversation that is no longer allowed, because we've forced GV2 on, but it's also too large to migrate. Nothing we can do. */ + DEPRECATED_GROUP_V1_TOO_LARGE, + + /** A message request is needed for a V1 group */ + GROUP_V1, + + /** An invite response is needed for a V2 group */ + GROUP_V2_INVITE, + + /** A message request is needed for a V2 group */ + GROUP_V2_ADD, + + /** A message request is needed for an individual */ + INDIVIDUAL +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java new file mode 100644 index 00000000..33bb096e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -0,0 +1,262 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataTriple; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collections; +import java.util.List; + +public class MessageRequestViewModel extends ViewModel { + + private final SingleLiveEvent status = new SingleLiveEvent<>(); + private final SingleLiveEvent failures = new SingleLiveEvent<>(); + private final MutableLiveData recipient = new MutableLiveData<>(); + private final LiveData messageData; + private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); + private final MutableLiveData memberCount = new MutableLiveData<>(GroupMemberCount.ZERO); + private final LiveData requestReviewDisplayState; + private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), + triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); + + private final MessageRequestRepository repository; + + private LiveRecipient liveRecipient; + private long threadId; + + private final RecipientForeverObserver recipientObserver = recipient -> { + loadMemberCount(); + this.recipient.setValue(recipient); + }; + + private MessageRequestViewModel(MessageRequestRepository repository) { + this.repository = repository; + this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient); + this.requestReviewDisplayState = LiveDataUtil.mapAsync(messageData, MessageRequestViewModel::transformHolderToReviewDisplayState); + } + + public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) { + if (liveRecipient != null) { + liveRecipient.removeForeverObserver(recipientObserver); + } + + liveRecipient = Recipient.live(recipientId); + this.threadId = threadId; + + loadRecipient(); + loadGroups(); + loadMemberCount(); + } + + @Override + protected void onCleared() { + if (liveRecipient != null) { + liveRecipient.removeForeverObserver(recipientObserver); + } + } + + public LiveData getRequestReviewDisplayState() { + return requestReviewDisplayState; + } + + public LiveData getRecipient() { + return recipient; + } + + public LiveData getMessageData() { + return messageData; + } + + public LiveData getRecipientInfo() { + return recipientInfo; + } + + public LiveData getMessageRequestStatus() { + return status; + } + + public LiveData getFailures() { + return failures; + } + + public boolean shouldShowMessageRequest() { + MessageData data = messageData.getValue(); + return data != null && data.getMessageState() != MessageRequestState.NONE; + } + + @MainThread + public void onAccept() { + status.setValue(Status.ACCEPTING); + repository.acceptMessageRequest(liveRecipient, + threadId, + () -> status.postValue(Status.ACCEPTED), + this::onGroupChangeError); + } + + @MainThread + public void onDelete() { + status.setValue(Status.DELETING); + repository.deleteMessageRequest(liveRecipient, + threadId, + () -> status.postValue(Status.DELETED), + this::onGroupChangeError); + } + + @MainThread + public void onBlock() { + status.setValue(Status.BLOCKING); + repository.blockMessageRequest(liveRecipient, + () -> status.postValue(Status.BLOCKED), + this::onGroupChangeError); + } + + @MainThread + public void onUnblock() { + repository.unblockAndAccept(liveRecipient, + threadId, + () -> status.postValue(Status.ACCEPTED)); + } + + @MainThread + public void onBlockAndDelete() { + repository.blockAndDeleteMessageRequest(liveRecipient, + threadId, + () -> status.postValue(Status.BLOCKED), + this::onGroupChangeError); + } + + private void onGroupChangeError(@NonNull GroupChangeFailureReason error) { + status.postValue(Status.IDLE); + failures.postValue(error); + } + + private void loadRecipient() { + liveRecipient.observeForever(recipientObserver); + SignalExecutors.BOUNDED.execute(() -> { + liveRecipient.refresh(); + recipient.postValue(liveRecipient.get()); + }); + } + + private void loadGroups() { + repository.getGroups(liveRecipient.getId(), this.groups::postValue); + } + + private void loadMemberCount() { + repository.getMemberCount(liveRecipient.getId(), memberCount::postValue); + } + + private static RequestReviewDisplayState transformHolderToReviewDisplayState(@NonNull MessageData holder) { + if (holder.getMessageState() == MessageRequestState.INDIVIDUAL) { + return ReviewUtil.isRecipientReviewSuggested(holder.getRecipient().getId()) ? RequestReviewDisplayState.SHOWN + : RequestReviewDisplayState.HIDDEN; + } else { + return RequestReviewDisplayState.NONE; + } + } + + @WorkerThread + private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) { + MessageRequestState state = repository.getMessageRequestState(recipient, threadId); + return new MessageData(recipient, state); + } + + public static class RecipientInfo { + @Nullable private final Recipient recipient; + @NonNull private final GroupMemberCount groupMemberCount; + @NonNull private final List sharedGroups; + + private RecipientInfo(@Nullable Recipient recipient, @Nullable GroupMemberCount groupMemberCount, @Nullable List sharedGroups) { + this.recipient = recipient; + this.groupMemberCount = groupMemberCount == null ? GroupMemberCount.ZERO : groupMemberCount; + this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; + } + + @Nullable + public Recipient getRecipient() { + return recipient; + } + + public int getGroupMemberCount() { + return groupMemberCount.getFullMemberCount(); + } + + public int getGroupPendingMemberCount() { + return groupMemberCount.getPendingMemberCount(); + } + + @NonNull + public List getSharedGroups() { + return sharedGroups; + } + } + + public enum Status { + IDLE, + BLOCKING, + BLOCKED, + DELETING, + DELETED, + ACCEPTING, + ACCEPTED + } + + public enum RequestReviewDisplayState { + HIDDEN, + SHOWN, + NONE + } + + public static final class MessageData { + private final Recipient recipient; + private final MessageRequestState messageState; + + public MessageData(@NonNull Recipient recipient, @NonNull MessageRequestState messageState) { + this.recipient = recipient; + this.messageState = messageState; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @NonNull MessageRequestState getMessageState() { + return messageState; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + + public Factory(Context context) { + this.context = context; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext())); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java new file mode 100644 index 00000000..cb003bdf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.Group; +import androidx.core.text.HtmlCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.HtmlUtil; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +public class MessageRequestsBottomView extends ConstraintLayout { + + private final Debouncer showProgressDebouncer = new Debouncer(250); + + private LearnMoreTextView question; + private Button accept; + private Button gv1Continue; + private View block; + private View delete; + private View bigDelete; + private View bigUnblock; + private View busyIndicator; + + private Group normalButtons; + private Group blockedButtons; + private Group gv1MigrationButtons; + private Group activeGroup; + + public MessageRequestsBottomView(Context context) { + super(context); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + inflate(getContext(), R.layout.message_request_bottom_bar, this); + + question = findViewById(R.id.message_request_question); + accept = findViewById(R.id.message_request_accept); + block = findViewById(R.id.message_request_block); + delete = findViewById(R.id.message_request_delete); + bigDelete = findViewById(R.id.message_request_big_delete); + bigUnblock = findViewById(R.id.message_request_big_unblock); + gv1Continue = findViewById(R.id.message_request_gv1_migration); + normalButtons = findViewById(R.id.message_request_normal_buttons); + blockedButtons = findViewById(R.id.message_request_blocked_buttons); + gv1MigrationButtons = findViewById(R.id.message_request_gv1_migration_buttons); + busyIndicator = findViewById(R.id.message_request_busy_indicator); + } + + public void setMessageData(@NonNull MessageRequestViewModel.MessageData messageData) { + Recipient recipient = messageData.getRecipient(); + + question.setLearnMoreVisible(false); + question.setOnLinkClickListener(null); + + switch (messageData.getMessageState()) { + case BLOCKED_INDIVIDUAL: + question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them, + HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0)); + setActiveInactiveGroups(blockedButtons, normalButtons, gv1MigrationButtons); + break; + case BLOCKED_GROUP: + question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members); + setActiveInactiveGroups(blockedButtons, normalButtons, gv1MigrationButtons); + break; + case LEGACY_INDIVIDUAL: + question.setText(getContext().getString(R.string.MessageRequestBottomView_continue_your_conversation_with_s_and_share_your_name_and_photo, recipient.getShortDisplayName(getContext()))); + question.setLearnMoreVisible(true); + question.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(getContext(), getContext().getString(R.string.MessageRequestBottomView_legacy_learn_more_url))); + setActiveInactiveGroups(normalButtons, blockedButtons, gv1MigrationButtons); + accept.setText(R.string.MessageRequestBottomView_continue); + break; + case LEGACY_GROUP_V1: + question.setText(R.string.MessageRequestBottomView_continue_your_conversation_with_this_group_and_share_your_name_and_photo); + question.setLearnMoreVisible(true); + question.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(getContext(), getContext().getString(R.string.MessageRequestBottomView_legacy_learn_more_url))); + setActiveInactiveGroups(normalButtons, blockedButtons, gv1MigrationButtons); + accept.setText(R.string.MessageRequestBottomView_continue); + break; + case DEPRECATED_GROUP_V1: + question.setText(R.string.MessageRequestBottomView_upgrade_this_group_to_activate_new_features); + setActiveInactiveGroups(gv1MigrationButtons, normalButtons, blockedButtons); + gv1Continue.setVisibility(VISIBLE); + break; + case DEPRECATED_GROUP_V1_TOO_LARGE: + question.setText(getContext().getString(R.string.MessageRequestBottomView_this_legacy_group_can_no_longer_be_used, FeatureFlags.groupLimits().getHardLimit() - 1)); + setActiveInactiveGroups(gv1MigrationButtons, normalButtons, blockedButtons); + gv1Continue.setVisibility(GONE); + break; + case GROUP_V1: + case GROUP_V2_INVITE: + question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept); + setActiveInactiveGroups(normalButtons, blockedButtons, gv1MigrationButtons); + accept.setText(R.string.MessageRequestBottomView_accept); + break; + case GROUP_V2_ADD: + question.setText(R.string.MessageRequestBottomView_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept); + setActiveInactiveGroups(normalButtons, blockedButtons, gv1MigrationButtons); + accept.setText(R.string.MessageRequestBottomView_accept); + break; + case INDIVIDUAL: + question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, + HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0)); + setActiveInactiveGroups(normalButtons, blockedButtons, gv1MigrationButtons); + accept.setText(R.string.MessageRequestBottomView_accept); + break; + } + } + + private void setActiveInactiveGroups(@NonNull Group activeGroup, @NonNull Group... inActiveGroups) { + int initialVisibility = this.activeGroup != null ? this.activeGroup.getVisibility() : VISIBLE; + + this.activeGroup = activeGroup; + + for (Group inactive : inActiveGroups) { + inactive.setVisibility(GONE); + } + + activeGroup.setVisibility(initialVisibility); + } + + public void showBusy() { + showProgressDebouncer.publish(() -> busyIndicator.setVisibility(VISIBLE)); + if (activeGroup != null) { + activeGroup.setVisibility(INVISIBLE); + } + } + + public void hideBusy() { + showProgressDebouncer.clear(); + busyIndicator.setVisibility(GONE); + if (activeGroup != null) { + activeGroup.setVisibility(VISIBLE); + } + } + + public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) { + accept.setOnClickListener(acceptOnClickListener); + } + + public void setDeleteOnClickListener(OnClickListener deleteOnClickListener) { + delete.setOnClickListener(deleteOnClickListener); + bigDelete.setOnClickListener(deleteOnClickListener); + } + + public void setBlockOnClickListener(OnClickListener blockOnClickListener) { + block.setOnClickListener(blockOnClickListener); + } + + public void setUnblockOnClickListener(OnClickListener unblockOnClickListener) { + bigUnblock.setOnClickListener(unblockOnClickListener); + } + + public void setGroupV1MigrationContinueListener(OnClickListener acceptOnClickListener) { + gv1Continue.setOnClickListener(acceptOnClickListener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/BackgroundMessageRetriever.java b/app/src/main/java/org/thoughtcrime/securesms/messages/BackgroundMessageRetriever.java new file mode 100644 index 00000000..b043ec12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/BackgroundMessageRetriever.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.messages; + +import android.content.Context; +import android.os.PowerManager; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.service.DelayedNotificationController; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.util.PowerManagerCompat; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.WakeLockUtil; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * Retrieves messages while the app is in the background via provided {@link MessageRetrievalStrategy}'s. + */ +public class BackgroundMessageRetriever { + + private static final String TAG = Log.tag(BackgroundMessageRetriever.class); + + private static final String WAKE_LOCK_TAG = "MessageRetriever"; + + private static final Semaphore ACTIVE_LOCK = new Semaphore(2); + + private static final long NORMAL_TIMEOUT = TimeUnit.SECONDS.toMillis(10); + + public static final long DO_NOT_SHOW_IN_FOREGROUND = DelayedNotificationController.DO_NOT_SHOW; + + /** + * @return False if the retrieval failed and should be rescheduled, otherwise true. + */ + @WorkerThread + public boolean retrieveMessages(@NonNull Context context, MessageRetrievalStrategy... strategies) { + return retrieveMessages(context, DO_NOT_SHOW_IN_FOREGROUND, strategies); + } + + /** + * @return False if the retrieval failed and should be rescheduled, otherwise true. + */ + @WorkerThread + public boolean retrieveMessages(@NonNull Context context, long showNotificationAfterMs, MessageRetrievalStrategy... strategies) { + if (shouldIgnoreFetch(context)) { + Log.i(TAG, "Skipping retrieval -- app is in the foreground."); + return true; + } + + if (!ACTIVE_LOCK.tryAcquire()) { + Log.i(TAG, "Skipping retrieval -- there's already one enqueued."); + return true; + } + + synchronized (this) { + try (DelayedNotificationController controller = GenericForegroundService.startForegroundTaskDelayed(context, context.getString(R.string.BackgroundMessageRetriever_checking_for_messages), showNotificationAfterMs)) { + PowerManager.WakeLock wakeLock = null; + + try { + wakeLock = WakeLockUtil.acquire(context, PowerManager.PARTIAL_WAKE_LOCK, TimeUnit.SECONDS.toMillis(60), WAKE_LOCK_TAG); + + TextSecurePreferences.setNeedsMessagePull(context, true); + + long startTime = System.currentTimeMillis(); + PowerManager powerManager = ServiceUtil.getPowerManager(context); + boolean doze = PowerManagerCompat.isDeviceIdleMode(powerManager); + boolean network = new NetworkConstraint.Factory(ApplicationContext.getInstance(context)).create().isMet(); + + if (doze || !network) { + Log.w(TAG, "We may be operating in a constrained environment. Doze: " + doze + " Network: " + network); + } + + Log.i(TAG, "Performing normal message fetch."); + return executeBackgroundRetrieval(context, startTime, strategies); + } finally { + WakeLockUtil.release(wakeLock, WAKE_LOCK_TAG); + ACTIVE_LOCK.release(); + } + } + } + } + + private boolean executeBackgroundRetrieval(@NonNull Context context, long startTime, @NonNull MessageRetrievalStrategy[] strategies) { + boolean success = false; + + for (MessageRetrievalStrategy strategy : strategies) { + if (shouldIgnoreFetch(context)) { + Log.i(TAG, "Stopping further strategy attempts -- app is in the foreground." + logSuffix(startTime)); + success = true; + break; + } + + Log.i(TAG, "Attempting strategy: " + strategy.toString() + logSuffix(startTime)); + + if (strategy.execute(NORMAL_TIMEOUT)) { + Log.i(TAG, "Strategy succeeded: " + strategy.toString() + logSuffix(startTime)); + success = true; + break; + } else { + Log.w(TAG, "Strategy failed: " + strategy.toString() + logSuffix(startTime)); + } + } + + if (success) { + TextSecurePreferences.setNeedsMessagePull(context, false); + } else { + Log.w(TAG, "All strategies failed!" + logSuffix(startTime)); + } + + return success; + } + + /** + * @return True if there is no need to execute a message fetch, because the websocket will take + * care of it. + */ + public static boolean shouldIgnoreFetch(@NonNull Context context) { + return ApplicationDependencies.getAppForegroundObserver().isForegrounded() && + !ApplicationDependencies.getSignalServiceNetworkAccess().isCensored(context); + } + + private static String logSuffix(long startTime) { + return " (" + (System.currentTimeMillis() - startTime) + " ms elapsed)"; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.java b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.java new file mode 100644 index 00000000..b7638ab0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.java @@ -0,0 +1,296 @@ +package org.thoughtcrime.securesms.messages; + +import android.app.Application; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.os.IBinder; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobs.PushDecryptDrainedJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.messages.IncomingMessageProcessor.Processor; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.util.AppForegroundObserver; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidVersionException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessagePipe; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +public class IncomingMessageObserver { + + private static final String TAG = IncomingMessageObserver.class.getSimpleName(); + + public static final int FOREGROUND_ID = 313399; + private static final long REQUEST_TIMEOUT_MINUTES = 1; + + private static final AtomicInteger INSTANCE_COUNT = new AtomicInteger(0); + + private static SignalServiceMessagePipe pipe = null; + private static SignalServiceMessagePipe unidentifiedPipe = null; + + private final Application context; + private final SignalServiceNetworkAccess networkAccess; + private final List decryptionDrainedListeners; + + private boolean appVisible; + + private volatile boolean networkDrained; + private volatile boolean decryptionDrained; + private volatile boolean terminated; + + public IncomingMessageObserver(@NonNull Application context) { + if (INSTANCE_COUNT.incrementAndGet() != 1) { + throw new AssertionError("Multiple observers!"); + } + + this.context = context; + this.networkAccess = ApplicationDependencies.getSignalServiceNetworkAccess(); + this.decryptionDrainedListeners = new CopyOnWriteArrayList<>(); + + new MessageRetrievalThread().start(); + + if (TextSecurePreferences.isFcmDisabled(context)) { + ContextCompat.startForegroundService(context, new Intent(context, ForegroundService.class)); + } + + ApplicationDependencies.getAppForegroundObserver().addListener(new AppForegroundObserver.Listener() { + @Override + public void onForeground() { + onAppForegrounded(); + } + + @Override + public void onBackground() { + onAppBackgrounded(); + } + }); + + context.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + synchronized (IncomingMessageObserver.this) { + if (!NetworkConstraint.isMet(context)) { + Log.w(TAG, "Lost network connection. Shutting down our websocket connections and resetting the drained state."); + networkDrained = false; + decryptionDrained = false; + shutdown(pipe, unidentifiedPipe); + } + IncomingMessageObserver.this.notifyAll(); + } + } + }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + public synchronized void addDecryptionDrainedListener(@NonNull Runnable listener) { + decryptionDrainedListeners.add(listener); + if (decryptionDrained) { + listener.run(); + } + } + + public boolean isDecryptionDrained() { + return decryptionDrained || networkAccess.isCensored(context); + } + + public void notifyDecryptionsDrained() { + List listenersToTrigger = new ArrayList<>(decryptionDrainedListeners.size()); + + synchronized (this) { + if (networkDrained && !decryptionDrained) { + Log.i(TAG, "Decryptions newly drained."); + decryptionDrained = true; + listenersToTrigger.addAll(decryptionDrainedListeners); + } + } + + for (Runnable listener : listenersToTrigger) { + listener.run(); + } + } + + private synchronized void onAppForegrounded() { + appVisible = true; + notifyAll(); + } + + private synchronized void onAppBackgrounded() { + appVisible = false; + notifyAll(); + } + + private synchronized boolean isConnectionNecessary() { + boolean registered = TextSecurePreferences.isPushRegistered(context); + boolean websocketRegistered = TextSecurePreferences.isWebsocketRegistered(context); + boolean isGcmDisabled = TextSecurePreferences.isFcmDisabled(context); + boolean hasNetwork = NetworkConstraint.isMet(context); + boolean hasProxy = SignalStore.proxy().isProxyEnabled(); + + Log.d(TAG, String.format("Network: %s, Foreground: %s, FCM: %s, Censored: %s, Registered: %s, Websocket Registered: %s, Proxy: %s", + hasNetwork, appVisible, !isGcmDisabled, networkAccess.isCensored(context), registered, websocketRegistered, hasProxy)); + + return registered && + websocketRegistered && + (appVisible || isGcmDisabled) && + hasNetwork && + !networkAccess.isCensored(context); + } + + private synchronized void waitForConnectionNecessary() { + try { + while (!isConnectionNecessary()) wait(); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + public void terminateAsync() { + INSTANCE_COUNT.decrementAndGet(); + + SignalExecutors.BOUNDED.execute(() -> { + Log.w(TAG, "Beginning termination."); + terminated = true; + shutdown(pipe, unidentifiedPipe); + }); + } + + private void shutdown(@Nullable SignalServiceMessagePipe pipe, @Nullable SignalServiceMessagePipe unidentifiedPipe) { + try { + if (pipe != null) { + Log.w(TAG, "Shutting down normal pipe."); + pipe.shutdown(); + } else { + Log.w(TAG, "No need to shutdown normal pipe, it doesn't exist."); + } + } catch (Throwable t) { + Log.w(TAG, "Closing normal pipe failed!", t); + } + + try { + if (unidentifiedPipe != null) { + Log.w(TAG, "Shutting down unidentified pipe."); + unidentifiedPipe.shutdown(); + } else { + Log.w(TAG, "No need to shutdown unidentified pipe, it doesn't exist."); + } + } catch (Throwable t) { + Log.w(TAG, "Closing unidentified pipe failed!", t); + } + } + + public static @Nullable SignalServiceMessagePipe getPipe() { + return pipe; + } + + public static @Nullable SignalServiceMessagePipe getUnidentifiedPipe() { + return unidentifiedPipe; + } + + private class MessageRetrievalThread extends Thread implements Thread.UncaughtExceptionHandler { + + MessageRetrievalThread() { + super("MessageRetrievalService"); + Log.i(TAG, "Initializing! (" + this.hashCode() + ")"); + setUncaughtExceptionHandler(this); + } + + @Override + public void run() { + while (!terminated) { + Log.i(TAG, "Waiting for websocket state change...."); + waitForConnectionNecessary(); + + Log.i(TAG, "Making websocket connection...."); + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + + pipe = receiver.createMessagePipe(); + unidentifiedPipe = receiver.createUnidentifiedMessagePipe(); + + SignalServiceMessagePipe localPipe = pipe; + SignalServiceMessagePipe unidentifiedLocalPipe = unidentifiedPipe; + + try { + while (isConnectionNecessary()) { + try { + Log.d(TAG, "Reading message..."); + Optional result = localPipe.readOrEmpty(REQUEST_TIMEOUT_MINUTES, TimeUnit.MINUTES, envelope -> { + Log.i(TAG, "Retrieved envelope! " + envelope.getTimestamp()); + try (Processor processor = ApplicationDependencies.getIncomingMessageProcessor().acquire()) { + processor.processEnvelope(envelope); + } + }); + + if (!result.isPresent() && !networkDrained) { + Log.i(TAG, "Network was newly-drained. Enqueuing a job to listen for decryption draining."); + networkDrained = true; + ApplicationDependencies.getJobManager().add(new PushDecryptDrainedJob()); + } + } catch (TimeoutException e) { + Log.w(TAG, "Application level read timeout..."); + } + } + } catch (Throwable e) { + Log.w(TAG, e); + } finally { + Log.w(TAG, "Shutting down pipe..."); + shutdown(localPipe, unidentifiedLocalPipe); + } + + Log.i(TAG, "Looping..."); + } + + Log.w(TAG, "Terminated! (" + this.hashCode() + ")"); + } + + @Override + public void uncaughtException(Thread t, Throwable e) { + Log.w(TAG, "*** Uncaught exception!"); + Log.w(TAG, e); + } + } + + public static class ForegroundService extends Service { + + @Override + public @Nullable IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(), NotificationChannels.OTHER); + builder.setContentTitle(getApplicationContext().getString(R.string.MessageRetrievalService_signal)); + builder.setContentText(getApplicationContext().getString(R.string.MessageRetrievalService_background_connection_enabled)); + builder.setPriority(NotificationCompat.PRIORITY_MIN); + builder.setWhen(0); + builder.setSmallIcon(R.drawable.ic_signal_background_connection); + startForeground(FOREGROUND_ID, builder.build()); + + return Service.START_STICKY; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java new file mode 100644 index 00000000..069dfdef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.messages; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.jobs.PushProcessMessageJob; +import org.thoughtcrime.securesms.messages.MessageDecryptionUtil.DecryptionResult; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The central entry point for all envelopes that have been retrieved. Envelopes must be processed + * here to guarantee proper ordering. + */ +public class IncomingMessageProcessor { + + private static final String TAG = Log.tag(IncomingMessageProcessor.class); + + private final Application context; + private final ReentrantLock lock; + + public IncomingMessageProcessor(@NonNull Application context) { + this.context = context; + this.lock = new ReentrantLock(); + } + + /** + * @return An instance of a Processor that will allow you to process messages in a thread safe + * way. Must be closed. + */ + public Processor acquire() { + lock.lock(); + return new Processor(context); + } + + private void release() { + lock.unlock(); + } + + public class Processor implements Closeable { + + private final Context context; + private final MmsSmsDatabase mmsSmsDatabase; + private final JobManager jobManager; + + private Processor(@NonNull Context context) { + this.context = context; + this.mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + this.jobManager = ApplicationDependencies.getJobManager(); + } + + /** + * @return The id of the {@link PushDecryptMessageJob} that was scheduled to process the message, if + * one was created. Otherwise null. + */ + public @Nullable String processEnvelope(@NonNull SignalServiceEnvelope envelope) { + if (envelope.hasSource()) { + Recipient.externalHighTrustPush(context, envelope.getSourceAddress()); + } + + if (envelope.isReceipt()) { + processReceipt(envelope); + return null; + } else if (envelope.isPreKeySignalMessage() || envelope.isSignalMessage() || envelope.isUnidentifiedSender()) { + return processMessage(envelope); + } else { + Log.w(TAG, "Received envelope of unknown type: " + envelope.getType()); + return null; + } + } + + private @Nullable String processMessage(@NonNull SignalServiceEnvelope envelope) { + if (FeatureFlags.internalUser()) { + return processMessageInline(envelope); + } else { + return processMessageDeferred(envelope); + } + } + + private @Nullable String processMessageDeferred(@NonNull SignalServiceEnvelope envelope) { + Job job = new PushDecryptMessageJob(context, envelope); + jobManager.add(job); + return job.getId(); + } + + private @Nullable String processMessageInline(@NonNull SignalServiceEnvelope envelope) { + Log.i(TAG, "Received message " + envelope.getTimestamp() + "."); + + Stopwatch stopwatch = new Stopwatch("message"); + + if (needsToEnqueueDecryption()) { + Log.d(TAG, "Need to enqueue decryption."); + PushDecryptMessageJob job = new PushDecryptMessageJob(context, envelope); + jobManager.add(job); + return job.getId(); + } + + stopwatch.split("queue-check"); + + long ownerThreadId = DatabaseSessionLock.INSTANCE.getLikeyOwnerThreadId(); + if (ownerThreadId != DatabaseSessionLock.NO_OWNER && ownerThreadId != Thread.currentThread().getId()) { + Log.i(TAG, "It is likely that some other thread has this lock. Owner: " + ownerThreadId + ", Us: " + Thread.currentThread().getId()); + } + + try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + Log.i(TAG, "Acquired lock while processing message " + envelope.getTimestamp() + "."); + + DecryptionResult result = MessageDecryptionUtil.decrypt(context, envelope); + Log.d(TAG, "Decryption finished for " + envelope.getTimestamp()); + stopwatch.split("decrypt"); + + for (Job job : result.getJobs()) { + jobManager.add(job); + } + + stopwatch.split("jobs"); + + if (needsToEnqueueProcessing(result)) { + Log.d(TAG, "Need to enqueue processing."); + jobManager.add(new PushProcessMessageJob(result.getState(), result.getContent(), result.getException(), -1, envelope.getTimestamp())); + return null; + } + + stopwatch.split("group-check"); + + try { + MessageContentProcessor processor = new MessageContentProcessor(context); + processor.process(result.getState(), result.getContent(), result.getException(), envelope.getTimestamp(), -1); + return null; + } catch (IOException | GroupChangeBusyException e) { + Log.w(TAG, "Exception during message processing.", e); + jobManager.add(new PushProcessMessageJob(result.getState(), result.getContent(), result.getException(), -1, envelope.getTimestamp())); + } + } finally { + stopwatch.split("process"); + stopwatch.stop(TAG); + } + + return null; + } + + private void processReceipt(@NonNull SignalServiceEnvelope envelope) { + Log.i(TAG, "Received server receipt for " + envelope.getTimestamp()); + mmsSmsDatabase.incrementDeliveryReceiptCount(new SyncMessageId(Recipient.externalHighTrustPush(context, envelope.getSourceAddress()).getId(), envelope.getTimestamp()), + System.currentTimeMillis()); + } + + private boolean needsToEnqueueDecryption() { + return !jobManager.areQueuesEmpty(SetUtil.newHashSet(Job.Parameters.MIGRATION_QUEUE_KEY, PushDecryptMessageJob.QUEUE)) || + !IdentityKeyUtil.hasIdentityKey(context) || + TextSecurePreferences.getNeedsSqlCipherMigration(context); + } + + private boolean needsToEnqueueProcessing(@NonNull DecryptionResult result) { + SignalServiceGroupContext groupContext = GroupUtil.getGroupContextIfPresent(result.getContent()); + + if (groupContext != null) { + try { + GroupId groupId = GroupUtil.idFromGroupContext(groupContext); + + if (groupId.isV2()) { + String queueName = PushProcessMessageJob.getQueueName(Recipient.externalPossiblyMigratedGroup(context, groupId).getId()); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + return !jobManager.isQueueEmpty(queueName) || + groupContext.getGroupV2().get().getRevision() > groupDatabase.getGroupV2Revision(groupId.requireV2()) || + groupDatabase.getGroupV1ByExpectedV2(groupId.requireV2()).isPresent(); + } else { + return false; + } + } catch (BadGroupIdException e) { + Log.w(TAG, "Bad group ID!"); + return false; + } + } else if (result.getContent() != null) { + RecipientId recipientId = RecipientId.fromHighTrust(result.getContent().getSender()); + String queueKey = PushProcessMessageJob.getQueueName(recipientId); + + return !jobManager.isQueueEmpty(queueKey); + } else if (result.getException() != null) { + RecipientId recipientId = Recipient.external(context, result.getException().getSender()).getId(); + String queueKey = PushProcessMessageJob.getQueueName(recipientId); + + return !jobManager.isQueueEmpty(queueKey); + } else { + return false; + } + } + + @Override + public void close() { + release(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java new file mode 100644 index 00000000..4f52c585 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -0,0 +1,1965 @@ +package org.thoughtcrime.securesms.messages; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.tm.androidcopysdk.DataGrabber; + +import org.archiver.ArchiveConstants; +import org.archiver.ArchiveSender; +import org.archiver.ArchiveUtil; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.attachments.PointerAttachment; +import org.thoughtcrime.securesms.attachments.TombstoneAttachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactModelMapper; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.crypto.SecurityEvent; +import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.GroupV1MessageProcessor; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; +import org.thoughtcrime.securesms.jobs.AutomaticSessionResetJob; +import org.thoughtcrime.securesms.jobs.GroupCallPeekJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackSyncJob; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; +import org.thoughtcrime.securesms.jobs.RequestGroupInfoJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; +import org.thoughtcrime.securesms.mms.IncomingMediaMessage; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.mms.QuoteModel; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.notifications.MessageNotifier; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage; +import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; +import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.RemoteDeleteUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.state.SessionStore; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; +import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.shared.SharedContact; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Takes data about a decrypted message, transforms it into user-presentable data, and writes that + * data to our data stores. + */ +public final class MessageContentProcessor { + + private static final String TAG = Log.tag(MessageContentProcessor.class); + + private final Context context; + + public MessageContentProcessor(@NonNull Context context) { + this.context = context; + } + + /** + * Given the details about a message decryption, this will insert the proper message content into + * the database. + * + * This is super-stateful, and it's recommended that this be run in a transaction so that no + * intermediate results are persisted to the database if the app were to crash. + */ + public void process(MessageState messageState, @Nullable SignalServiceContent content, @Nullable ExceptionMetadata exceptionMetadata, long timestamp, long smsMessageId) + throws IOException, GroupChangeBusyException + { + Optional optionalSmsMessageId = smsMessageId > 0 ? Optional.of(smsMessageId) : Optional.absent(); + + if (messageState == MessageState.DECRYPTED_OK) { + handleMessage(content, timestamp, optionalSmsMessageId); + + if (content != null) { + Optional> earlyContent = ApplicationDependencies.getEarlyMessageCache() + .retrieve(Recipient.externalPush(context, content.getSender()).getId(), + content.getTimestamp()); + if (earlyContent.isPresent()) { + log(String.valueOf(content.getTimestamp()), "Found " + earlyContent.get().size() + " dependent item(s) that were retrieved earlier. Processing."); + + for (SignalServiceContent earlyItem : earlyContent.get()) { + handleMessage(earlyItem, timestamp, Optional.absent()); + } + } + } + } else if (exceptionMetadata != null) { + handleExceptionMessage(messageState, exceptionMetadata, timestamp, optionalSmsMessageId); + } else if (messageState == MessageState.NOOP) { + Log.d(TAG, "Nothing to do: " + messageState.name()); + } else { + warn("Bad state! messageState: " + messageState); + } + } + + private void handleMessage(@Nullable SignalServiceContent content, long timestamp, @NonNull Optional smsMessageId) + throws IOException, GroupChangeBusyException + { + try { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + if (content == null || shouldIgnore(content)) { + log(content != null ? String.valueOf(content.getTimestamp()) : "null", "Ignoring message."); + return; + } + + log(String.valueOf(content.getTimestamp()), "Beginning message processing."); + + if (content.getDataMessage().isPresent()) { + SignalServiceDataMessage message = content.getDataMessage().get(); + boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent() || message.getMentions().isPresent(); + Optional groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); + boolean isGv2Message = groupId.isPresent() && groupId.get().isV2(); + + if (isGv2Message) { + GroupId.V2 groupIdV2 = groupId.get().requireV2(); + + Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2); + if (possibleGv1.isPresent()) { + GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1()); + } + + if (!updateGv2GroupFromServerOrP2PChange(content, message.getGroupContext().get().getGroupV2().get())) { + log(String.valueOf(content.getTimestamp()), "Ignoring GV2 message for group we are not currently in " + groupIdV2); + return; + } + + Recipient sender = Recipient.externalPush(context, content.getSender()); + if (!groupDatabase.isCurrentMember(groupIdV2, sender.getId())) { + log(String.valueOf(content.getTimestamp()), "Ignoring GV2 message from member not in group " + groupIdV2); + return; + } + } + + if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId); + else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); + else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId, groupId.get().requireV1()); + else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId, groupId); + else if (message.getReaction().isPresent()) handleReaction(content, message); + else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message); + else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId, groupId); + else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId); + else if (FeatureFlags.groupCalling() && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId); + + if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { + handleUnknownGroupMessage(content, message.getGroupContext().get()); + } + + if (message.getProfileKey().isPresent()) { + handleProfileKey(content, message.getProfileKey().get()); + } + + if (content.isNeedsReceipt()) { + handleNeedsDeliveryReceipt(content, message); + } + } else if (content.getSyncMessage().isPresent()) { + TextSecurePreferences.setMultiDevice(context, true); + + SignalServiceSyncMessage syncMessage = content.getSyncMessage().get(); + + if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(content, syncMessage.getSent().get()); + else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(syncMessage.getRequest().get()); + else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp()); + else if (syncMessage.getViewOnceOpen().isPresent()) handleSynchronizeViewOnceOpenMessage(syncMessage.getViewOnceOpen().get(), content.getTimestamp()); + else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get()); + else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get()); + else if (syncMessage.getConfiguration().isPresent()) handleSynchronizeConfigurationMessage(syncMessage.getConfiguration().get()); + else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get()); + else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get()); + else if (syncMessage.getMessageRequestResponse().isPresent()) handleSynchronizeMessageRequestResponse(syncMessage.getMessageRequestResponse().get()); + else warn(String.valueOf(content.getTimestamp()), "Contains no known sync types..."); + } else if (content.getCallMessage().isPresent()) { + log(String.valueOf(content.getTimestamp()), "Got call message..."); + + SignalServiceCallMessage message = content.getCallMessage().get(); + Optional destinationDeviceId = message.getDestinationDeviceId(); + + if (destinationDeviceId.isPresent() && destinationDeviceId.get() != 1) { + log(String.valueOf(content.getTimestamp()), String.format(Locale.US, "Ignoring call message that is not for this device! intended: %d, this: %d", destinationDeviceId.get(), 1)); + return; + } + + if (message.getOfferMessage().isPresent()) handleCallOfferMessage(content, message.getOfferMessage().get(), smsMessageId); + else if (message.getAnswerMessage().isPresent()) handleCallAnswerMessage(content, message.getAnswerMessage().get()); + else if (message.getIceUpdateMessages().isPresent()) handleCallIceUpdateMessage(content, message.getIceUpdateMessages().get()); + else if (message.getHangupMessage().isPresent()) handleCallHangupMessage(content, message.getHangupMessage().get(), smsMessageId); + else if (message.getBusyMessage().isPresent()) handleCallBusyMessage(content, message.getBusyMessage().get()); + else if (message.getOpaqueMessage().isPresent()) handleCallOpaqueMessage(content, message.getOpaqueMessage().get()); + } else if (content.getReceiptMessage().isPresent()) { + SignalServiceReceiptMessage message = content.getReceiptMessage().get(); + + if (message.isReadReceipt()) handleReadReceipt(content, message); + else if (message.isDeliveryReceipt()) handleDeliveryReceipt(content, message); + else if (message.isViewedReceipt()) handleViewedReceipt(content, message); + } else if (content.getTypingMessage().isPresent()) { + handleTypingMessage(content, content.getTypingMessage().get()); + } else { + warn(String.valueOf(content.getTimestamp()), "Got unrecognized message!"); + } + + resetRecipientToPush(Recipient.externalPush(context, content.getSender())); + } catch (StorageFailedException e) { + warn(String.valueOf(content.getTimestamp()), e); + handleCorruptMessage(e.getSender(), e.getSenderDevice(), timestamp, smsMessageId); + } catch (BadGroupIdException e) { + warn(String.valueOf(content.getTimestamp()), "Ignoring message with bad group id", e); + } + } + + private static @Nullable + SignalServiceGroupContext getGroupContextIfPresent(@NonNull SignalServiceContent content) { + if (content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupContext().isPresent()) { + return content.getDataMessage().get().getGroupContext().get(); + } else if (content.getSyncMessage().isPresent() && + content.getSyncMessage().get().getSent().isPresent() && + content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().isPresent()) + { + return content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().get(); + } else { + return null; + } + } + + /** + * Attempts to update the group to the revision mentioned in the message. + * If the local version is at least the revision in the message it will not query the server. + * If the message includes a signed change proto that is sufficient (i.e. local revision is only + * 1 revision behind), it will also not query the server in this case. + * + * @return false iff needed to query the server and was not able to because self is not a current + * member of the group. + */ + private boolean updateGv2GroupFromServerOrP2PChange(@NonNull SignalServiceContent content, + @NonNull SignalServiceGroupV2 groupV2) + throws IOException, GroupChangeBusyException + { + try { + GroupManager.updateGroupFromServer(context, groupV2.getMasterKey(), groupV2.getRevision(), content.getTimestamp(), groupV2.getSignedGroupChange()); + return true; + } catch (GroupNotAMemberException e) { + warn(String.valueOf(content.getTimestamp()), "Ignoring message for a group we're not in"); + return false; + } + } + + private void handleExceptionMessage(@NonNull MessageState messageState, @NonNull ExceptionMetadata e, long timestamp, @NonNull Optional smsMessageId) { + Recipient sender = Recipient.external(context, e.sender); + + if (sender.isBlocked()) { + warn("Ignoring exception content from blocked sender, message state:" + messageState); + return; + } + + switch (messageState) { + case INVALID_VERSION: + warn(String.valueOf(timestamp), "Handling invalid version."); + handleInvalidVersionMessage(e.sender, e.senderDevice, timestamp, smsMessageId); + break; + + case LEGACY_MESSAGE: + warn(String.valueOf(timestamp), "Handling legacy message."); + handleLegacyMessage(e.sender, e.senderDevice, timestamp, smsMessageId); + break; + + case DUPLICATE_MESSAGE: + warn(String.valueOf(timestamp), "Duplicate message. Dropping."); + break; + + case UNSUPPORTED_DATA_MESSAGE: + warn(String.valueOf(timestamp), "Handling unsupported data message."); + handleUnsupportedDataMessage(e.sender, e.senderDevice, Optional.fromNullable(e.groupId), timestamp, smsMessageId); + break; + + case CORRUPT_MESSAGE: + case NO_SESSION: + warn(String.valueOf(timestamp), "Discovered old enqueued bad encrypted message. Scheduling reset."); + ApplicationDependencies.getJobManager().add(new AutomaticSessionResetJob(Recipient.external(context, e.sender).getId(), e.senderDevice, timestamp)); + break; + + default: + throw new AssertionError("Not handled " + messageState + ". (" + timestamp + ")"); + } + } + + private void handleCallOfferMessage(@NonNull SignalServiceContent content, + @NonNull OfferMessage message, + @NonNull Optional smsMessageId) + { + log(String.valueOf(content.getTimestamp()), "handleCallOfferMessage..."); + + if (smsMessageId.isPresent()) { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + database.markAsMissedCall(smsMessageId.get(), message.getType() == OfferMessage.Type.VIDEO_CALL); + } else { + Intent intent = new Intent(context, WebRtcCallService.class); + Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender()); + RemotePeer remotePeer = new RemotePeer(recipient.getId()); + byte[] remoteIdentityKey = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()).transform(record -> record.getIdentityKey().serialize()).orNull(); + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_OFFER) + .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) + .putExtra(WebRtcCallService.EXTRA_REMOTE_IDENTITY_KEY, remoteIdentityKey) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) + .putExtra(WebRtcCallService.EXTRA_OFFER_OPAQUE, message.getOpaque()) + .putExtra(WebRtcCallService.EXTRA_OFFER_SDP, message.getSdp()) + .putExtra(WebRtcCallService.EXTRA_SERVER_RECEIVED_TIMESTAMP, content.getServerReceivedTimestamp()) + .putExtra(WebRtcCallService.EXTRA_SERVER_DELIVERED_TIMESTAMP, content.getServerDeliveredTimestamp()) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, message.getType().getCode()) + .putExtra(WebRtcCallService.EXTRA_MULTI_RING, content.getCallMessage().get().isMultiRing()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent); + else context.startService(intent); + } + } + + private void handleCallAnswerMessage(@NonNull SignalServiceContent content, + @NonNull AnswerMessage message) + { + log(String.valueOf(content), "handleCallAnswerMessage..."); + Intent intent = new Intent(context, WebRtcCallService.class); + Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender()); + RemotePeer remotePeer = new RemotePeer(recipient.getId()); + byte[] remoteIdentityKey = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()).transform(record -> record.getIdentityKey().serialize()).orNull(); + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_ANSWER) + .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) + .putExtra(WebRtcCallService.EXTRA_REMOTE_IDENTITY_KEY, remoteIdentityKey) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) + .putExtra(WebRtcCallService.EXTRA_ANSWER_OPAQUE, message.getOpaque()) + .putExtra(WebRtcCallService.EXTRA_ANSWER_SDP, message.getSdp()) + .putExtra(WebRtcCallService.EXTRA_MULTI_RING, content.getCallMessage().get().isMultiRing()); + + context.startService(intent); + } + + private void handleCallIceUpdateMessage(@NonNull SignalServiceContent content, + @NonNull List messages) + { + log(String.valueOf(content), "handleCallIceUpdateMessage... " + messages.size()); + + ArrayList iceCandidates = new ArrayList<>(messages.size()); + long callId = -1; + for (IceUpdateMessage iceMessage : messages) { + iceCandidates.add(new IceCandidateParcel(iceMessage)); + callId = iceMessage.getId(); + } + + Intent intent = new Intent(context, WebRtcCallService.class); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_ICE_CANDIDATES) + .putExtra(WebRtcCallService.EXTRA_CALL_ID, callId) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) + .putParcelableArrayListExtra(WebRtcCallService.EXTRA_ICE_CANDIDATES, iceCandidates); + + context.startService(intent); + } + + private void handleCallHangupMessage(@NonNull SignalServiceContent content, + @NonNull HangupMessage message, + @NonNull Optional smsMessageId) + { + log(String.valueOf(content), "handleCallHangupMessage"); + if (smsMessageId.isPresent()) { + DatabaseFactory.getSmsDatabase(context).markAsMissedCall(smsMessageId.get(), false); + } else { + Intent intent = new Intent(context, WebRtcCallService.class); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_HANGUP) + .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) + .putExtra(WebRtcCallService.EXTRA_HANGUP_IS_LEGACY, message.isLegacy()) + .putExtra(WebRtcCallService.EXTRA_HANGUP_DEVICE_ID, message.getDeviceId()) + .putExtra(WebRtcCallService.EXTRA_HANGUP_TYPE, message.getType().getCode()); + + context.startService(intent); + } + } + + private void handleCallBusyMessage(@NonNull SignalServiceContent content, + @NonNull BusyMessage message) + { + log(String.valueOf(content.getTimestamp()), "handleCallBusyMessage"); + + Intent intent = new Intent(context, WebRtcCallService.class); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_BUSY) + .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()); + + context.startService(intent); + } + + private void handleCallOpaqueMessage(@NonNull SignalServiceContent content, + @NonNull OpaqueMessage message) + { + log(String.valueOf(content.getTimestamp()), "handleCallOpaqueMessage"); + + Intent intent = new Intent(context, WebRtcCallService.class); + + long messageAgeSeconds = 0; + if (content.getServerReceivedTimestamp() > 0 && content.getServerDeliveredTimestamp() >= content.getServerReceivedTimestamp()) { + messageAgeSeconds = (content.getServerDeliveredTimestamp() - content.getServerReceivedTimestamp()) / 1000; + } + + intent.setAction(WebRtcCallService.ACTION_RECEIVE_OPAQUE_MESSAGE) + .putExtra(WebRtcCallService.EXTRA_OPAQUE_MESSAGE, message.getOpaque()) + .putExtra(WebRtcCallService.EXTRA_UUID, Recipient.externalHighTrustPush(context, content.getSender()).requireUuid().toString()) + .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) + .putExtra(WebRtcCallService.EXTRA_MESSAGE_AGE_SECONDS, messageAgeSeconds); + + context.startService(intent); + } + + private void handleGroupCallUpdateMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional groupId) + { + if (!groupId.isPresent() || !groupId.get().isV2()) { + Log.w(TAG, "Invalid group for group call update message"); + return; + } + + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(groupId.get()); + + DatabaseFactory.getSmsDatabase(context).insertOrUpdateGroupCall(groupRecipientId, + RecipientId.from(content.getSender()), + content.getServerReceivedTimestamp(), + message.getGroupCallUpdate().get().getEraId()); + + GroupCallPeekJob.enqueue(groupRecipientId); + } + + private void handleEndSessionMessage(@NonNull SignalServiceContent content, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + IncomingTextMessage incomingTextMessage = new IncomingTextMessage(Recipient.externalHighTrustPush(context, content.getSender()).getId(), + content.getSenderDevice(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + "", Optional.absent(), 0, + content.isNeedsReceipt()); + + Long threadId; + + if (!smsMessageId.isPresent()) { + IncomingEndSessionMessage incomingEndSessionMessage = new IncomingEndSessionMessage(incomingTextMessage); + Optional insertResult = smsDatabase.insertMessageInbox(incomingEndSessionMessage); + + if (insertResult.isPresent()) threadId = insertResult.get().getThreadId(); + else threadId = null; + } else { + smsDatabase.markAsEndSession(smsMessageId.get()); + threadId = smsDatabase.getThreadIdForMessage(smsMessageId.get()); + } + + if (threadId != null) { + SessionStore sessionStore = new TextSecureSessionStore(context); + sessionStore.deleteAllSessions(content.getSender().getIdentifier()); + + SecurityEvent.broadcastSecurityUpdateEvent(context); + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId); + } + } + + private long handleSynchronizeSentEndSessionMessage(@NonNull SentTranscriptMessage message) + throws BadGroupIdException + { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + Recipient recipient = getSyncMessageDestination(message); + OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, "", -1); + OutgoingEndSessionMessage outgoingEndSessionMessage = new OutgoingEndSessionMessage(outgoingTextMessage); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + if (!recipient.isGroup()) { + SessionStore sessionStore = new TextSecureSessionStore(context); + sessionStore.deleteAllSessions(recipient.requireServiceId()); + + SecurityEvent.broadcastSecurityUpdateEvent(context); + + long messageId = database.insertMessageOutbox(threadId, outgoingEndSessionMessage, + false, message.getTimestamp(), + null); + database.markAsSent(messageId, true); + } + + return threadId; + } + + private void handleGroupV1Message(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional smsMessageId, + @NonNull GroupId.V1 groupId) + throws StorageFailedException, BadGroupIdException + { + GroupV1MessageProcessor.process(context, content, message, false); + + if (message.getExpiresInSeconds() != 0 && message.getExpiresInSeconds() != getMessageDestination(content, message).getExpireMessages()) { + handleExpirationUpdate(content, message, Optional.absent(), Optional.of(groupId)); + } + + if (smsMessageId.isPresent()) { + DatabaseFactory.getSmsDatabase(context).deleteMessage(smsMessageId.get()); + } + } + + private void handleUnknownGroupMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceGroupContext group) + throws BadGroupIdException + { + if (group.getGroupV1().isPresent()) { + SignalServiceGroup groupV1 = group.getGroupV1().get(); + if (groupV1.getType() != SignalServiceGroup.Type.REQUEST_INFO) { + ApplicationDependencies.getJobManager().add(new RequestGroupInfoJob(Recipient.externalHighTrustPush(context, content.getSender()).getId(), GroupId.v1(groupV1.getGroupId()))); + } else { + warn(String.valueOf(content.getTimestamp()), "Received a REQUEST_INFO message for a group we don't know about. Ignoring."); + } + } else { + warn(String.valueOf(content.getTimestamp()), "Received a message for a group we don't know about without a GV1 context. Ignoring."); + } + } + + private void handleExpirationUpdate(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional smsMessageId, + @NonNull Optional groupId) + throws StorageFailedException, BadGroupIdException + { + if (groupId.isPresent() && groupId.get().isV2()) { + warn(String.valueOf(content.getTimestamp()), "Expiration update received for GV2. Ignoring."); + return; + } + + int expiresInSeconds = message.getExpiresInSeconds(); + Optional groupContext = message.getGroupContext(); + Recipient recipient = getMessageDestination(content, groupContext); + + if (recipient.getExpireMessages() == expiresInSeconds) { + log(String.valueOf(content.getTimestamp()), "No change in message expiry for group. Ignoring."); + return; + } + + try { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(sender.getId(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + -1, + expiresInSeconds * 1000L, + true, + false, + content.isNeedsReceipt(), + Optional.absent(), + groupContext, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent()); + + database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipient.getId(), expiresInSeconds); + + if (smsMessageId.isPresent()) { + DatabaseFactory.getSmsDatabase(context).deleteMessage(smsMessageId.get()); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } + } + + private void handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { + SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); + + Recipient targetAuthor = Recipient.externalPush(context, reaction.getTargetAuthor()); + MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId()); + + if (targetMessage != null && !targetMessage.isRemoteDelete()) { + Recipient reactionAuthor = Recipient.externalHighTrustPush(context, content.getSender()); + MessageDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + if (reaction.isRemove()) { + db.deleteReaction(targetMessage.getId(), reactionAuthor.getId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + } else { + ReactionRecord reactionRecord = new ReactionRecord(reaction.getEmoji(), reactionAuthor.getId(), message.getTimestamp(), System.currentTimeMillis()); + db.addReaction(targetMessage.getId(), reactionRecord); + ApplicationDependencies.getMessageNotifier().updateNotification(context, targetMessage.getThreadId(), false); + } + } else if (targetMessage != null) { + warn(String.valueOf(content.getTimestamp()), "[handleReaction] Found a matching message, but it's flagged as remotely deleted. timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId()); + } else { + warn(String.valueOf(content.getTimestamp()), "[handleReaction] Could not find matching message! timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId()); + ApplicationDependencies.getEarlyMessageCache().store(targetAuthor.getId(), reaction.getTargetSentTimestamp(), content); + } + } + + private void handleRemoteDelete(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { + SignalServiceDataMessage.RemoteDelete delete = message.getRemoteDelete().get(); + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(delete.getTargetSentTimestamp(), sender.getId()); + + if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, sender, content.getServerReceivedTimestamp())) { + MessageDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + db.markAsRemoteDelete(targetMessage.getId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, targetMessage.getThreadId(), false); + } else if (targetMessage == null) { + warn(String.valueOf(content.getTimestamp()), "[handleRemoteDelete] Could not find matching message! timestamp: " + delete.getTargetSentTimestamp() + " author: " + sender.getId()); + ApplicationDependencies.getEarlyMessageCache().store(sender.getId(), delete.getTargetSentTimestamp(), content); + } else { + warn(String.valueOf(content.getTimestamp()), String.format(Locale.ENGLISH, "[handleRemoteDelete] Invalid remote delete! deleteTime: %d, targetTime: %d, deleteAuthor: %s, targetAuthor: %s", + content.getServerReceivedTimestamp(), targetMessage.getServerTimestamp(), sender.getId(), targetMessage.getRecipient().getId())); + } + } + + private void handleSynchronizeVerifiedMessage(@NonNull VerifiedMessage verifiedMessage) { + IdentityUtil.processVerifiedMessage(context, verifiedMessage); + } + + private void handleSynchronizeStickerPackOperation(@NonNull List stickerPackOperations) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + for (StickerPackOperationMessage operation : stickerPackOperations) { + if (operation.getPackId().isPresent() && operation.getPackKey().isPresent() && operation.getType().isPresent()) { + String packId = Hex.toStringCondensed(operation.getPackId().get()); + String packKey = Hex.toStringCondensed(operation.getPackKey().get()); + + switch (operation.getType().get()) { + case INSTALL: + jobManager.add(StickerPackDownloadJob.forInstall(packId, packKey, false)); + break; + case REMOVE: + DatabaseFactory.getStickerDatabase(context).uninstallPack(packId); + break; + } + } else { + warn("Received incomplete sticker pack operation sync."); + } + } + } + + private void handleSynchronizeConfigurationMessage(@NonNull ConfigurationMessage configurationMessage) { + if (configurationMessage.getReadReceipts().isPresent()) { + TextSecurePreferences.setReadReceiptsEnabled(context, configurationMessage.getReadReceipts().get()); + } + + if (configurationMessage.getUnidentifiedDeliveryIndicators().isPresent()) { + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, configurationMessage.getReadReceipts().get()); + } + + if (configurationMessage.getTypingIndicators().isPresent()) { + TextSecurePreferences.setTypingIndicatorsEnabled(context, configurationMessage.getTypingIndicators().get()); + } + + if (configurationMessage.getLinkPreviews().isPresent()) { + SignalStore.settings().setLinkPreviewsEnabled(configurationMessage.getReadReceipts().get()); + } + } + + private void handleSynchronizeBlockedListMessage(@NonNull BlockedListMessage blockMessage) { + DatabaseFactory.getRecipientDatabase(context).applyBlockedUpdate(blockMessage.getAddresses(), blockMessage.getGroupIds()); + } + + private void handleSynchronizeFetchMessage(@NonNull SignalServiceSyncMessage.FetchType fetchType) { + log("Received fetch request with type: " + fetchType); + + switch (fetchType) { + case LOCAL_PROFILE: + ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); + break; + case STORAGE_MANIFEST: + StorageSyncHelper.scheduleSyncForDataChange(); + break; + default: + Log.w(TAG, "Received a fetch message for an unknown type."); + } + } + + private void handleSynchronizeMessageRequestResponse(@NonNull MessageRequestResponseMessage response) + throws BadGroupIdException + { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + + Recipient recipient; + + if (response.getPerson().isPresent()) { + recipient = Recipient.externalPush(context, response.getPerson().get()); + } else if (response.getGroupId().isPresent()) { + GroupId groupId = GroupId.v1(response.getGroupId().get()); + recipient = Recipient.externalPossiblyMigratedGroup(context, groupId); + } else { + warn("Message request response was missing a thread recipient! Skipping."); + return; + } + + long threadId = threadDatabase.getThreadIdFor(recipient); + + switch (response.getType()) { + case ACCEPT: + recipientDatabase.setProfileSharing(recipient.getId(), true); + recipientDatabase.setBlocked(recipient.getId(), false); + break; + case DELETE: + recipientDatabase.setProfileSharing(recipient.getId(), false); + if (threadId > 0) threadDatabase.deleteConversation(threadId); + break; + case BLOCK: + recipientDatabase.setBlocked(recipient.getId(), true); + recipientDatabase.setProfileSharing(recipient.getId(), false); + break; + case BLOCK_AND_DELETE: + recipientDatabase.setBlocked(recipient.getId(), true); + recipientDatabase.setProfileSharing(recipient.getId(), false); + if (threadId > 0) threadDatabase.deleteConversation(threadId); + break; + default: + warn("Got an unknown response type! Skipping"); + break; + } + } + + private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content, + @NonNull SentTranscriptMessage message) + throws StorageFailedException, BadGroupIdException, IOException, GroupChangeBusyException + { + log(String.valueOf(content.getTimestamp()), "Processing sent transcript for message with ID " + message.getTimestamp()); + + try { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + if (message.getMessage().isGroupV2Message()) { + Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(GroupId.v2(message.getMessage().getGroupContext().get().getGroupV2().get().getMasterKey())); + if (possibleGv1.isPresent()) { + GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1()); + } + } + + long threadId = -1; + + if (message.isRecipientUpdate()) { + handleGroupRecipientUpdate(message); + } else if (message.getMessage().isEndSession()) { + threadId = handleSynchronizeSentEndSessionMessage(message); + } else if (message.getMessage().isGroupV1Update()) { + Long gv1ThreadId = GroupV1MessageProcessor.process(context, content, message.getMessage(), true); + threadId = gv1ThreadId == null ? -1 : gv1ThreadId; + } else if (message.getMessage().isGroupV2Update()) { + handleSynchronizeSentGv2Update(content, message); + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(getSyncMessageDestination(message)); + } else if (FeatureFlags.groupCalling() && message.getMessage().getGroupCallUpdate().isPresent()) { + handleGroupCallUpdateMessage(content, message.getMessage(), GroupUtil.idFromGroupContext(message.getMessage().getGroupContext())); + } else if (message.getMessage().isEmptyGroupV2Message()) { + // Do nothing + } else if (message.getMessage().isExpirationUpdate()) { + threadId = handleSynchronizeSentExpirationUpdate(message); + } else if (message.getMessage().getReaction().isPresent()) { + handleReaction(content, message.getMessage()); + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(getSyncMessageDestination(message)); + } else if (message.getMessage().getRemoteDelete().isPresent()) { + handleRemoteDelete(content, message.getMessage()); + } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent() || message.getMessage().isViewOnce() || message.getMessage().getMentions().isPresent()) { + threadId = handleSynchronizeSentMediaMessage(message); + } else { + threadId = handleSynchronizeSentTextMessage(message); + } + + if (message.getMessage().getGroupContext().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.idFromGroupContext(message.getMessage().getGroupContext().get()))) { + handleUnknownGroupMessage(content, message.getMessage().getGroupContext().get()); + } + + if (message.getMessage().getProfileKey().isPresent()) { + Recipient recipient = getSyncMessageDestination(message); + + if (recipient != null && !recipient.isSystemContact() && !recipient.isProfileSharing()) { + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); + } + } + + if (threadId != -1) { + DatabaseFactory.getThreadDatabase(context).setRead(threadId, true); + ApplicationDependencies.getMessageNotifier().updateNotification(context); + } + + ApplicationDependencies.getMessageNotifier().setLastDesktopActivityTimestamp(message.getTimestamp()); + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } + } + + private void handleSynchronizeSentGv2Update(@NonNull SignalServiceContent content, + @NonNull SentTranscriptMessage message) + throws IOException, GroupChangeBusyException + { + SignalServiceGroupV2 signalServiceGroupV2 = message.getMessage().getGroupContext().get().getGroupV2().get(); + GroupId.V2 groupIdV2 = GroupId.v2(signalServiceGroupV2.getMasterKey()); + + if (!updateGv2GroupFromServerOrP2PChange(content, signalServiceGroupV2)) { + log(String.valueOf(content.getTimestamp()), "Ignoring GV2 message for group we are not currently in " + groupIdV2); + } + } + + private void handleSynchronizeRequestMessage(@NonNull RequestMessage message) + { + if (message.isContactsRequest()) { + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true)); + } + + if (message.isGroupsRequest()) { + ApplicationDependencies.getJobManager().add(new MultiDeviceGroupUpdateJob()); + } + + if (message.isBlockedListRequest()) { + ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + } + + if (message.isConfigurationRequest()) { + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(context), + TextSecurePreferences.isTypingIndicatorsEnabled(context), + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context), + SignalStore.settings().isLinkPreviewsEnabled())); + ApplicationDependencies.getJobManager().add(new MultiDeviceStickerPackSyncJob()); + } + + if (message.isKeysRequest()) { + ApplicationDependencies.getJobManager().add(new MultiDeviceKeysUpdateJob()); + } + } + + private void handleSynchronizeReadMessage(@NonNull List readMessages, long envelopeTimestamp) + { + for (ReadMessage readMessage : readMessages) { + List> expiringText = DatabaseFactory.getSmsDatabase(context).setTimestampRead(new SyncMessageId(Recipient.externalPush(context, readMessage.getSender()).getId(), readMessage.getTimestamp()), envelopeTimestamp); + List> expiringMedia = DatabaseFactory.getMmsDatabase(context).setTimestampRead(new SyncMessageId(Recipient.externalPush(context, readMessage.getSender()).getId(), readMessage.getTimestamp()), envelopeTimestamp); + + for (Pair expiringMessage : expiringText) { + ApplicationContext.getInstance(context) + .getExpiringMessageManager() + .scheduleDeletion(expiringMessage.first(), false, envelopeTimestamp, expiringMessage.second()); + } + + for (Pair expiringMessage : expiringMedia) { + ApplicationContext.getInstance(context) + .getExpiringMessageManager() + .scheduleDeletion(expiringMessage.first(), true, envelopeTimestamp, expiringMessage.second()); + } + } + + MessageNotifier messageNotifier = ApplicationDependencies.getMessageNotifier(); + messageNotifier.setLastDesktopActivityTimestamp(envelopeTimestamp); + messageNotifier.cancelDelayedNotifications(); + messageNotifier.updateNotification(context); + } + + private void handleSynchronizeViewOnceOpenMessage(@NonNull ViewOnceOpenMessage openMessage, long envelopeTimestamp) { + log(String.valueOf(envelopeTimestamp), "Handling a view-once open for message: " + openMessage.getTimestamp()); + + RecipientId author = Recipient.externalPush(context, openMessage.getSender()).getId(); + long timestamp = openMessage.getTimestamp(); + MessageRecord record = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(timestamp, author); + + if (record != null && record.isMms()) { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentFilesForViewOnceMessage(record.getId()); + } else { + warn(String.valueOf(envelopeTimestamp), "Got a view-once open message for a message we don't have!"); + } + + MessageNotifier messageNotifier = ApplicationDependencies.getMessageNotifier(); + messageNotifier.setLastDesktopActivityTimestamp(envelopeTimestamp); + messageNotifier.cancelDelayedNotifications(); + messageNotifier.updateNotification(context); + } + + private void handleMediaMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional smsMessageId, Optional groupId) + throws StorageFailedException, BadGroupIdException + { + notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice()); + + Optional insertResult; + + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + database.beginTransaction(); + IncomingMediaMessage mediaMessage = null; + try { + Optional quote = getValidatedQuote(message.getQuote()); + Optional> sharedContacts = getContacts(message.getSharedContacts()); + Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); + Optional> mentions = getMentions(message.getMentions()); + Optional sticker = getStickerAttachment(message.getSticker()); + mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()), + message.getTimestamp(), + content.getServerReceivedTimestamp(), + -1, + message.getExpiresInSeconds() * 1000L, + false, + message.isViewOnce(), + content.isNeedsReceipt(), + message.getBody(), + message.getGroupContext(), + message.getAttachments(), + quote, + sharedContacts, + linkPreviews, + mentions, + sticker); + + insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + + if (insertResult.isPresent()) { + + if (smsMessageId.isPresent()) { + DatabaseFactory.getSmsDatabase(context).deleteMessage(smsMessageId.get()); + } + + database.setTransactionSuccessful(); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } finally { + database.endTransaction(); + } + + if (insertResult.isPresent()) { + List allAttachments = DatabaseFactory.getAttachmentDatabase(context).getAttachmentsForMessage(insertResult.get().getMessageId()); + List stickerAttachments = Stream.of(allAttachments).filter(Attachment::isSticker).toList(); + List attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); + + forceStickerDownloadIfNecessary(insertResult.get().getMessageId(), stickerAttachments); + + //Build recipient for sending message + List recipientList = null; + if(groupId.isPresent()) { + recipientList = Recipient.externalGroupExact(context, groupId.get()).getParticipants(); + } + Recipient recipient = Recipient.resolved(RecipientId.fromHighTrust(content.getSender())); + //archiveMediaInboxMessage(recipient); + + for (DatabaseAttachment attachment : attachments) { + archiveInboxMediaMessage(recipient, recipientList, mediaMessage, insertResult.get().getMessageId(), attachment.getAttachmentId()); + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(insertResult.get().getMessageId(), attachment.getAttachmentId(), false)); + } + com.tm.logger.Log.d("MNMNC", "Logger"); + + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(insertResult.get().getThreadId())); + + if (message.isViewOnce()) { + ApplicationContext.getInstance(context).getViewOnceMessageManager().scheduleIfNecessary(); + } + } + } + + private void archiveInboxMediaMessage(Recipient recipient, List recipientList, IncomingMediaMessage mediaMessage, long messageId, AttachmentId attachmentId) { + + if(mediaMessage.getAttachments().size() > 0) { + for (int i = mediaMessage.getAttachments().size() - 1; i >= 0; i--) { + + File tempFileForArchiving = FileUtils.createPlaceHolderTempFile(context, ArchiveUtil.Companion.generateAttachmentName(messageId, attachmentId.getUniqueId()) + "." + FileUtils.getExtensionFromMimeType(context, mediaMessage.getAttachments().get(i).getContentType())); + + ArchiveSender.Companion.archiveMessageInboxMMS(context, ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX, recipient, recipientList, mediaMessage, messageId, tempFileForArchiving); + + } + } + } + + + private long handleSynchronizeSentExpirationUpdate(@NonNull SentTranscriptMessage message) + throws MmsException, BadGroupIdException + { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Recipient recipient = getSyncMessageDestination(message); + + OutgoingExpirationUpdateMessage expirationUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, + message.getTimestamp(), + message.getMessage().getExpiresInSeconds() * 1000L); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + long messageId = database.insertMessageOutbox(expirationUpdateMessage, threadId, false, null); + + database.markAsSent(messageId, true); + + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipient.getId(), message.getMessage().getExpiresInSeconds()); + + return threadId; + } + + private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message) + throws MmsException, BadGroupIdException + { + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + Recipient recipients = getSyncMessageDestination(message); + Optional quote = getValidatedQuote(message.getMessage().getQuote()); + Optional sticker = getStickerAttachment(message.getMessage().getSticker()); + Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); + Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or("")); + Optional> mentions = getMentions(message.getMessage().getMentions()); + boolean viewOnce = message.getMessage().isViewOnce(); + List syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) + : PointerAttachment.forPointers(message.getMessage().getAttachments()); + + if (sticker.isPresent()) { + syncAttachments.add(sticker.get()); + } + + OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(), + syncAttachments, + message.getTimestamp(), -1, + message.getMessage().getExpiresInSeconds() * 1000, + viewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), + sharedContacts.or(Collections.emptyList()), + previews.or(Collections.emptyList()), + mentions.or(Collections.emptyList()), + Collections.emptyList(), Collections.emptyList()); + + mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); + + if (recipients.getExpireMessages() != message.getMessage().getExpiresInSeconds()) { + handleSynchronizeSentExpirationUpdate(message); + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipients); + + database.beginTransaction(); + + try { + long messageId = database.insertMessageOutbox(mediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); + + if (recipients.isGroup()) { + updateGroupReceiptStatus(message, messageId, recipients.requireGroupId()); + } else { + database.markUnidentified(messageId, isUnidentified(message, recipients)); + } + + database.markAsSent(messageId, true); + + List allAttachments = DatabaseFactory.getAttachmentDatabase(context).getAttachmentsForMessage(messageId); + List stickerAttachments = Stream.of(allAttachments).filter(Attachment::isSticker).toList(); + List attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); + + forceStickerDownloadIfNecessary(messageId, stickerAttachments); + + for (DatabaseAttachment attachment : attachments) { + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(messageId, attachment.getAttachmentId(), false)); + } + + if (message.getMessage().getExpiresInSeconds() > 0) { + database.markExpireStarted(messageId, message.getExpirationStartTimestamp()); + ApplicationContext.getInstance(context) + .getExpiringMessageManager() + .scheduleDeletion(messageId, true, + message.getExpirationStartTimestamp(), + message.getMessage().getExpiresInSeconds() * 1000L); + } + + if (recipients.isSelf()) { + SyncMessageId id = new SyncMessageId(recipients.getId(), message.getTimestamp()); + DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCount(id, System.currentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementReadReceiptCount(id, System.currentTimeMillis()); + } + + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + return threadId; + } + + private void handleGroupRecipientUpdate(@NonNull SentTranscriptMessage message) + throws BadGroupIdException + { + Recipient recipient = getSyncMessageDestination(message); + + if (!recipient.isGroup()) { + warn("Got recipient update for a non-group message! Skipping."); + return; + } + + MmsSmsDatabase database = DatabaseFactory.getMmsSmsDatabase(context); + MessageRecord record = database.getMessageFor(message.getTimestamp(), Recipient.self().getId()); + + if (record == null) { + warn("Got recipient update for non-existing message! Skipping."); + return; + } + + if (!record.isMms()) { + warn("Recipient update matched a non-MMS message! Skipping."); + return; + } + + updateGroupReceiptStatus(message, record.getId(), recipient.requireGroupId()); + } + + private void updateGroupReceiptStatus(@NonNull SentTranscriptMessage message, long messageId, @NonNull GroupId groupString) { + GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); + List messageRecipients = Stream.of(message.getRecipients()).map(address -> Recipient.externalPush(context, address)).toList(); + List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupString, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + Map localReceipts = Stream.of(receiptDatabase.getGroupReceiptInfo(messageId)) + .collect(Collectors.toMap(GroupReceiptInfo::getRecipientId, GroupReceiptInfo::getStatus)); + + for (Recipient messageRecipient : messageRecipients) { + //noinspection ConstantConditions + if (localReceipts.containsKey(messageRecipient.getId()) && localReceipts.get(messageRecipient.getId()) < GroupReceiptDatabase.STATUS_UNDELIVERED) { + receiptDatabase.update(messageRecipient.getId(), messageId, GroupReceiptDatabase.STATUS_UNDELIVERED, message.getTimestamp()); + } else if (!localReceipts.containsKey(messageRecipient.getId())) { + receiptDatabase.insert(Collections.singletonList(messageRecipient.getId()), messageId, GroupReceiptDatabase.STATUS_UNDELIVERED, message.getTimestamp()); + } + } + + List> unidentifiedStatus = Stream.of(members) + .map(m -> new org.whispersystems.libsignal.util.Pair<>(m.getId(), message.isUnidentified(m.requireServiceId()))) + .toList(); + receiptDatabase.setUnidentified(unidentifiedStatus, messageId); + } + + private void handleTextMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional smsMessageId, + @NonNull Optional groupId) + throws StorageFailedException, BadGroupIdException + { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + String body = message.getBody().isPresent() ? message.getBody().get() : ""; + Recipient recipient = getMessageDestination(content, message); + + if (message.getExpiresInSeconds() != recipient.getExpireMessages()) { + handleExpirationUpdate(content, message, Optional.absent(), groupId); + } + + Long threadId; + + if (smsMessageId.isPresent() && !message.getGroupContext().isPresent()) { + threadId = database.updateBundleMessageBody(smsMessageId.get(), body).second(); + } else { + notifyTypingStoppedFromIncomingMessage(recipient, content.getSender(), content.getSenderDevice()); + + IncomingTextMessage textMessage = new IncomingTextMessage(RecipientId.fromHighTrust(content.getSender()), + content.getSenderDevice(), + message.getTimestamp(), + content.getServerReceivedTimestamp(), + body, + groupId, + message.getExpiresInSeconds() * 1000L, + content.isNeedsReceipt()); + + textMessage = new IncomingEncryptedMessage(textMessage, body); + Optional insertResult = database.insertMessageInbox(textMessage); + + if (insertResult.isPresent()) threadId = insertResult.get().getThreadId(); + else threadId = null; + + if (smsMessageId.isPresent()) database.deleteMessage(smsMessageId.get()); + } + + if (threadId != null) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId); + } + } + + private long handleSynchronizeSentTextMessage(@NonNull SentTranscriptMessage message) + throws MmsException, BadGroupIdException + { + Recipient recipient = getSyncMessageDestination(message); + String body = message.getMessage().getBody().or(""); + long expiresInMillis = message.getMessage().getExpiresInSeconds() * 1000L; + + if (recipient.getExpireMessages() != message.getMessage().getExpiresInSeconds()) { + handleSynchronizeSentExpirationUpdate(message); + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + boolean isGroup = recipient.isGroup(); + + MessageDatabase database; + long messageId; + + if (isGroup) { + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + new SlideDeck(), + body, + message.getTimestamp(), + -1, + expiresInMillis, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage); + + messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); + database = DatabaseFactory.getMmsDatabase(context); + + updateGroupReceiptStatus(message, messageId, recipient.requireGroupId()); + } else { + OutgoingTextMessage outgoingTextMessage = new OutgoingEncryptedMessage(recipient, body, expiresInMillis); + + messageId = DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoingTextMessage, false, message.getTimestamp(), null); + database = DatabaseFactory.getSmsDatabase(context); + database.markUnidentified(messageId, isUnidentified(message, recipient)); + } + + database.markAsSent(messageId, true); + + if (expiresInMillis > 0) { + database.markExpireStarted(messageId, message.getExpirationStartTimestamp()); + ApplicationContext.getInstance(context) + .getExpiringMessageManager() + .scheduleDeletion(messageId, isGroup, message.getExpirationStartTimestamp(), expiresInMillis); + } + + if (recipient.isSelf()) { + SyncMessageId id = new SyncMessageId(recipient.getId(), message.getTimestamp()); + DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCount(id, System.currentTimeMillis()); + DatabaseFactory.getMmsSmsDatabase(context).incrementReadReceiptCount(id, System.currentTimeMillis()); + } + + return threadId; + } + + private void handleInvalidVersionMessage(@NonNull String sender, int senderDevice, long timestamp, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender, senderDevice, timestamp); + + if (insertResult.isPresent()) { + smsDatabase.markAsInvalidVersionKeyExchange(insertResult.get().getMessageId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsInvalidVersionKeyExchange(smsMessageId.get()); + } + } + + private void handleCorruptMessage(@NonNull String sender, int senderDevice, long timestamp, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender, senderDevice, timestamp); + + if (insertResult.isPresent()) { + smsDatabase.markAsDecryptFailed(insertResult.get().getMessageId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsDecryptFailed(smsMessageId.get()); + } + } + + private void handleUnsupportedDataMessage(@NonNull String sender, + int senderDevice, + @NonNull Optional groupId, + long timestamp, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender, senderDevice, timestamp, groupId); + + if (insertResult.isPresent()) { + smsDatabase.markAsUnsupportedProtocolVersion(insertResult.get().getMessageId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsNoSession(smsMessageId.get()); + } + } + + private void handleInvalidMessage(@NonNull SignalServiceAddress sender, + int senderDevice, + @NonNull Optional groupId, + long timestamp, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender.getIdentifier(), senderDevice, timestamp, groupId); + + if (insertResult.isPresent()) { + smsDatabase.markAsInvalidMessage(insertResult.get().getMessageId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsNoSession(smsMessageId.get()); + } + } + + private void handleLegacyMessage(@NonNull String sender, int senderDevice, long timestamp, + @NonNull Optional smsMessageId) + { + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + + if (!smsMessageId.isPresent()) { + Optional insertResult = insertPlaceholder(sender, senderDevice, timestamp); + + if (insertResult.isPresent()) { + smsDatabase.markAsLegacyVersion(insertResult.get().getMessageId()); + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } else { + smsDatabase.markAsLegacyVersion(smsMessageId.get()); + } + } + + private void handleProfileKey(@NonNull SignalServiceContent content, + @NonNull byte[] messageProfileKeyBytes) + { + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender()); + ProfileKey messageProfileKey = ProfileKeyUtil.profileKeyOrNull(messageProfileKeyBytes); + + if (messageProfileKey != null) { + if (database.setProfileKey(recipient.getId(), messageProfileKey)) { + ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(recipient.getId())); + } + } else { + warn(String.valueOf(content.getTimestamp()), "Ignored invalid profile key seen in message"); + } + } + + private void handleNeedsDeliveryReceipt(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message) + { + ApplicationDependencies.getJobManager().add(new SendDeliveryReceiptJob(RecipientId.fromHighTrust(content.getSender()), message.getTimestamp())); + } + + private void handleViewedReceipt(@NonNull SignalServiceContent content, + @NonNull SignalServiceReceiptMessage message) + { + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + log("Ignoring viewed receipts for IDs: " + Util.join(message.getTimestamps(), ", ")); + return; + } + + log("Processing viewed reciepts for IDs: " + Util.join(message.getTimestamps(), ",")); + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + List ids = Stream.of(message.getTimestamps()) + .map(t -> new SyncMessageId(sender.getId(), t)) + .toList(); + Collection unhandled = DatabaseFactory.getMmsSmsDatabase(context) + .incrementViewedReceiptCounts(ids, content.getTimestamp()); + + for (SyncMessageId id : unhandled) { + warn(String.valueOf(content.getTimestamp()), "[handleViewedReceipt] Could not find matching message! timestamp: " + id.getTimetamp() + " author: " + sender.getId()); + ApplicationDependencies.getEarlyMessageCache().store(sender.getId(), id.getTimetamp(), content); + } + } + + @SuppressLint("DefaultLocale") + private void handleDeliveryReceipt(@NonNull SignalServiceContent content, + @NonNull SignalServiceReceiptMessage message) + { + log(TAG, "Processing delivery receipts for IDs: " + Util.join(message.getTimestamps(), ", ")); + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + List ids = Stream.of(message.getTimestamps()) + .map(t -> new SyncMessageId(sender.getId(), t)) + .toList(); + + DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCounts(ids, System.currentTimeMillis()); + } + + @SuppressLint("DefaultLocale") + private void handleReadReceipt(@NonNull SignalServiceContent content, + @NonNull SignalServiceReceiptMessage message) + { + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + log("Ignoring read receipts for IDs: " + Util.join(message.getTimestamps(), ", ")); + return; + } + + log("Processing read receipts for IDs: " + Util.join(message.getTimestamps(), ", ")); + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + List ids = Stream.of(message.getTimestamps()) + .map(t -> new SyncMessageId(sender.getId(), t)) + .toList(); + + Collection unhandled = DatabaseFactory.getMmsSmsDatabase(context).incrementReadReceiptCounts(ids, content.getTimestamp()); + + for (SyncMessageId id : unhandled) { + warn(String.valueOf(content.getTimestamp()), "[handleReadReceipt] Could not find matching message! timestamp: " + id.getTimetamp() + " author: " + sender.getId()); + ApplicationDependencies.getEarlyMessageCache().store(sender.getId(), id.getTimetamp(), content); + } + } + + private void handleTypingMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceTypingMessage typingMessage) + throws BadGroupIdException + { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(context)) { + return; + } + + Recipient author = Recipient.externalHighTrustPush(context, content.getSender()); + + long threadId; + + if (typingMessage.getGroupId().isPresent()) { + GroupId.Push groupId = GroupId.push(typingMessage.getGroupId().get()); + + if (!DatabaseFactory.getGroupDatabase(context).isCurrentMember(groupId, author.getId())) { + warn(String.valueOf(content.getTimestamp()), "Seen typing indicator for non-member"); + return; + } + + Recipient groupRecipient = Recipient.externalPossiblyMigratedGroup(context, groupId); + + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + } else { + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(author); + } + + if (threadId <= 0) { + warn(String.valueOf(content.getTimestamp()), "Couldn't find a matching thread for a typing message."); + return; + } + + if (typingMessage.isTypingStarted()) { + Log.d(TAG, "Typing started on thread " + threadId); + ApplicationDependencies.getTypingStatusRepository().onTypingStarted(context,threadId, author, content.getSenderDevice()); + } else { + Log.d(TAG, "Typing stopped on thread " + threadId); + ApplicationDependencies.getTypingStatusRepository().onTypingStopped(context, threadId, author, content.getSenderDevice(), false); + } + } + + private static boolean isInvalidMessage(@NonNull SignalServiceDataMessage message) { + if (message.isViewOnce()) { + List attachments = message.getAttachments().or(Collections.emptyList()); + + return attachments.size() != 1 || + !isViewOnceSupportedContentType(attachments.get(0).getContentType().toLowerCase()); + } + + return false; + } + + private static boolean isViewOnceSupportedContentType(@NonNull String contentType) { + return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType); + } + + private Optional getValidatedQuote(Optional quote) { + if (!quote.isPresent()) return Optional.absent(); + + if (quote.get().getId() <= 0) { + warn("Received quote without an ID! Ignoring..."); + return Optional.absent(); + } + + if (quote.get().getAuthor() == null) { + warn("Received quote without an author! Ignoring..."); + return Optional.absent(); + } + + RecipientId author = Recipient.externalPush(context, quote.get().getAuthor()).getId(); + MessageRecord message = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quote.get().getId(), author); + + if (message != null && !message.isRemoteDelete()) { + log("Found matching message record..."); + + List attachments = new LinkedList<>(); + List mentions = new LinkedList<>(); + + if (message.isMms()) { + MmsMessageRecord mmsMessage = (MmsMessageRecord) message; + + mentions.addAll(DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(mmsMessage.getId())); + + if (mmsMessage.isViewOnce()) { + attachments.add(new TombstoneAttachment(MediaUtil.VIEW_ONCE, true)); + } else { + attachments = mmsMessage.getSlideDeck().asAttachments(); + + if (attachments.isEmpty()) { + attachments.addAll(Stream.of(mmsMessage.getLinkPreviews()) + .filter(lp -> lp.getThumbnail().isPresent()) + .map(lp -> lp.getThumbnail().get()) + .toList()); + } + } + } + + return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments, mentions)); + } else if (message != null) { + warn("Found the target for the quote, but it's flagged as remotely deleted."); + } + + warn("Didn't find matching message record..."); + + return Optional.of(new QuoteModel(quote.get().getId(), + author, + quote.get().getText(), + true, + PointerAttachment.forPointers(quote.get().getAttachments()), + getMentions(quote.get().getMentions()))); + } + + private Optional getStickerAttachment(Optional sticker) { + if (!sticker.isPresent()) { + return Optional.absent(); + } + + if (sticker.get().getPackId() == null || sticker.get().getPackKey() == null || sticker.get().getAttachment() == null) { + warn("Malformed sticker!"); + return Optional.absent(); + } + + String packId = Hex.toStringCondensed(sticker.get().getPackId()); + String packKey = Hex.toStringCondensed(sticker.get().getPackKey()); + int stickerId = sticker.get().getStickerId(); + String emoji = sticker.get().getEmoji(); + StickerLocator stickerLocator = new StickerLocator(packId, packKey, stickerId, emoji); + StickerDatabase stickerDatabase = DatabaseFactory.getStickerDatabase(context); + StickerRecord stickerRecord = stickerDatabase.getSticker(stickerLocator.getPackId(), stickerLocator.getStickerId(), false); + + if (stickerRecord != null) { + return Optional.of(new UriAttachment(stickerRecord.getUri(), + stickerRecord.getContentType(), + AttachmentDatabase.TRANSFER_PROGRESS_DONE, + stickerRecord.getSize(), + StickerSlide.WIDTH, + StickerSlide.HEIGHT, + null, + String.valueOf(new SecureRandom().nextLong()), + false, + false, + false, + null, + stickerLocator, + null, + null, + null)); + } else { + return Optional.of(PointerAttachment.forPointer(Optional.of(sticker.get().getAttachment()), stickerLocator).get()); + } + } + + private static Optional> getContacts(Optional> sharedContacts) { + if (!sharedContacts.isPresent()) return Optional.absent(); + + List contacts = new ArrayList<>(sharedContacts.get().size()); + + for (SharedContact sharedContact : sharedContacts.get()) { + contacts.add(ContactModelMapper.remoteToLocal(sharedContact)); + } + + return Optional.of(contacts); + } + + private Optional> getLinkPreviews(Optional> previews, @NonNull String message) { + if (!previews.isPresent() || previews.get().isEmpty()) return Optional.absent(); + + List linkPreviews = new ArrayList<>(previews.get().size()); + LinkPreviewUtil.Links urlsInMessage = LinkPreviewUtil.findValidPreviewUrls(message); + + for (SignalServiceDataMessage.Preview preview : previews.get()) { + Optional thumbnail = PointerAttachment.forPointer(preview.getImage()); + Optional url = Optional.fromNullable(preview.getUrl()); + Optional title = Optional.fromNullable(preview.getTitle()); + Optional description = Optional.fromNullable(preview.getDescription()); + boolean hasTitle = !TextUtils.isEmpty(title.or("")); + boolean presentInBody = url.isPresent() && urlsInMessage.containsUrl(url.get()); + boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get()); + + if (hasTitle && presentInBody && validDomain) { + LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), description.or(""), preview.getDate(), thumbnail); + linkPreviews.add(linkPreview); + } else { + warn(String.format("Discarding an invalid link preview. hasTitle: %b presentInBody: %b validDomain: %b", hasTitle, presentInBody, validDomain)); + } + } + + return Optional.of(linkPreviews); + } + + private Optional> getMentions(Optional> signalServiceMentions) { + if (!signalServiceMentions.isPresent()) return Optional.absent(); + + return Optional.of(getMentions(signalServiceMentions.get())); + } + + private @NonNull List getMentions(@Nullable List signalServiceMentions) { + if (signalServiceMentions == null || signalServiceMentions.isEmpty()) { + return Collections.emptyList(); + } + + List mentions = new ArrayList<>(signalServiceMentions.size()); + + for (SignalServiceDataMessage.Mention mention : signalServiceMentions) { + mentions.add(new Mention(Recipient.externalPush(context, mention.getUuid(), null, false).getId(), mention.getStart(), mention.getLength())); + } + + return mentions; + } + + private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) { + return insertPlaceholder(sender, senderDevice, timestamp, Optional.absent()); + } + + private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp, Optional groupId) { + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + IncomingTextMessage textMessage = new IncomingTextMessage(Recipient.external(context, sender).getId(), + senderDevice, timestamp, -1, "", + groupId, 0, false); + + textMessage = new IncomingEncryptedMessage(textMessage, ""); + return database.insertMessageInbox(textMessage); + } + + private Recipient getSyncMessageDestination(@NonNull SentTranscriptMessage message) + throws BadGroupIdException + { + return getGroupRecipient(message.getMessage().getGroupContext()).or(() -> Recipient.externalPush(context, message.getDestination().get())); + } + + private Recipient getMessageDestination(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message) + throws BadGroupIdException + { + return getGroupRecipient(message.getGroupContext()).or(() -> Recipient.externalHighTrustPush(context, content.getSender())); + } + + private Recipient getMessageDestination(@NonNull SignalServiceContent content, + @NonNull Optional groupContext) + throws BadGroupIdException + { + return getGroupRecipient(groupContext).or(() -> Recipient.externalPush(context, content.getSender())); + } + + private Optional getGroupRecipient(Optional message) + throws BadGroupIdException + { + if (message.isPresent()) { + return Optional.of(Recipient.externalPossiblyMigratedGroup(context, GroupUtil.idFromGroupContext(message.get()))); + } + return Optional.absent(); + } + + private void notifyTypingStoppedFromIncomingMessage(@NonNull Recipient conversationRecipient, @NonNull SignalServiceAddress sender, int device) { + Recipient author = Recipient.externalPush(context, sender); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(conversationRecipient); + + if (threadId > 0) { + Log.d(TAG, "Typing stopped on thread " + threadId + " due to an incoming message."); + ApplicationDependencies.getTypingStatusRepository().onTypingStopped(context, threadId, author, device, true); + } + } + + private boolean shouldIgnore(@Nullable SignalServiceContent content) + throws BadGroupIdException + { + if (content == null) { + warn("Got a message with null content."); + return true; + } + + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); + + if (content.getDataMessage().isPresent()) { + SignalServiceDataMessage message = content.getDataMessage().get(); + Recipient conversation = getMessageDestination(content, message); + + if (conversation.isGroup() && conversation.isBlocked()) { + return true; + } else if (conversation.isGroup()) { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); + + if (groupId.isPresent() && + groupId.get().isV1() && + message.isGroupV1Update() && + groupDatabase.groupExists(groupId.get().requireV1().deriveV2MigrationGroupId())) + { + warn(String.valueOf(content.getTimestamp()), "Ignoring V1 update for a group we've already migrated to V2."); + return true; + } + + if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { + return sender.isBlocked(); + } + + boolean isTextMessage = message.getBody().isPresent(); + boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent(); + boolean isExpireMessage = message.isExpirationUpdate(); + boolean isGv2Update = message.isGroupV2Update(); + boolean isContentMessage = !message.isGroupV1Update() && !isGv2Update && !isExpireMessage && (isTextMessage || isMediaMessage); + boolean isGroupActive = groupId.isPresent() && groupDatabase.isActive(groupId.get()); + boolean isLeaveMessage = message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1Type() == SignalServiceGroup.Type.QUIT; + + return (isContentMessage && !isGroupActive) || (sender.isBlocked() && !isLeaveMessage && !isGv2Update); + } else { + return sender.isBlocked(); + } + } else if (content.getCallMessage().isPresent()) { + return sender.isBlocked(); + } else if (content.getTypingMessage().isPresent()) { + if (sender.isBlocked()) { + return true; + } + + if (content.getTypingMessage().get().getGroupId().isPresent()) { + GroupId groupId = GroupId.push(content.getTypingMessage().get().getGroupId().get()); + Recipient groupRecipient = Recipient.externalPossiblyMigratedGroup(context, groupId); + return groupRecipient.isBlocked() || !groupRecipient.isActiveGroup(); + } + } + + return false; + } + + private void resetRecipientToPush(@NonNull Recipient recipient) { + if (recipient.isForceSmsSelection()) { + DatabaseFactory.getRecipientDatabase(context).setForceSmsSelection(recipient.getId(), false); + } + } + + private void forceStickerDownloadIfNecessary(long messageId, List stickerAttachments) { + if (stickerAttachments.isEmpty()) return; + + DatabaseAttachment stickerAttachment = stickerAttachments.get(0); + + if (stickerAttachment.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + AttachmentDownloadJob downloadJob = new AttachmentDownloadJob(messageId, stickerAttachment.getAttachmentId(), true); + + try { + downloadJob.setContext(context); + downloadJob.doWork(); + } catch (Exception e) { + warn("Failed to download sticker inline. Scheduling."); + ApplicationDependencies.getJobManager().add(downloadJob); + } + } + } + + private static boolean isUnidentified(@NonNull SentTranscriptMessage message, @NonNull Recipient recipient) { + boolean unidentified = false; + + if (recipient.hasE164()) { + unidentified |= message.isUnidentified(recipient.requireE164()); + } + if (recipient.hasUuid()) { + unidentified |= message.isUnidentified(recipient.requireUuid()); + } + + return unidentified; + } + + protected void log(@NonNull String message) { + Log.i(TAG, message); + } + + protected void log(@NonNull String extra, @NonNull String message) { + String extraLog = Util.isEmpty(extra) ? "" : "[" + extra + "] "; + Log.i(TAG, extraLog + message); + } + + protected void warn(@NonNull String message) { + warn("", message, null); + } + + protected void warn(@NonNull String extra, @NonNull String message) { + warn(extra, message, null); + } + + protected void warn(@NonNull String message, @Nullable Throwable t) { + warn("", message, t); + } + + protected void warn(@NonNull String extra, @NonNull String message, @Nullable Throwable t) { + String extraLog = Util.isEmpty(extra) ? "" : "[" + extra + "] "; + Log.w(TAG, extraLog + message, t); + } + + @SuppressWarnings("WeakerAccess") + private static class StorageFailedException extends Exception { + private final String sender; + private final int senderDevice; + + private StorageFailedException(Exception e, String sender, int senderDevice) { + super(e); + this.sender = sender; + this.senderDevice = senderDevice; + } + + public String getSender() { + return sender; + } + + public int getSenderDevice() { + return senderDevice; + } + } + + public enum MessageState { + DECRYPTED_OK, + INVALID_VERSION, + CORRUPT_MESSAGE, // Not used, but can't remove due to serialization + NO_SESSION, // Not used, but can't remove due to serialization + LEGACY_MESSAGE, + DUPLICATE_MESSAGE, + UNSUPPORTED_DATA_MESSAGE, + NOOP + } + + public static final class ExceptionMetadata { + @NonNull private final String sender; + private final int senderDevice; + @Nullable private final GroupId groupId; + + public ExceptionMetadata(@NonNull String sender, int senderDevice, @Nullable GroupId groupId) { + this.sender = sender; + this.senderDevice = senderDevice; + this.groupId = groupId; + } + + public ExceptionMetadata(@NonNull String sender, int senderDevice) { + this(sender, senderDevice, null); + } + + @NonNull + public String getSender() { + return sender; + } + + public int getSenderDevice() { + return senderDevice; + } + + @Nullable + public GroupId getGroupId() { + return groupId; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptionUtil.java new file mode 100644 index 00000000..a220608c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageDecryptionUtil.java @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.messages; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.libsignal.metadata.InvalidMetadataMessageException; +import org.signal.libsignal.metadata.InvalidMetadataVersionException; +import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; +import org.signal.libsignal.metadata.ProtocolException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyException; +import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; +import org.signal.libsignal.metadata.ProtocolInvalidMessageException; +import org.signal.libsignal.metadata.ProtocolInvalidVersionException; +import org.signal.libsignal.metadata.ProtocolLegacyMessageException; +import org.signal.libsignal.metadata.ProtocolNoSessionException; +import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; +import org.signal.libsignal.metadata.SelfSendException; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.AutomaticSessionResetJob; +import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata; +import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.state.SignalProtocolStore; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Handles taking an encrypted {@link SignalServiceEnvelope} and turning it into a plaintext model. + */ +public final class MessageDecryptionUtil { + + private static final String TAG = Log.tag(MessageDecryptionUtil.class); + + private MessageDecryptionUtil() {} + + /** + * Takes a {@link SignalServiceEnvelope} and returns a {@link DecryptionResult}, which has either + * a plaintext {@link SignalServiceContent} or information about an error that happened. + * + * Excluding the data updated in our protocol stores that results from decrypting a message, this + * method is side-effect free, preferring to return the decryption results to be handled by the + * caller. + */ + public static @NonNull DecryptionResult decrypt(@NonNull Context context, @NonNull SignalServiceEnvelope envelope) { + SignalProtocolStore axolotlStore = new SignalProtocolStoreImpl(context); + SignalServiceAddress localAddress = new SignalServiceAddress(Optional.of(TextSecurePreferences.getLocalUuid(context)), Optional.of(TextSecurePreferences.getLocalNumber(context))); + SignalServiceCipher cipher = new SignalServiceCipher(localAddress, axolotlStore, DatabaseSessionLock.INSTANCE, UnidentifiedAccessUtil.getCertificateValidator()); + List jobs = new LinkedList<>(); + + if (envelope.isPreKeySignalMessage()) { + jobs.add(new RefreshPreKeysJob()); + } + + try { + try { + return DecryptionResult.forSuccess(cipher.decrypt(envelope), jobs); + } catch (ProtocolInvalidVersionException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + return DecryptionResult.forError(MessageState.INVALID_VERSION, toExceptionMetadata(e), jobs); + + } catch (ProtocolInvalidMessageException | ProtocolInvalidKeyIdException | ProtocolInvalidKeyException | ProtocolUntrustedIdentityException | ProtocolNoSessionException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + jobs.add(new AutomaticSessionResetJob(Recipient.external(context, e.getSender()).getId(), e.getSenderDevice(), envelope.getTimestamp())); + return DecryptionResult.forNoop(jobs); + } catch (ProtocolLegacyMessageException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + return DecryptionResult.forError(MessageState.LEGACY_MESSAGE, toExceptionMetadata(e), jobs); + } catch (ProtocolDuplicateMessageException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + return DecryptionResult.forError(MessageState.DUPLICATE_MESSAGE, toExceptionMetadata(e), jobs); + } catch (InvalidMetadataVersionException | InvalidMetadataMessageException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + return DecryptionResult.forNoop(jobs); + } catch (SelfSendException e) { + Log.i(TAG, "Dropping UD message from self."); + return DecryptionResult.forNoop(jobs); + } catch (UnsupportedDataMessageException e) { + Log.w(TAG, String.valueOf(envelope.getTimestamp()), e); + return DecryptionResult.forError(MessageState.UNSUPPORTED_DATA_MESSAGE, toExceptionMetadata(e), jobs); + } + } catch (NoSenderException e) { + Log.w(TAG, "Invalid message, but no sender info!"); + return DecryptionResult.forNoop(jobs); + } + } + + private static ExceptionMetadata toExceptionMetadata(@NonNull UnsupportedDataMessageException e) + throws NoSenderException + { + String sender = e.getSender(); + + if (sender == null) throw new NoSenderException(); + + GroupId groupId = null; + + if (e.getGroup().isPresent()) { + try { + groupId = GroupUtil.idFromGroupContext(e.getGroup().get()); + } catch (BadGroupIdException ex) { + Log.w(TAG, "Bad group id found in unsupported data message", ex); + } + } + + return new ExceptionMetadata(sender, e.getSenderDevice(), groupId); + } + + private static ExceptionMetadata toExceptionMetadata(@NonNull ProtocolException e) throws NoSenderException { + String sender = e.getSender(); + + if (sender == null) throw new NoSenderException(); + + return new ExceptionMetadata(sender, e.getSenderDevice()); + } + + private static class NoSenderException extends Exception {} + + public static class DecryptionResult { + private final @NonNull MessageState state; + private final @Nullable SignalServiceContent content; + private final @Nullable ExceptionMetadata exception; + private final @NonNull List jobs; + + static @NonNull DecryptionResult forSuccess(@NonNull SignalServiceContent content, @NonNull List jobs) { + return new DecryptionResult(MessageState.DECRYPTED_OK, content, null, jobs); + } + + static @NonNull DecryptionResult forError(@NonNull MessageState messageState, + @NonNull ExceptionMetadata exception, + @NonNull List jobs) + { + return new DecryptionResult(messageState, null, exception, jobs); + } + + static @NonNull DecryptionResult forNoop(@NonNull List jobs) { + return new DecryptionResult(MessageState.NOOP, null, null, jobs); + } + + private DecryptionResult(@NonNull MessageState state, + @Nullable SignalServiceContent content, + @Nullable ExceptionMetadata exception, + @NonNull List jobs) + { + this.state = state; + this.content = content; + this.exception = exception; + this.jobs = jobs; + } + + public @NonNull MessageState getState() { + return state; + } + + public @Nullable SignalServiceContent getContent() { + return content; + } + + public @Nullable ExceptionMetadata getException() { + return exception; + } + + public @NonNull List getJobs() { + return jobs; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageRetrievalStrategy.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageRetrievalStrategy.java new file mode 100644 index 00000000..d78a0feb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageRetrievalStrategy.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.messages; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JobTracker; +import org.thoughtcrime.securesms.jobs.MarkerJob; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.HashSet; +import java.util.Set; + +/** + * Implementations are responsible for fetching and processing a batch of messages. + */ +public abstract class MessageRetrievalStrategy { + + private volatile boolean canceled; + + /** + * Fetches and processes any pending messages. This method should block until the messages are + * actually stored and processed -- not just retrieved. + * + * @param timeout Hint for how long this will run. The strategy will also be canceled after the + * timeout ends, but having the timeout available may be useful for setting things + * like socket timeouts. + * + * @return True if everything was successful up until cancelation, false otherwise. + */ + @WorkerThread + abstract boolean execute(long timeout); + + /** + * Marks the strategy as canceled. It is the responsibility of the implementation of + * {@link #execute(long)} to check {@link #isCanceled()} to know if execution should stop. + */ + void cancel() { + this.canceled = true; + } + + protected boolean isCanceled() { + return canceled; + } + + protected static void blockUntilQueueDrained(@NonNull String tag, @NonNull String queue, long timeoutMs) { + long startTime = System.currentTimeMillis(); + final JobManager jobManager = ApplicationDependencies.getJobManager(); + final MarkerJob markerJob = new MarkerJob(queue); + + Optional jobState = jobManager.runSynchronously(markerJob, timeoutMs); + + if (!jobState.isPresent()) { + Log.w(tag, "Timed out waiting for " + queue + " job(s) to finish!"); + } + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + Log.d(tag, "Waited " + duration + " ms for the " + queue + " job(s) to finish."); + } + + protected static String timeSuffix(long startTime) { + return " (" + (System.currentTimeMillis() - startTime) + " ms elapsed)"; + } + + protected static class QueueFindingJobListener implements JobTracker.JobListener { + private final Set queues = new HashSet<>(); + + @Override + @AnyThread + public void onStateChanged(@NonNull Job job, @NonNull JobTracker.JobState jobState) { + synchronized (queues) { + queues.add(job.getParameters().getQueue()); + } + } + + @NonNull Set getQueues() { + synchronized (queues) { + return new HashSet<>(queues); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/RestStrategy.java b/app/src/main/java/org/thoughtcrime/securesms/messages/RestStrategy.java new file mode 100644 index 00000000..f5541f5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/RestStrategy.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.messages; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JobTracker; +import org.thoughtcrime.securesms.jobs.MarkerJob; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.jobs.PushProcessMessageJob; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Retrieves messages over the REST endpoint. + */ +public class RestStrategy extends MessageRetrievalStrategy { + + private static final String TAG = Log.tag(RestStrategy.class); + + @WorkerThread + @Override + public boolean execute(long timeout) { + long startTime = System.currentTimeMillis(); + JobManager jobManager = ApplicationDependencies.getJobManager(); + QueueFindingJobListener queueListener = new QueueFindingJobListener(); + + try (IncomingMessageProcessor.Processor processor = ApplicationDependencies.getIncomingMessageProcessor().acquire()) { + jobManager.addListener(job -> job.getParameters().getQueue() != null && job.getParameters().getQueue().startsWith(PushProcessMessageJob.QUEUE_PREFIX), queueListener); + + int jobCount = enqueuePushDecryptJobs(processor, startTime, timeout); + + if (jobCount == 0) { + Log.d(TAG, "No PushDecryptMessageJobs were enqueued."); + return true; + } else { + Log.d(TAG, jobCount + " PushDecryptMessageJob(s) were enqueued."); + } + + long timeRemainingMs = blockUntilQueueDrained(PushDecryptMessageJob.QUEUE, TimeUnit.SECONDS.toMillis(10)); + Set processQueues = queueListener.getQueues(); + + Log.d(TAG, "Discovered " + processQueues.size() + " queue(s): " + processQueues); + + if (timeRemainingMs > 0) { + Iterator iter = processQueues.iterator(); + + while (iter.hasNext() && timeRemainingMs > 0) { + timeRemainingMs = blockUntilQueueDrained(iter.next(), timeRemainingMs); + } + + if (timeRemainingMs <= 0) { + Log.w(TAG, "Ran out of time while waiting for queues to drain."); + } + } else { + Log.w(TAG, "Ran out of time before we could even wait on individual queues!"); + } + + return true; + } catch (IOException e) { + Log.w(TAG, "Failed to retrieve messages. Resetting the SignalServiceMessageReceiver.", e); + ApplicationDependencies.resetSignalServiceMessageReceiver(); + return false; + } finally { + jobManager.removeListener(queueListener); + } + } + + private static int enqueuePushDecryptJobs(IncomingMessageProcessor.Processor processor, long startTime, long timeout) + throws IOException + { + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + AtomicInteger jobCount = new AtomicInteger(0); + + receiver.setSoTimeoutMillis(timeout); + + receiver.retrieveMessages(envelope -> { + Log.i(TAG, "Retrieved an envelope." + timeSuffix(startTime)); + String jobId = processor.processEnvelope(envelope); + + if (jobId != null) { + jobCount.incrementAndGet(); + } + Log.i(TAG, "Successfully processed an envelope." + timeSuffix(startTime)); + }); + + return jobCount.get(); + } + + private static long blockUntilQueueDrained(@NonNull String queue, long timeoutMs) { + long startTime = System.currentTimeMillis(); + final JobManager jobManager = ApplicationDependencies.getJobManager(); + final MarkerJob markerJob = new MarkerJob(queue); + + Optional jobState = jobManager.runSynchronously(markerJob, timeoutMs); + + if (!jobState.isPresent()) { + Log.w(TAG, "Timed out waiting for " + queue + " job(s) to finish!"); + } + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + Log.d(TAG, "Waited " + duration + " ms for the " + queue + " job(s) to finish."); + return timeoutMs - duration; + } + + @Override + public @NonNull String toString() { + return RestStrategy.class.getSimpleName(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/WebsocketStrategy.java b/app/src/main/java/org/thoughtcrime/securesms/messages/WebsocketStrategy.java new file mode 100644 index 00000000..d4df57a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/WebsocketStrategy.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.messages; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.PushProcessMessageJob; +import org.whispersystems.libsignal.InvalidVersionException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessagePipe; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +class WebsocketStrategy extends MessageRetrievalStrategy { + + private static final String TAG = Log.tag(WebsocketStrategy.class); + + private final SignalServiceMessageReceiver receiver; + private final JobManager jobManager; + + public WebsocketStrategy() { + this.receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + this.jobManager = ApplicationDependencies.getJobManager(); + } + + @Override + public boolean execute(long timeout) { + long startTime = System.currentTimeMillis(); + + try { + Set processJobQueues = drainWebsocket(timeout, startTime); + Iterator queueIterator = processJobQueues.iterator(); + long timeRemaining = Math.max(0, timeout - (System.currentTimeMillis() - startTime)); + + while (!isCanceled() && queueIterator.hasNext() && timeRemaining > 0) { + String queue = queueIterator.next(); + + blockUntilQueueDrained(TAG, queue, timeRemaining); + + timeRemaining = Math.max(0, timeout - (System.currentTimeMillis() - startTime)); + } + + return true; + } catch (IOException e) { + Log.w(TAG, "Encountered an exception while draining the websocket.", e); + return false; + } + } + + private @NonNull Set drainWebsocket(long timeout, long startTime) throws IOException { + SignalServiceMessagePipe pipe = receiver.createMessagePipe(); + QueueFindingJobListener queueListener = new QueueFindingJobListener(); + + jobManager.addListener(job -> job.getParameters().getQueue() != null && job.getParameters().getQueue().startsWith(PushProcessMessageJob.QUEUE_PREFIX), queueListener); + + try { + while (shouldContinue()) { + try { + Optional result = pipe.readOrEmpty(timeout, TimeUnit.MILLISECONDS, envelope -> { + Log.i(TAG, "Retrieved envelope! " + envelope.getTimestamp() + timeSuffix(startTime)); + try (IncomingMessageProcessor.Processor processor = ApplicationDependencies.getIncomingMessageProcessor().acquire()) { + processor.processEnvelope(envelope); + } + }); + + if (!result.isPresent()) { + Log.i(TAG, "Hit an empty response. Finished." + timeSuffix(startTime)); + break; + } + } catch (TimeoutException e) { + Log.w(TAG, "Websocket timeout." + timeSuffix(startTime)); + } + } + } finally { + pipe.shutdown(); + jobManager.removeListener(queueListener); + } + + return queueListener.getQueues(); + } + + + private boolean shouldContinue() { + return !isCanceled(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrationActivity.java new file mode 100644 index 00000000..a7889cfb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrationActivity.java @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2019 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.migrations; + +import android.os.Bundle; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; + +/** + * An activity that can be shown to block access to the rest of the app when a long-running or + * otherwise blocking application-level migration is happening. + */ +public class ApplicationMigrationActivity extends BaseActivity { + + private static final String TAG = Log.tag(ApplicationMigrationActivity.class); + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + ApplicationMigrations.getUiBlockingMigrationStatus().observe(this, running -> { + if (running == null) { + return; + } + + if (running) { + Log.i(TAG, "UI-blocking migration is in progress. Showing spinner."); + setContentView(R.layout.application_migration_activity); + } else { + Log.i(TAG, "UI-blocking migration is no-longer in progress. Finishing."); + startActivity(getIntent().getParcelableExtra("next_intent")); + finish(); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java new file mode 100644 index 00000000..1412c4a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -0,0 +1,305 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.VersionTracker; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Manages application-level migrations. + * + * Migrations can be slotted to occur based on changes in the canonical version code + * (see {@link Util#getCanonicalVersionCode()}). + * + * Migrations are performed via {@link MigrationJob}s. These jobs are durable and are run before any + * other job, allowing you to schedule safe migrations. Furthermore, you may specify that a + * migration is UI-blocking, at which point we will show a spinner via + * {@link ApplicationMigrationActivity} if the user opens the app while the migration is in + * progress. + */ +public class ApplicationMigrations { + + private static final String TAG = Log.tag(ApplicationMigrations.class); + + private static final MutableLiveData UI_BLOCKING_MIGRATION_RUNNING = new MutableLiveData<>(); + + private static final int LEGACY_CANONICAL_VERSION = 455; + + public static final int CURRENT_VERSION = 27; + + private static final class Version { + static final int LEGACY = 1; + static final int RECIPIENT_ID = 2; + static final int RECIPIENT_SEARCH = 3; + static final int RECIPIENT_CLEANUP = 4; + static final int AVATAR_MIGRATION = 5; + static final int UUIDS = 6; + static final int CACHED_ATTACHMENTS = 7; + static final int STICKERS_LAUNCH = 8; + //static final int TEST_ARGON2 = 9; + static final int SWOON_STICKERS = 10; + static final int STORAGE_SERVICE = 11; + //static final int STORAGE_KEY_ROTATE = 12; + static final int REMOVE_AVATAR_ID = 13; + static final int STORAGE_CAPABILITY = 14; + static final int PIN_REMINDER = 15; + static final int VERSIONED_PROFILE = 16; + static final int PIN_OPT_OUT = 17; + static final int TRIM_SETTINGS = 18; + static final int THUMBNAIL_CLEANUP = 19; + static final int GV2 = 20; + static final int GV2_2 = 21; + static final int CDS = 22; + static final int BACKUP_NOTIFICATION = 23; + static final int GV1_MIGRATION = 24; + static final int USER_NOTIFICATION = 25; + static final int DAY_BY_DAY_STICKERS = 26; + static final int BLOB_LOCATION = 27; + } + + /** + * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call + * to {@link JobManager#beginJobLoop()}. Otherwise, other non-migration jobs may have started + * executing before we add the migration jobs. + */ + public static void onApplicationCreate(@NonNull Context context, @NonNull JobManager jobManager) { + if (isLegacyUpdate(context)) { + Log.i(TAG, "Detected the need for a legacy update. Last seen canonical version: " + VersionTracker.getLastSeenVersion(context)); + TextSecurePreferences.setAppMigrationVersion(context, 0); + } + + if (!isUpdate(context)) { + Log.d(TAG, "Not an update. Skipping."); + VersionTracker.updateLastSeenVersion(context); + return; + } else { + Log.d(TAG, "About to update. Clearing deprecation flag."); + SignalStore.misc().clearClientDeprecated(); + } + + final int lastSeenVersion = TextSecurePreferences.getAppMigrationVersion(context); + Log.d(TAG, "currentVersion: " + CURRENT_VERSION + ", lastSeenVersion: " + lastSeenVersion); + + LinkedHashMap migrationJobs = getMigrationJobs(context, lastSeenVersion); + + if (migrationJobs.size() > 0) { + Log.i(TAG, "About to enqueue " + migrationJobs.size() + " migration(s)."); + + boolean uiBlocking = true; + int uiBlockingVersion = lastSeenVersion; + + for (Map.Entry entry : migrationJobs.entrySet()) { + int version = entry.getKey(); + MigrationJob job = entry.getValue(); + + uiBlocking &= job.isUiBlocking(); + if (uiBlocking) { + uiBlockingVersion = version; + } + + jobManager.add(job); + jobManager.add(new MigrationCompleteJob(version)); + } + + if (uiBlockingVersion > lastSeenVersion) { + Log.i(TAG, "Migration set is UI-blocking through version " + uiBlockingVersion + "."); + UI_BLOCKING_MIGRATION_RUNNING.setValue(true); + } else { + Log.i(TAG, "Migration set is non-UI-blocking."); + UI_BLOCKING_MIGRATION_RUNNING.setValue(false); + } + + final long startTime = System.currentTimeMillis(); + final int uiVersion = uiBlockingVersion; + + EventBus.getDefault().register(new Object() { + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onMigrationComplete(MigrationCompleteEvent event) { + Log.i(TAG, "Received MigrationCompleteEvent for version " + event.getVersion() + ". (Current: " + CURRENT_VERSION + ")"); + + if (event.getVersion() > CURRENT_VERSION) { + throw new AssertionError("Received a higher version than the current version? App downgrades are not supported. (received: " + event.getVersion() + ", current: " + CURRENT_VERSION + ")"); + } + + Log.i(TAG, "Updating last migration version to " + event.getVersion()); + TextSecurePreferences.setAppMigrationVersion(context, event.getVersion()); + + if (event.getVersion() == CURRENT_VERSION) { + Log.i(TAG, "Migration complete. Took " + (System.currentTimeMillis() - startTime) + " ms."); + EventBus.getDefault().unregister(this); + + VersionTracker.updateLastSeenVersion(context); + UI_BLOCKING_MIGRATION_RUNNING.setValue(false); + } else if (event.getVersion() >= uiVersion) { + Log.i(TAG, "Version is >= the UI-blocking version. Posting 'false'."); + UI_BLOCKING_MIGRATION_RUNNING.setValue(false); + } + } + }); + } else { + Log.d(TAG, "No migrations."); + TextSecurePreferences.setAppMigrationVersion(context, CURRENT_VERSION); + VersionTracker.updateLastSeenVersion(context); + UI_BLOCKING_MIGRATION_RUNNING.setValue(false); + } + } + + /** + * @return A {@link LiveData} object that will update with whether or not a UI blocking migration + * is in progress. + */ + public static LiveData getUiBlockingMigrationStatus() { + return UI_BLOCKING_MIGRATION_RUNNING; + } + + /** + * @return True if a UI blocking migration is running. + */ + public static boolean isUiBlockingMigrationRunning() { + Boolean value = UI_BLOCKING_MIGRATION_RUNNING.getValue(); + return value != null && value; + } + + /** + * @return Whether or not we're in the middle of an update, as determined by the last seen and + * current version. + */ + public static boolean isUpdate(@NonNull Context context) { + return isLegacyUpdate(context) || TextSecurePreferences.getAppMigrationVersion(context) < CURRENT_VERSION; + } + + private static LinkedHashMap getMigrationJobs(@NonNull Context context, int lastSeenVersion) { + LinkedHashMap jobs = new LinkedHashMap<>(); + + if (lastSeenVersion < Version.LEGACY) { + jobs.put(Version.LEGACY, new LegacyMigrationJob()); + } + + if (lastSeenVersion < Version.RECIPIENT_ID) { + jobs.put(Version.RECIPIENT_ID, new DatabaseMigrationJob()); + } + + if (lastSeenVersion < Version.RECIPIENT_SEARCH) { + jobs.put(Version.RECIPIENT_SEARCH, new RecipientSearchMigrationJob()); + } + + if (lastSeenVersion < Version.RECIPIENT_CLEANUP) { + jobs.put(Version.RECIPIENT_CLEANUP, new DatabaseMigrationJob()); + } + + if (lastSeenVersion < Version.AVATAR_MIGRATION) { + jobs.put(Version.AVATAR_MIGRATION, new AvatarMigrationJob()); + } + + if (lastSeenVersion < Version.UUIDS) { + jobs.put(Version.UUIDS, new UuidMigrationJob()); + } + + if (lastSeenVersion < Version.CACHED_ATTACHMENTS) { + jobs.put(Version.CACHED_ATTACHMENTS, new CachedAttachmentsMigrationJob()); + } + + if (lastSeenVersion < Version.STICKERS_LAUNCH) { + jobs.put(Version.STICKERS_LAUNCH, new StickerLaunchMigrationJob()); + } + + // This migration only triggered a test we aren't interested in any more. + // if (lastSeenVersion < Version.TEST_ARGON2) { + // jobs.put(Version.TEST_ARGON2, new Argon2TestMigrationJob()); + // } + + if (lastSeenVersion < Version.SWOON_STICKERS) { + jobs.put(Version.SWOON_STICKERS, new StickerAdditionMigrationJob(BlessedPacks.SWOON_HANDS, BlessedPacks.SWOON_FACES)); + } + + if (lastSeenVersion < Version.STORAGE_SERVICE) { + jobs.put(Version.STORAGE_SERVICE, new StorageServiceMigrationJob()); + } + + // Superceded by StorageCapabilityMigrationJob +// if (lastSeenVersion < Version.STORAGE_KEY_ROTATE) { +// jobs.put(Version.STORAGE_KEY_ROTATE, new StorageKeyRotationMigrationJob()); +// } + + if (lastSeenVersion < Version.REMOVE_AVATAR_ID) { + jobs.put(Version.REMOVE_AVATAR_ID, new AvatarIdRemovalMigrationJob()); + } + + if (lastSeenVersion < Version.STORAGE_CAPABILITY) { + jobs.put(Version.STORAGE_CAPABILITY, new StorageCapabilityMigrationJob()); + } + + if (lastSeenVersion < Version.PIN_REMINDER) { + jobs.put(Version.PIN_REMINDER, new PinReminderMigrationJob()); + } + + if (lastSeenVersion < Version.VERSIONED_PROFILE) { + jobs.put(Version.VERSIONED_PROFILE, new ProfileMigrationJob()); + } + + if (lastSeenVersion < Version.PIN_OPT_OUT) { + jobs.put(Version.PIN_OPT_OUT, new PinOptOutMigration()); + } + + if (lastSeenVersion < Version.TRIM_SETTINGS) { + jobs.put(Version.TRIM_SETTINGS, new TrimByLengthSettingsMigrationJob()); + } + + if (lastSeenVersion < Version.THUMBNAIL_CLEANUP) { + jobs.put(Version.THUMBNAIL_CLEANUP, new DatabaseMigrationJob()); + } + + if (lastSeenVersion < Version.GV2) { + jobs.put(Version.GV2, new AttributesMigrationJob()); + } + + if (lastSeenVersion < Version.GV2_2) { + jobs.put(Version.GV2_2, new AttributesMigrationJob()); + } + + if (lastSeenVersion < Version.CDS) { + jobs.put(Version.CDS, new DirectoryRefreshMigrationJob()); + } + + if (lastSeenVersion < Version.BACKUP_NOTIFICATION) { + jobs.put(Version.BACKUP_NOTIFICATION, new BackupNotificationMigrationJob()); + } + + if (lastSeenVersion < Version.GV1_MIGRATION) { + jobs.put(Version.GV1_MIGRATION, new AttributesMigrationJob()); + } + + if (lastSeenVersion < Version.USER_NOTIFICATION) { + jobs.put(Version.USER_NOTIFICATION, new UserNotificationMigrationJob()); + } + + if (lastSeenVersion < Version.DAY_BY_DAY_STICKERS) { + jobs.put(Version.DAY_BY_DAY_STICKERS, new StickerDayByDayMigrationJob()); + } + + if (lastSeenVersion < Version.BLOB_LOCATION) { + jobs.put(Version.BLOB_LOCATION, new BlobStorageLocationMigrationJob()); + } + + return jobs; + } + + private static boolean isLegacyUpdate(@NonNull Context context) { + return VersionTracker.getLastSeenVersion(context) < LEGACY_CANONICAL_VERSION; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AttributesMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/AttributesMigrationJob.java new file mode 100644 index 00000000..257448d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AttributesMigrationJob.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; + +/** + * Schedules a re-upload of the users attributes followed by a download of their profile. + */ +public final class AttributesMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(AttributesMigrationJob.class); + + public static final String KEY = "AttributesMigrationJob"; + + AttributesMigrationJob() { + this(new Parameters.Builder().build()); + } + + private AttributesMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + Log.i(TAG, "Scheduling attributes upload and profile refresh job chain"); + ApplicationDependencies.getJobManager().startChain(new RefreshAttributesJob()) + .then(new RefreshOwnProfileJob()) + .enqueue(); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull AttributesMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AttributesMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java new file mode 100644 index 00000000..ef5a9fd3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; + +/** + * We just want to make sure that the user has a profile avatar set in the RecipientDatabase, so + * we're refreshing their own profile. + */ +public class AvatarIdRemovalMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(AvatarIdRemovalMigrationJob.class); + + public static final String KEY = "AvatarIdRemovalMigrationJob"; + + AvatarIdRemovalMigrationJob() { + this(new Parameters.Builder().build()); + } + + private AvatarIdRemovalMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull AvatarIdRemovalMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AvatarIdRemovalMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java new file mode 100644 index 00000000..b7e8f9bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.regex.Pattern; + +/** + * Previously, we used a recipient's address as the filename for their avatar. We want to use + * recipientId's instead in preparation for UUIDs. + */ +public class AvatarMigrationJob extends MigrationJob { + + public static final String KEY = "AvatarMigrationJob"; + + private static final String TAG = Log.tag(AvatarMigrationJob.class); + + private static final Pattern NUMBER_PATTERN = Pattern.compile("^[0-9\\-+]+$"); + + AvatarMigrationJob() { + this(new Parameters.Builder().build()); + } + + private AvatarMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return true; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + File oldDirectory = new File(context.getFilesDir(), "avatars"); + File[] files = oldDirectory.listFiles(); + + if (files == null) { + Log.w(TAG, "Unable to read directory, and therefore unable to migrate any avatars."); + return; + } + + Log.i(TAG, "Preparing to move " + files.length + " avatars."); + + for (File file : files) { + try { + if (isValidFileName(file.getName())) { + Recipient recipient = Recipient.external(context, file.getName()); + byte[] data = StreamUtil.readFully(new FileInputStream(file)); + + AvatarHelper.setAvatar(context, recipient.getId(), new ByteArrayInputStream(data)); + } else { + Log.w(TAG, "Invalid file name! Can't migrate this file. It'll just get deleted."); + } + } catch (IOException e) { + Log.w(TAG, "Failed to copy avatar file. Skipping it.", e); + } finally { + file.delete(); + } + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + private static boolean isValidFileName(@NonNull String name) { + return NUMBER_PATTERN.matcher(name).matches() || GroupId.isEncodedGroup(name) || NumberUtil.isValidEmail(name); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull AvatarMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AvatarMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupNotificationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupNotificationMigrationJob.java new file mode 100644 index 00000000..3379453b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupNotificationMigrationJob.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.migrations; + +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.BackupFileIOError; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; +import java.util.Locale; + +/** + * Handles showing a notification if we think backups were unintentionally disabled. + */ +public final class BackupNotificationMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(BackupNotificationMigrationJob.class); + + public static final String KEY = "BackupNotificationMigrationJob"; + + BackupNotificationMigrationJob() { + this(new Parameters.Builder().build()); + } + + private BackupNotificationMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + if (Build.VERSION.SDK_INT >= 29 && !TextSecurePreferences.isBackupEnabled(context) && BackupUtil.hasBackupFiles(context)) { + Log.w(TAG, "Stranded backup! Notifying."); + BackupFileIOError.UNKNOWN.postNotification(context); + } else { + Log.w(TAG, String.format(Locale.US, "Does not meet criteria. API: %d, BackupsEnabled: %s, HasFiles: %s", + Build.VERSION.SDK_INT, + TextSecurePreferences.isBackupEnabled(context), + BackupUtil.hasBackupFiles(context))); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull BackupNotificationMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new BackupNotificationMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/BlobStorageLocationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/BlobStorageLocationMigrationJob.java new file mode 100644 index 00000000..bf3e220e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/BlobStorageLocationMigrationJob.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +import java.io.File; + +/** + * We moved files stored by {@link org.thoughtcrime.securesms.providers.BlobProvider} from the cache + * into internal storage, so we gotta move any existing multi-session files. + */ +public class BlobStorageLocationMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(BlobStorageLocationMigrationJob.class); + + public static final String KEY = "BlobStorageLocationMigrationJob"; + + BlobStorageLocationMigrationJob() { + this(new Job.Parameters.Builder().build()); + } + + private BlobStorageLocationMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() { + File oldDirectory = new File(context.getCacheDir(), "multi_session_blobs"); + + File[] oldFiles = oldDirectory.listFiles(); + + if (oldFiles == null) { + Log.i(TAG, "No files to move."); + return; + } + + Log.i(TAG, "Preparing to move " + oldFiles.length + " files."); + + File newDirectory = context.getDir("multi_session_blobs", Context.MODE_PRIVATE); + + for (File oldFile : oldFiles) { + if (oldFile.renameTo(new File(newDirectory, oldFile.getName()))) { + Log.i(TAG, "Successfully moved file: " + oldFile.getName()); + } else { + Log.w(TAG, "Failed to move file! " + oldFile.getAbsolutePath()); + } + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull BlobStorageLocationMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new BlobStorageLocationMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/CachedAttachmentsMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/CachedAttachmentsMigrationJob.java new file mode 100644 index 00000000..a7848326 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/CachedAttachmentsMigrationJob.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.FileUtils; + +import java.io.File; + +public class CachedAttachmentsMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(CachedAttachmentsMigrationJob.class); + + public static final String KEY = "CachedAttachmentsMigrationJob"; + + CachedAttachmentsMigrationJob() { + this(new Parameters.Builder().build()); + } + + private CachedAttachmentsMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() { + File externalCacheDir = context.getExternalCacheDir(); + + if (externalCacheDir == null || !externalCacheDir.exists() || !externalCacheDir.isDirectory()) { + Log.w(TAG, "External Cache Directory either does not exist or isn't a directory. Skipping."); + return; + } + + FileUtils.deleteDirectoryContents(context.getExternalCacheDir()); + GlideApp.get(context).clearDiskCache(); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull CachedAttachmentsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new CachedAttachmentsMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/DatabaseMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/DatabaseMigrationJob.java new file mode 100644 index 00000000..443c42e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/DatabaseMigrationJob.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +/** + * Triggers a database access, forcing the database to upgrade if it hasn't already. Should be used + * when you expect a database migration to take a particularly long time. + */ +public class DatabaseMigrationJob extends MigrationJob { + + public static final String KEY = "DatabaseMigrationJob"; + + DatabaseMigrationJob() { + this(new Parameters.Builder().build()); + } + + private DatabaseMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return true; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + DatabaseFactory.getInstance(context).triggerDatabaseAccess(); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull DatabaseMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new DatabaseMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/DirectoryRefreshMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/DirectoryRefreshMigrationJob.java new file mode 100644 index 00000000..1d24c034 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/DirectoryRefreshMigrationJob.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; + +/** + * Does a full directory refresh. + */ +public final class DirectoryRefreshMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(DirectoryRefreshMigrationJob.class); + + public static final String KEY = "DirectoryRefreshMigrationJob"; + + DirectoryRefreshMigrationJob() { + this(new Parameters.Builder().build()); + } + + private DirectoryRefreshMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() throws IOException { + if (!TextSecurePreferences.isPushRegistered(context) || + !SignalStore.registrationValues().isRegistrationComplete() || + TextSecurePreferences.getLocalUuid(context) == null) + { + Log.w(TAG, "Not registered! Skipping."); + return; + } + + DirectoryHelper.refreshDirectory(context, true); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull DirectoryRefreshMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new DirectoryRefreshMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/KbsEnclaveMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/KbsEnclaveMigrationJob.java new file mode 100644 index 00000000..dff47afb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/KbsEnclaveMigrationJob.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.KbsEnclaveMigrationWorkerJob; + +/** + * A job to be run whenever we add a new KBS enclave. In order to prevent this moderately-expensive + * task from blocking the network for too long, this task simply enqueues another non-migration job, + * {@link KbsEnclaveMigrationWorkerJob}, to do the heavy lifting. + */ +public class KbsEnclaveMigrationJob extends MigrationJob { + + public static final String KEY = "KbsEnclaveMigrationJob"; + + KbsEnclaveMigrationJob() { + this(new Parameters.Builder().build()); + } + + private KbsEnclaveMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + ApplicationDependencies.getJobManager().add(new KbsEnclaveMigrationWorkerJob()); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull KbsEnclaveMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new KbsEnclaveMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java new file mode 100644 index 00000000..c7e19313 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java @@ -0,0 +1,312 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase.Reader; +import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; +import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.VersionTracker; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; + +import java.io.File; +import java.util.List; + +/** + * Represents all of the migrations that used to take place in {@link ApplicationMigrationActivity} + * (previously known as DatabaseUpgradeActivity). This job should *never* have new versions or + * migrations added to it. Instead, create a new {@link MigrationJob} and place it in + * {@link ApplicationMigrations}. + */ +public class LegacyMigrationJob extends MigrationJob { + + public static final String KEY = "LegacyMigrationJob"; + + private static final String TAG = Log.tag(LegacyMigrationJob.class); + + public static final int NO_MORE_KEY_EXCHANGE_PREFIX_VERSION = 46; + public static final int MMS_BODY_VERSION = 46; + public static final int TOFU_IDENTITIES_VERSION = 50; + private static final int CURVE25519_VERSION = 63; + public static final int ASYMMETRIC_MASTER_SECRET_FIX_VERSION = 73; + private static final int NO_V1_VERSION = 83; + private static final int SIGNED_PREKEY_VERSION = 83; + private static final int NO_DECRYPT_QUEUE_VERSION = 113; + private static final int PUSH_DECRYPT_SERIAL_ID_VERSION = 131; + private static final int MIGRATE_SESSION_PLAINTEXT = 136; + private static final int CONTACTS_ACCOUNT_VERSION = 136; + private static final int MEDIA_DOWNLOAD_CONTROLS_VERSION = 151; + private static final int REDPHONE_SUPPORT_VERSION = 157; + private static final int NO_MORE_CANONICAL_DB_VERSION = 276; + private static final int PROFILES = 289; + private static final int SCREENSHOTS = 300; + private static final int PERSISTENT_BLOBS = 317; + private static final int INTERNALIZE_CONTACTS = 317; + public static final int SQLCIPHER = 334; + private static final int SQLCIPHER_COMPLETE = 352; + private static final int REMOVE_JOURNAL = 353; + private static final int REMOVE_CACHE = 354; + private static final int FULL_TEXT_SEARCH = 358; + private static final int BAD_IMPORT_CLEANUP = 373; + private static final int IMAGE_CACHE_CLEANUP = 406; + private static final int WORKMANAGER_MIGRATION = 408; + private static final int COLOR_MIGRATION = 412; + private static final int UNIDENTIFIED_DELIVERY = 422; + private static final int SIGNALING_KEY_DEPRECATION = 447; + private static final int CONVERSATION_SEARCH = 455; + + + public LegacyMigrationJob() { + this(new Parameters.Builder().build()); + } + + private LegacyMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return true; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + void performMigration() throws RetryLaterException { + Log.i(TAG, "Running background upgrade.."); + int lastSeenVersion = VersionTracker.getLastSeenVersion(context); + MasterSecret masterSecret = KeyCachingService.getMasterSecret(context); + + if (lastSeenVersion < SQLCIPHER && masterSecret != null) { + DatabaseFactory.getInstance(context).onApplicationLevelUpgrade(context, masterSecret, lastSeenVersion, (progress, total) -> { + Log.i(TAG, "onApplicationLevelUpgrade: " + progress + "/" + total); + }); + } else if (lastSeenVersion < SQLCIPHER) { + throw new RetryLaterException(); + } + + if (lastSeenVersion < CURVE25519_VERSION) { + IdentityKeyUtil.migrateIdentityKeys(context, masterSecret); + } + + if (lastSeenVersion < NO_V1_VERSION) { + File v1sessions = new File(context.getFilesDir(), "sessions"); + + if (v1sessions.exists() && v1sessions.isDirectory()) { + File[] contents = v1sessions.listFiles(); + + if (contents != null) { + for (File session : contents) { + session.delete(); + } + } + + v1sessions.delete(); + } + } + + if (lastSeenVersion < SIGNED_PREKEY_VERSION) { + ApplicationDependencies.getJobManager().add(new CreateSignedPreKeyJob(context)); + } + + if (lastSeenVersion < NO_DECRYPT_QUEUE_VERSION) { + scheduleMessagesInPushDatabase(context); + } + + if (lastSeenVersion < PUSH_DECRYPT_SERIAL_ID_VERSION) { + scheduleMessagesInPushDatabase(context); + } + + if (lastSeenVersion < MIGRATE_SESSION_PLAINTEXT) { +// new TextSecureSessionStore(context, masterSecret).migrateSessions(); +// new TextSecurePreKeyStore(context, masterSecret).migrateRecords(); + + IdentityKeyUtil.migrateIdentityKeys(context, masterSecret); + scheduleMessagesInPushDatabase(context);; + } + + if (lastSeenVersion < CONTACTS_ACCOUNT_VERSION) { + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } + + if (lastSeenVersion < MEDIA_DOWNLOAD_CONTROLS_VERSION) { + schedulePendingIncomingParts(context); + } + + if (lastSeenVersion < REDPHONE_SUPPORT_VERSION) { + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } + + if (lastSeenVersion < PROFILES) { + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } + + if (lastSeenVersion < SCREENSHOTS) { + boolean screenSecurity = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(TextSecurePreferences.SCREEN_SECURITY_PREF, true); + TextSecurePreferences.setScreenSecurityEnabled(context, screenSecurity); + } + + if (lastSeenVersion < PERSISTENT_BLOBS) { + File externalDir = context.getExternalFilesDir(null); + + if (externalDir != null && externalDir.isDirectory() && externalDir.exists()) { + for (File blob : externalDir.listFiles()) { + if (blob.exists() && blob.isFile()) blob.delete(); + } + } + } + + if (lastSeenVersion < INTERNALIZE_CONTACTS) { + if (TextSecurePreferences.isPushRegistered(context)) { + TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true); + } + } + + if (lastSeenVersion < SQLCIPHER) { + scheduleMessagesInPushDatabase(context); + } + + if (lastSeenVersion < SQLCIPHER_COMPLETE) { + File file = context.getDatabasePath("messages.db"); + if (file != null && file.exists()) file.delete(); + } + + if (lastSeenVersion < REMOVE_JOURNAL) { + File file = context.getDatabasePath("messages.db-journal"); + if (file != null && file.exists()) file.delete(); + } + + if (lastSeenVersion < REMOVE_CACHE) { + FileUtils.deleteDirectoryContents(context.getCacheDir()); + } + + if (lastSeenVersion < IMAGE_CACHE_CLEANUP) { + FileUtils.deleteDirectoryContents(context.getExternalCacheDir()); + GlideApp.get(context).clearDiskCache(); + } + + // This migration became unnecessary after switching away from WorkManager +// if (lastSeenVersion < WORKMANAGER_MIGRATION) { +// Log.i(TAG, "Beginning migration of existing jobs to WorkManager"); +// +// JobManager jobManager = ApplicationContext.getInstance(getApplicationContext()).getJobManager(); +// PersistentStorage storage = new PersistentStorage(getApplicationContext(), "TextSecureJobs", new JavaJobSerializer()); +// +// for (Job job : storage.getAllUnencrypted()) { +// jobManager.add(job); +// Log.i(TAG, "Migrated job with class '" + job.getClass().getSimpleName() + "' to run on new JobManager."); +// } +// } + + if (lastSeenVersion < COLOR_MIGRATION) { + long startTime = System.currentTimeMillis(); + DatabaseFactory.getRecipientDatabase(context).updateSystemContactColors((name, color) -> { + if (color != null) { + try { + return MaterialColor.fromSerialized(color); + } catch (MaterialColor.UnknownColorException e) { + Log.w(TAG, "Encountered an unknown color during legacy color migration.", e); + return ContactColorsLegacy.generateFor(name); + } + } + return ContactColorsLegacy.generateFor(name); + }); + Log.i(TAG, "Color migration took " + (System.currentTimeMillis() - startTime) + " ms"); + } + + if (lastSeenVersion < UNIDENTIFIED_DELIVERY) { + if (TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "MultiDevice: Disabling UD (will be re-enabled if possible after pending refresh)."); + TextSecurePreferences.setIsUnidentifiedDeliveryEnabled(context, false); + } + + Log.i(TAG, "Scheduling UD attributes refresh."); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + } + + if (lastSeenVersion < SIGNALING_KEY_DEPRECATION) { + Log.i(TAG, "Scheduling a RefreshAttributesJob to remove the signaling key remotely."); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return e instanceof RetryLaterException; + } + + private void schedulePendingIncomingParts(Context context) { + final AttachmentDatabase attachmentDb = DatabaseFactory.getAttachmentDatabase(context); + final MessageDatabase mmsDb = DatabaseFactory.getMmsDatabase(context); + final List pendingAttachments = DatabaseFactory.getAttachmentDatabase(context).getPendingAttachments(); + + Log.i(TAG, pendingAttachments.size() + " pending parts."); + for (DatabaseAttachment attachment : pendingAttachments) { + final Reader reader = MmsDatabase.readerFor(mmsDb.getMessageCursor(attachment.getMmsId())); + final MessageRecord record = reader.getNext(); + + if (attachment.hasData()) { + Log.i(TAG, "corrected a pending media part " + attachment.getAttachmentId() + "that already had data."); + attachmentDb.setTransferState(attachment.getMmsId(), attachment.getAttachmentId(), AttachmentDatabase.TRANSFER_PROGRESS_DONE); + } else if (record != null && !record.isOutgoing() && record.isPush()) { + Log.i(TAG, "queuing new attachment download job for incoming push part " + attachment.getAttachmentId() + "."); + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(attachment.getMmsId(), attachment.getAttachmentId(), false)); + } + reader.close(); + } + } + + private static void scheduleMessagesInPushDatabase(@NonNull Context context) { + PushDatabase pushDatabase = DatabaseFactory.getPushDatabase(context); + JobManager jobManager = ApplicationDependencies.getJobManager(); + + try (PushDatabase.Reader pushReader = pushDatabase.readerFor(pushDatabase.getPending())) { + SignalServiceEnvelope envelope; + while ((envelope = pushReader.getNext()) != null) { + jobManager.add(new PushDecryptMessageJob(context, envelope)); + } + } + } + + public interface DatabaseUpgradeListener { + void setProgress(int progress, int total); + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull LegacyMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new LegacyMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteEvent.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteEvent.java new file mode 100644 index 00000000..aa37d9f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteEvent.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.migrations; + +public class MigrationCompleteEvent { + + private final int version; + + public MigrationCompleteEvent(int version) { + this.version = version; + } + + public int getVersion() { + return version; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteJob.java new file mode 100644 index 00000000..ee46f453 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationCompleteJob.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.BaseJob; + +/** + * A job that should be enqueued last in a series of migrations. When this runs, we know that the + * current set of migrations has been completed. + * + * To avoid confusion around the possibility of multiples of these jobs being enqueued as the + * result of doing multiple migrations, we associate the canonicalVersionCode with the job and + * include that in the event we broadcast out. + */ +public class MigrationCompleteJob extends BaseJob { + + public static final String KEY = "MigrationCompleteJob"; + + private final static String KEY_VERSION = "version"; + + private final int version; + + MigrationCompleteJob(int version) { + this(new Parameters.Builder() + .setQueue(Parameters.MIGRATION_QUEUE_KEY) + .setLifespan(Parameters.IMMORTAL) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + version); + } + + private MigrationCompleteJob(@NonNull Job.Parameters parameters, int version) { + super(parameters); + this.version = version; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putInt(KEY_VERSION, version).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + throw new AssertionError("This job should never fail."); + } + + @Override + protected void onRun() throws Exception { + EventBus.getDefault().postSticky(new MigrationCompleteEvent(version)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return true; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull MigrationCompleteJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MigrationCompleteJob(parameters, data.getInt(KEY_VERSION)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationJob.java new file mode 100644 index 00000000..1ae6a421 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/MigrationJob.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobLogger; +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; + +/** + * A base class for jobs that are intended to be used in {@link ApplicationMigrations}. Some + * sensible defaults are provided, as well as enforcement that jobs have the correct queue key, + * never expire, and have at most one instance (to avoid double-migrating). + * + * These jobs can never fail, or else the JobManager will skip over them. As a result, if they are + * neither successful nor retryable, they will crash the app. + */ +abstract class MigrationJob extends Job { + + private static final String TAG = Log.tag(MigrationJob.class); + + MigrationJob(@NonNull Parameters parameters) { + super(parameters.toBuilder() + .setQueue(Parameters.MIGRATION_QUEUE_KEY) + .setMaxInstancesForFactory(1) + .setLifespan(Parameters.IMMORTAL) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull Result run() { + try { + Log.i(TAG, "About to run " + getClass().getSimpleName()); + performMigration(); + return Result.success(); + } catch (RuntimeException e) { + Log.w(TAG, JobLogger.format(this, "Encountered a runtime exception."), e); + throw new FailedMigrationError(e); + } catch (Exception e) { + if (shouldRetry(e)) { + Log.w(TAG, JobLogger.format(this, "Encountered a retryable exception."), e); + return Result.retry(BackoffUtil.exponentialBackoff(getRunAttempt(), FeatureFlags.getDefaultMaxBackoff())); + } else { + Log.w(TAG, JobLogger.format(this, "Encountered a non-runtime fatal exception."), e); + throw new FailedMigrationError(e); + } + } + } + + @Override + public void onFailure() { + throw new AssertionError("This job should never fail. " + getClass().getSimpleName()); + } + + /** + * @return True if you want the UI to be blocked by a spinner if the user opens the application + * during the migration, otherwise false. + */ + abstract boolean isUiBlocking(); + + /** + * Do the actual work of your migration. + */ + abstract void performMigration() throws Exception; + + /** + * @return True if you should retry this job based on the exception type, otherwise false. + * Returning false will result in a crash and your job being re-run upon app start. + * This could result in a crash loop, but considering that this is for an application + * migration, this is likely preferable to skipping it. + */ + abstract boolean shouldRetry(@NonNull Exception e); + + private static class FailedMigrationError extends Error { + FailedMigrationError(Throwable t) { + super(t); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PassingMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PassingMigrationJob.java new file mode 100644 index 00000000..c3ae0449 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PassingMigrationJob.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; + +/** + * A migration job that always passes. Not useful on it's own, but you can register it's factory for jobs that + * have been removed that you'd like to pass instead of keeping around. + */ +public final class PassingMigrationJob extends MigrationJob { + + public static final String KEY = "PassingMigrationJob"; + + private PassingMigrationJob(Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() { + // Nothing + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull PassingMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PassingMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java new file mode 100644 index 00000000..d82fb359 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.StorageForcePushJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +/** + * We changed some details of what it means to opt-out of a PIN. This ensures that users who went + * through the previous opt-out flow are now in the same state as users who went through the new + * opt-out flow. + */ +public final class PinOptOutMigration extends MigrationJob { + + private static final String TAG = Log.tag(PinOptOutMigration.class); + + public static final String KEY = "PinOptOutMigration"; + + PinOptOutMigration() { + this(new Parameters.Builder().build()); + } + + private PinOptOutMigration(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() { + if (SignalStore.kbsValues().hasOptedOut() && SignalStore.kbsValues().hasPin()) { + Log.w(TAG, "Discovered a legacy opt-out user! Resetting the state."); + + SignalStore.kbsValues().optOut(); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + ApplicationDependencies.getJobManager().add(new StorageForcePushJob()); + } else if (SignalStore.kbsValues().hasOptedOut()) { + Log.i(TAG, "Discovered an opt-out user, but they're already in a good state. No action required."); + } else { + Log.i(TAG, "Discovered a normal PIN user. No action required."); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull PinOptOutMigration create(@NonNull Parameters parameters, @NonNull Data data) { + return new PinOptOutMigration(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinReminderMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinReminderMigrationJob.java new file mode 100644 index 00000000..449ebd28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinReminderMigrationJob.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.util.concurrent.TimeUnit; + +public class PinReminderMigrationJob extends MigrationJob { + + public static final String KEY = "PinReminderMigrationJob"; + + PinReminderMigrationJob() { + this(new Job.Parameters.Builder().build()); + } + + private PinReminderMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + void performMigration() { + SignalStore.pinValues().setNextReminderIntervalToAtMost(TimeUnit.DAYS.toMillis(3)); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull PinReminderMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PinReminderMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ProfileMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ProfileMigrationJob.java new file mode 100644 index 00000000..1c6d8942 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ProfileMigrationJob.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; + +/** + * Schedules a re-upload of the users profile. + */ +public final class ProfileMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(ProfileMigrationJob.class); + + public static final String KEY = "ProfileMigrationJob"; + + ProfileMigrationJob() { + this(new Parameters.Builder().build()); + } + + private ProfileMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + Log.i(TAG, "Scheduling profile upload job"); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull ProfileMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ProfileMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/RecipientSearchMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/RecipientSearchMigrationJob.java new file mode 100644 index 00000000..33cec735 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/RecipientSearchMigrationJob.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; + +import java.io.IOException; + +/** + * We added a column for keeping track of the phone number type ("mobile", "home", etc) to the + * recipient database, and therefore we need to do a directory sync to fill in that column. + */ +public class RecipientSearchMigrationJob extends MigrationJob { + + public static final String KEY = "RecipientSearchMigrationJob"; + + RecipientSearchMigrationJob() { + this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY).build()); + } + + private RecipientSearchMigrationJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() throws Exception { + DirectoryHelper.refreshDirectory(context, false); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull RecipientSearchMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RecipientSearchMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java new file mode 100644 index 00000000..4b1ef056 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/RegistrationPinV2MigrationJob.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.migrations; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobs.BaseJob; +import org.thoughtcrime.securesms.pin.PinState; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Migrates an existing V1 registration lock user to a V2 registration lock that is backed by a + * Signal PIN. + * + * Deliberately not a {@link MigrationJob} because it is not something that needs to run at app start. + * This migration can run at anytime. + */ +public final class RegistrationPinV2MigrationJob extends BaseJob { + + private static final String TAG = Log.tag(RegistrationPinV2MigrationJob.class); + + public static final String KEY = "RegistrationPinV2MigrationJob"; + + public RegistrationPinV2MigrationJob() { + this(new Parameters.Builder() + .setQueue(KEY) + .setMaxInstancesForFactory(1) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(Job.Parameters.IMMORTAL) + .setMaxAttempts(Job.Parameters.UNLIMITED) + .build()); + } + + private RegistrationPinV2MigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + protected void onRun() throws IOException, UnauthenticatedResponseException, InvalidKeyException { + if (!TextSecurePreferences.isV1RegistrationLockEnabled(context)) { + Log.i(TAG, "Registration lock disabled"); + return; + } + + //noinspection deprecation Only acceptable place to read the old pin. + String pinValue = TextSecurePreferences.getDeprecatedV1RegistrationLockPin(context); + + if (pinValue == null | TextUtils.isEmpty(pinValue)) { + Log.i(TAG, "No old pin to migrate"); + return; + } + + Log.i(TAG, "Migrating pin to Key Backup Service"); + PinState.onMigrateToRegistrationLockV2(context, pinValue); + Log.i(TAG, "Pin migrated to Key Backup Service"); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull RegistrationPinV2MigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RegistrationPinV2MigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerAdditionMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerAdditionMigrationJob.java new file mode 100644 index 00000000..e01d0a94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerAdditionMigrationJob.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.stickers.BlessedPacks; + +import java.util.Arrays; +import java.util.List; + +/** + * Migration job for installing new blessed packs as references. This means that the packs will + * show up in the list as available blessed packs, but they *won't* be auto-installed. + */ +public class StickerAdditionMigrationJob extends MigrationJob { + + public static final String KEY = "StickerInstallMigrationJob"; + + private static String TAG = Log.tag(StickerAdditionMigrationJob.class); + + private static final String KEY_PACKS = "packs"; + + private final List packs; + + StickerAdditionMigrationJob(@NonNull BlessedPacks.Pack... packs) { + this(new Parameters.Builder().build(), Arrays.asList(packs)); + } + + private StickerAdditionMigrationJob(@NonNull Parameters parameters, @NonNull List packs) { + super(parameters); + this.packs = packs; + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public @NonNull Data serialize() { + String[] packsRaw = Stream.of(packs).map(BlessedPacks.Pack::toJson).toArray(String[]::new); + return new Data.Builder().putStringArray(KEY_PACKS, packsRaw).build(); + } + + @Override + public void performMigration() { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + for (BlessedPacks.Pack pack : packs) { + Log.i(TAG, "Installing reference for blessed pack: " + pack.getPackId()); + jobManager.add(StickerPackDownloadJob.forReference(pack.getPackId(), pack.getPackKey())); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull StickerAdditionMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + String[] raw = data.getStringArray(KEY_PACKS); + List packs = Stream.of(raw).map(BlessedPacks.Pack::fromJson).toList(); + + return new StickerAdditionMigrationJob(parameters, packs); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerDayByDayMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerDayByDayMigrationJob.java new file mode 100644 index 00000000..374c74da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerDayByDayMigrationJob.java @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.stickers.BlessedPacks; + +/** + * Installs Day by Day blessed pack. + */ +public class StickerDayByDayMigrationJob extends MigrationJob { + + public static final String KEY = "StickerDayByDayMigrationJob"; + + StickerDayByDayMigrationJob() { + this(new Parameters.Builder().build()); + } + + private StickerDayByDayMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false)); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull StickerDayByDayMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StickerDayByDayMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerLaunchMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerLaunchMigrationJob.java new file mode 100644 index 00000000..4a37a61c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StickerLaunchMigrationJob.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackOperationJob; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class StickerLaunchMigrationJob extends MigrationJob { + + public static final String KEY = "StickerLaunchMigrationJob"; + + StickerLaunchMigrationJob() { + this(new Parameters.Builder().build()); + } + + private StickerLaunchMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + installPack(context, BlessedPacks.ZOZO); + installPack(context, BlessedPacks.BANDIT); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + private static void installPack(@NonNull Context context, @NonNull BlessedPacks.Pack pack) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + StickerDatabase stickerDatabase = DatabaseFactory.getStickerDatabase(context); + + if (stickerDatabase.isPackAvailableAsReference(pack.getPackId())) { + stickerDatabase.markPackAsInstalled(pack.getPackId(), false); + } + + jobManager.add(StickerPackDownloadJob.forInstall(pack.getPackId(), pack.getPackKey(), false)); + + if (TextSecurePreferences.isMultiDevice(context)) { + jobManager.add(new MultiDeviceStickerPackOperationJob(pack.getPackId(), pack.getPackKey(), MultiDeviceStickerPackOperationJob.Type.INSTALL)); + } + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + StickerLaunchMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StickerLaunchMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageCapabilityMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageCapabilityMigrationJob.java new file mode 100644 index 00000000..4dbe4d36 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageCapabilityMigrationJob.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceStorageSyncRequestJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.StorageForcePushJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * This does a couple things: + * (1) Sets the storage capability for reglockv2 users by refreshing account attributes. + * (2) Force-pushes storage, which is now backed by the KBS master key. + * + * Note: *All* users need to do this force push, because some people were in the storage service FF + * bucket in the past, and if we don't schedule a force push, they could enter a situation + * where different storage items are encrypted with different keys. + */ +public class StorageCapabilityMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(StorageCapabilityMigrationJob.class); + + public static final String KEY = "StorageCapabilityMigrationJob"; + + StorageCapabilityMigrationJob() { + this(new Parameters.Builder().build()); + } + + private StorageCapabilityMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + jobManager.add(new RefreshAttributesJob()); + + if (TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Multi-device."); + jobManager.startChain(new StorageForcePushJob()) + .then(new MultiDeviceKeysUpdateJob()) + .then(new MultiDeviceStorageSyncRequestJob()) + .enqueue(); + } else { + Log.i(TAG, "Single-device."); + jobManager.add(new StorageForcePushJob()); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull StorageCapabilityMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageCapabilityMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java new file mode 100644 index 00000000..ea1406e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class StorageServiceMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(StorageServiceMigrationJob.class); + + public static final String KEY = "StorageServiceMigrationJob"; + + StorageServiceMigrationJob() { + this(new Parameters.Builder().build()); + } + + private StorageServiceMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Multi-device."); + jobManager.startChain(new StorageSyncJob()) + .then(new MultiDeviceKeysUpdateJob()) + .enqueue(); + } else { + Log.i(TAG, "Single-device."); + jobManager.add(new StorageSyncJob()); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull StorageServiceMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageServiceMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/TrimByLengthSettingsMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/TrimByLengthSettingsMigrationJob.java new file mode 100644 index 00000000..9cd09b79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/TrimByLengthSettingsMigrationJob.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import static org.thoughtcrime.securesms.keyvalue.SettingsValues.THREAD_TRIM_ENABLED; +import static org.thoughtcrime.securesms.keyvalue.SettingsValues.THREAD_TRIM_LENGTH; + +public class TrimByLengthSettingsMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(TrimByLengthSettingsMigrationJob.class); + + public static final String KEY = "TrimByLengthSettingsMigrationJob"; + + TrimByLengthSettingsMigrationJob() { + this(new Parameters.Builder().build()); + } + + private TrimByLengthSettingsMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() throws Exception { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(ApplicationDependencies.getApplication()); + if (preferences.contains(THREAD_TRIM_ENABLED)) { + SignalStore.settings().setThreadTrimByLengthEnabled(preferences.getBoolean(THREAD_TRIM_ENABLED, false)); + //noinspection ConstantConditions + SignalStore.settings().setThreadTrimLength(Integer.parseInt(preferences.getString(THREAD_TRIM_LENGTH, "500"))); + + preferences.edit() + .remove(THREAD_TRIM_ENABLED) + .remove(THREAD_TRIM_LENGTH) + .apply(); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull TrimByLengthSettingsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new TrimByLengthSettingsMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java new file mode 100644 index 00000000..29c92e10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.migrations; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.TaskStackBuilder; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.NewConversationActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.List; +import java.util.Set; + +/** + * Show a user that contacts are newly available. Only for users that recently installed. + */ +public class UserNotificationMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(UserNotificationMigrationJob.class); + + public static final String KEY = "UserNotificationMigration"; + + UserNotificationMigrationJob() { + this(new Parameters.Builder().build()); + } + + private UserNotificationMigrationJob(Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() { + if (!TextSecurePreferences.isPushRegistered(context) || + TextSecurePreferences.getLocalNumber(context) == null || + TextSecurePreferences.getLocalUuid(context) == null) + { + Log.w(TAG, "Not registered! Skipping."); + return; + } + + if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) { + Log.w(TAG, "New contact notifications disabled! Skipping."); + return; + } + + if (TextSecurePreferences.getFirstInstallVersion(context) < 759) { + Log.w(TAG, "Install is older than v5.0.8. Skipping."); + return; + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + + int threadCount = threadDatabase.getUnarchivedConversationListCount() + + threadDatabase.getArchivedConversationListCount(); + + if (threadCount >= 3) { + Log.w(TAG, "Already have 3 or more threads. Skipping."); + return; + } + + List registered = DatabaseFactory.getRecipientDatabase(context).getRegistered(); + List systemContacts = DatabaseFactory.getRecipientDatabase(context).getSystemContacts(); + Set registeredSystemContacts = SetUtil.intersection(registered, systemContacts); + Set threadRecipients = threadDatabase.getAllThreadRecipients(); + + if (threadRecipients.containsAll(registeredSystemContacts)) { + Log.w(TAG, "Threads already exist for all relevant contacts. Skipping."); + return; + } + + String message = context.getResources().getQuantityString(R.plurals.UserNotificationMigrationJob_d_contacts_are_on_signal, + registeredSystemContacts.size(), + registeredSystemContacts.size()); + + Intent mainActivityIntent = new Intent(context, MainActivity.class); + Intent newConversationIntent = new Intent(context, NewConversationActivity.class); + PendingIntent pendingIntent = TaskStackBuilder.create(context) + .addNextIntent(mainActivityIntent) + .addNextIntent(newConversationIntent) + .getPendingIntent(0, 0); + + Notification notification = new NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) + .setSmallIcon(R.drawable.ic_notification) + .setContentText(message) + .setContentIntent(pendingIntent) + .build(); + + try { + NotificationManagerCompat.from(context) + .notify(NotificationIds.USER_NOTIFICATION_MIGRATION, notification); + } catch (Throwable t) { + Log.w(TAG, "Failed to notify!", t); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull UserNotificationMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new UserNotificationMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java new file mode 100644 index 00000000..5835ddbc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.migrations; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; +import java.util.UUID; + +/** + * Couple migrations steps need to happen after we move to UUIDS. + * - We need to get our own UUID. + * - We need to fetch the new UUID sealed sender cert. + * - We need to do a directory sync so we can guarantee that all active users have UUIDs. + */ +public class UuidMigrationJob extends MigrationJob { + + public static final String KEY = "UuidMigrationJob"; + + private static final String TAG = Log.tag(UuidMigrationJob.class); + + UuidMigrationJob() { + this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY).build()); + } + + private UuidMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + boolean isUiBlocking() { + return false; + } + + @Override + void performMigration() throws Exception { + if (!TextSecurePreferences.isPushRegistered(context) || TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) { + Log.w(TAG, "Not registered! Skipping migration, as it wouldn't do anything."); + return; + } + + ensureSelfRecipientExists(context); + fetchOwnUuid(context); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return e instanceof IOException; + } + + private static void ensureSelfRecipientExists(@NonNull Context context) { + DatabaseFactory.getRecipientDatabase(context).getOrInsertFromE164(TextSecurePreferences.getLocalNumber(context)); + } + + private static void fetchOwnUuid(@NonNull Context context) throws IOException { + RecipientId self = Recipient.self().getId(); + UUID localUuid = ApplicationDependencies.getSignalServiceAccountManager().getOwnUuid(); + + DatabaseFactory.getRecipientDatabase(context).markRegisteredOrThrow(self, localUuid); + TextSecurePreferences.setLocalUuid(context, localUuid); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull UuidMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new UuidMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ApnUnavailableException.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ApnUnavailableException.java new file mode 100644 index 00000000..7aea170f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ApnUnavailableException.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.mms; + +public class ApnUnavailableException extends Exception { + + public ApnUnavailableException() { + } + + public ApnUnavailableException(String detailMessage) { + super(detailMessage); + } + + public ApnUnavailableException(Throwable throwable) { + super(throwable); + } + + public ApnUnavailableException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java new file mode 100644 index 00000000..c8385b06 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.ContactsContract; +import android.provider.OpenableColumns; +import android.util.Pair; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.DocumentView; +import org.thoughtcrime.securesms.components.RemovableEditableMediaView; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.location.SignalMapView; +import org.thoughtcrime.securesms.components.location.SignalPlace; +import org.thoughtcrime.securesms.giph.ui.GiphyActivity; +import org.thoughtcrime.securesms.maps.PlacePickerActivity; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.thoughtcrime.securesms.util.views.Stub; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + + +public class AttachmentManager { + + private final static String TAG = AttachmentManager.class.getSimpleName(); + + private final @NonNull Context context; + private final @NonNull Stub attachmentViewStub; + private final @NonNull AttachmentListener attachmentListener; + + private RemovableEditableMediaView removableMediaView; + private ThumbnailView thumbnail; + private AudioView audioView; + private DocumentView documentView; + private SignalMapView mapView; + + private @NonNull List garbage = new LinkedList<>(); + private @NonNull Optional slide = Optional.absent(); + private @Nullable Uri captureUri; + + public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { + this.context = activity; + this.attachmentListener = listener; + this.attachmentViewStub = ViewUtil.findStubById(activity, R.id.attachment_editor_stub); + } + + private void inflateStub() { + if (!attachmentViewStub.resolved()) { + View root = attachmentViewStub.get(); + + this.thumbnail = root.findViewById(R.id.attachment_thumbnail); + this.audioView = root.findViewById(R.id.attachment_audio); + this.documentView = root.findViewById(R.id.attachment_document); + this.mapView = root.findViewById(R.id.attachment_location); + this.removableMediaView = root.findViewById(R.id.removable_media_view); + + removableMediaView.setRemoveClickListener(new RemoveButtonListener()); + thumbnail.setOnClickListener(new ThumbnailClickListener()); + documentView.getBackground().setColorFilter(ContextCompat.getColor(context, R.color.signal_background_secondary), PorterDuff.Mode.MULTIPLY); + } + + } + + public void clear(@NonNull GlideRequests glideRequests, boolean animate) { + if (attachmentViewStub.resolved()) { + + if (animate) { + ViewUtil.fadeOut(attachmentViewStub.get(), 200).addListener(new Listener() { + @Override + public void onSuccess(Boolean result) { + thumbnail.clear(glideRequests); + attachmentViewStub.get().setVisibility(View.GONE); + attachmentListener.onAttachmentChanged(); + } + + @Override + public void onFailure(ExecutionException e) { + } + }); + } else { + thumbnail.clear(glideRequests); + attachmentViewStub.get().setVisibility(View.GONE); + attachmentListener.onAttachmentChanged(); + } + + markGarbage(getSlideUri()); + slide = Optional.absent(); + } + } + + public void cleanup() { + cleanup(captureUri); + cleanup(getSlideUri()); + + captureUri = null; + slide = Optional.absent(); + + Iterator iterator = garbage.listIterator(); + + while (iterator.hasNext()) { + cleanup(iterator.next()); + iterator.remove(); + } + } + + private void cleanup(final @Nullable Uri uri) { + if (uri != null && DeprecatedPersistentBlobProvider.isAuthority(context, uri)) { + Log.d(TAG, "cleaning up " + uri); + DeprecatedPersistentBlobProvider.getInstance(context).delete(context, uri); + } else if (uri != null && BlobProvider.isAuthority(uri)) { + BlobProvider.getInstance().delete(context, uri); + } + } + + private void markGarbage(@Nullable Uri uri) { + if (uri != null && (DeprecatedPersistentBlobProvider.isAuthority(context, uri) || BlobProvider.isAuthority(uri))) { + Log.d(TAG, "Marking garbage that needs cleaning: " + uri); + garbage.add(uri); + } + } + + private void setSlide(@NonNull Slide slide) { + if (getSlideUri() != null) { + cleanup(getSlideUri()); + } + + if (captureUri != null && !captureUri.equals(slide.getUri())) { + cleanup(captureUri); + captureUri = null; + } + + this.slide = Optional.of(slide); + } + + public ListenableFuture setLocation(@NonNull final SignalPlace place, + @NonNull final MediaConstraints constraints) + { + inflateStub(); + + SettableFuture returnResult = new SettableFuture<>(); + ListenableFuture future = mapView.display(place); + + attachmentViewStub.get().setVisibility(View.VISIBLE); + removableMediaView.display(mapView, false); + + future.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(@NonNull Bitmap result) { + byte[] blob = BitmapUtil.toByteArray(result); + Uri uri = BlobProvider.getInstance() + .forData(blob) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleSessionInMemory(); + LocationSlide locationSlide = new LocationSlide(context, uri, blob.length, place); + + Util.runOnMain(() -> { + setSlide(locationSlide); + attachmentListener.onAttachmentChanged(); + returnResult.set(true); + }); + } + }); + + return returnResult; + } + + @SuppressLint("StaticFieldLeak") + public ListenableFuture setMedia(@NonNull final GlideRequests glideRequests, + @NonNull final Uri uri, + @NonNull final SlideFactory.MediaType mediaType, + @NonNull final MediaConstraints constraints, + final int width, + final int height) + { + inflateStub(); + + final SettableFuture result = new SettableFuture<>(); + + new AsyncTask() { + @Override + protected void onPreExecute() { + thumbnail.clear(glideRequests); + thumbnail.showProgressSpinner(); + attachmentViewStub.get().setVisibility(View.VISIBLE); + } + + @Override + protected @Nullable Slide doInBackground(Void... params) { + try { + if (PartAuthority.isLocalUri(uri)) { + return getManuallyCalculatedSlideInfo(uri, width, height); + } else { + Slide result = getContentResolverSlideInfo(uri, width, height); + + if (result == null) return getManuallyCalculatedSlideInfo(uri, width, height); + else return result; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + protected void onPostExecute(@Nullable final Slide slide) { + if (slide == null) { + attachmentViewStub.get().setVisibility(View.GONE); + Toast.makeText(context, + R.string.ConversationActivity_sorry_there_was_an_error_setting_your_attachment, + Toast.LENGTH_SHORT).show(); + result.set(false); + } else if (!areConstraintsSatisfied(context, slide, constraints)) { + attachmentViewStub.get().setVisibility(View.GONE); + Toast.makeText(context, + R.string.ConversationActivity_attachment_exceeds_size_limits, + Toast.LENGTH_SHORT).show(); + result.set(false); + } else { + setSlide(slide); + attachmentViewStub.get().setVisibility(View.VISIBLE); + + if (slide.hasAudio()) { + audioView.setAudio((AudioSlide) slide, null, false, false); + removableMediaView.display(audioView, false); + result.set(true); + } else if (slide.hasDocument()) { + documentView.setDocument((DocumentSlide) slide, false); + removableMediaView.display(documentView, false); + result.set(true); + } else { + Attachment attachment = slide.asAttachment(); + result.deferTo(thumbnail.setImageResource(glideRequests, slide, false, true, attachment.getWidth(), attachment.getHeight())); + removableMediaView.display(thumbnail, mediaType == SlideFactory.MediaType.IMAGE); + } + + attachmentListener.onAttachmentChanged(); + } + } + + private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height) { + Cursor cursor = null; + long start = System.currentTimeMillis(); + + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + String mimeType = context.getContentResolver().getType(uri); + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, fileSize, width, height); + } + } finally { + if (cursor != null) cursor.close(); + } + + return null; + } + + private @NonNull Slide getManuallyCalculatedSlideInfo(Uri uri, int width, int height) throws IOException { + long start = System.currentTimeMillis(); + Long mediaSize = null; + String fileName = null; + String mimeType = null; + + if (PartAuthority.isLocalUri(uri)) { + mediaSize = PartAuthority.getAttachmentSize(context, uri); + fileName = PartAuthority.getAttachmentFileName(context, uri); + mimeType = PartAuthority.getAttachmentContentType(context, uri); + } + + if (mediaSize == null) { + mediaSize = MediaUtil.getMediaSize(context, uri); + } + + if (mimeType == null) { + mimeType = MediaUtil.getMimeType(context, uri); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, mediaSize, width, height); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return result; + } + + public boolean isAttachmentPresent() { + return attachmentViewStub.resolved() && attachmentViewStub.get().getVisibility() == View.VISIBLE; + } + + public @NonNull SlideDeck buildSlideDeck() { + SlideDeck deck = new SlideDeck(); + if (slide.isPresent()) deck.addSlide(slide.get()); + return deck; + } + + public static void selectDocument(Activity activity, int requestCode) { + selectMediaType(activity, "*/*", null, requestCode); + } + + public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport) { + Permissions.with(activity) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body, transport), requestCode)) + .execute(); + } + + public static void selectContactInfo(Activity activity, int requestCode) { + Permissions.with(activity) + .request(Manifest.permission.READ_CONTACTS) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information)) + .onAllGranted(() -> { + Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI); + activity.startActivityForResult(intent, requestCode); + }) + .execute(); + } + + public static void selectLocation(Activity activity, int requestCode) { + Permissions.with(activity) + .request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + .ifNecessary() + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location)) + .onAllGranted(() -> PlacePickerActivity.startActivityForResultAtCurrentLocation(activity, requestCode)) + .execute(); + } + + public static void selectGif(Activity activity, int requestCode, boolean isForMms, @ColorInt int color) { + Intent intent = new Intent(activity, GiphyActivity.class); + intent.putExtra(GiphyActivity.EXTRA_IS_MMS, isForMms); + intent.putExtra(GiphyActivity.EXTRA_COLOR, color); + activity.startActivityForResult(intent, requestCode); + } + + private @Nullable Uri getSlideUri() { + return slide.isPresent() ? slide.get().getUri() : null; + } + + public @Nullable Uri getCaptureUri() { + return captureUri; + } + + private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + final Intent intent = new Intent(); + intent.setType(type); + + if (extraMimeType != null) { + intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + } + + intent.setAction(Intent.ACTION_OPEN_DOCUMENT); + try { + activity.startActivityForResult(intent, requestCode); + return; + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_OPEN_DOCUMENT, no activity found. falling back."); + } + + intent.setAction(Intent.ACTION_GET_CONTENT); + + try { + activity.startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_GET_CONTENT intent, no activity found. falling back."); + Toast.makeText(activity, R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG).show(); + } + } + + private boolean areConstraintsSatisfied(final @NonNull Context context, + final @Nullable Slide slide, + final @NonNull MediaConstraints constraints) + { + return slide == null || + constraints.isSatisfied(context, slide.asAttachment()) || + constraints.canResize(slide.asAttachment()); + } + + private void previewImageDraft(final @NonNull Slide slide) { + if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, slide.asAttachment().getSize()); + intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, slide.getCaption().orNull()); + intent.setDataAndType(slide.getUri(), slide.getContentType()); + + context.startActivity(intent); + } + } + + private class ThumbnailClickListener implements View.OnClickListener { + @Override + public void onClick(View v) { + if (slide.isPresent()) previewImageDraft(slide.get()); + } + } + + private class RemoveButtonListener implements View.OnClickListener { + @Override + public void onClick(View v) { + cleanup(); + clear(GlideApp.with(context.getApplicationContext()), true); + } + } + + public interface AttachmentListener { + void onAttachmentChanged(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamLocalUriFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamLocalUriFetcher.java new file mode 100644 index 00000000..bd1a9991 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamLocalUriFetcher.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +class AttachmentStreamLocalUriFetcher implements DataFetcher { + + private static final String TAG = AttachmentStreamLocalUriFetcher.class.getSimpleName(); + + private final File attachment; + private final byte[] key; + private final Optional digest; + private final long plaintextLength; + + private InputStream is; + + AttachmentStreamLocalUriFetcher(File attachment, long plaintextLength, byte[] key, Optional digest) { + this.attachment = attachment; + this.plaintextLength = plaintextLength; + this.digest = digest; + this.key = key; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + if (!digest.isPresent()) throw new InvalidMessageException("No attachment digest!"); + is = AttachmentCipherInputStream.createForAttachment(attachment, plaintextLength, key, digest.get()); + callback.onDataReady(is); + } catch (IOException | InvalidMessageException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + try { + if (is != null) is.close(); + is = null; + } catch (IOException ioe) { + Log.w(TAG, "ioe"); + } + } + + @Override + public void cancel() {} + + @Override + public @NonNull Class getDataClass() { + return InputStream.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.LOCAL; + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamUriLoader.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamUriLoader.java new file mode 100644 index 00000000..4dc3b324 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentStreamUriLoader.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.File; +import java.io.InputStream; +import java.security.MessageDigest; + +public class AttachmentStreamUriLoader implements ModelLoader { + + @Override + public @Nullable LoadData buildLoadData(@NonNull AttachmentModel attachmentModel, int width, int height, @NonNull Options options) { + return new LoadData<>(attachmentModel, new AttachmentStreamLocalUriFetcher(attachmentModel.attachment, attachmentModel.plaintextLength, attachmentModel.key, attachmentModel.digest)); + } + + @Override + public boolean handles(@NonNull AttachmentModel attachmentModel) { + return true; + } + + static class Factory implements ModelLoaderFactory { + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new AttachmentStreamUriLoader(); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + public static class AttachmentModel implements Key { + public @NonNull File attachment; + public @NonNull byte[] key; + public @NonNull Optional digest; + public long plaintextLength; + + public AttachmentModel(@NonNull File attachment, @NonNull byte[] key, + long plaintextLength, @NonNull Optional digest) + { + this.attachment = attachment; + this.key = key; + this.digest = digest; + this.plaintextLength = plaintextLength; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(attachment.toString().getBytes()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AttachmentModel that = (AttachmentModel)o; + + return attachment.equals(that.attachment); + + } + + @Override + public int hashCode() { + return attachment.hashCode(); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java new file mode 100644 index 00000000..cfaa6dd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -0,0 +1,72 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.util.MediaUtil; + + +public class AudioSlide extends Slide { + + public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false)); + } + + public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) { + super(context, new UriAttachment(uri, contentType, AttachmentDatabase.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, null, null, null, null, null)); + } + + public AudioSlide(Context context, Attachment attachment) { + super(context, attachment); + } + + @Override + public boolean hasPlaceholder() { + return true; + } + + @Override + public boolean hasImage() { + return false; + } + + @Override + public boolean hasAudio() { + return true; + } + + @NonNull + @Override + public String getContentDescription() { + return context.getString(R.string.Slide_audio); + } + + @Override + public @DrawableRes int getPlaceholderRes(Theme theme) { + return R.drawable.ic_audio; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/CompatMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/CompatMmsConnection.java new file mode 100644 index 00000000..70195301 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/CompatMmsConnection.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.pdu_alt.PduHeaders; +import com.google.android.mms.pdu_alt.RetrieveConf; +import com.google.android.mms.pdu_alt.SendConf; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; + +import java.io.IOException; + +public class CompatMmsConnection implements OutgoingMmsConnection, IncomingMmsConnection { + private static final String TAG = CompatMmsConnection.class.getSimpleName(); + + private Context context; + + public CompatMmsConnection(Context context) { + this.context = context; + } + + @Nullable + @Override + public SendConf send(@NonNull byte[] pduBytes, int subscriptionId) + throws UndeliverableMessageException + { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP_MR1) { + try { + Log.i(TAG, "Sending via Lollipop API"); + return new OutgoingLollipopMmsConnection(context).send(pduBytes, subscriptionId); + } catch (UndeliverableMessageException e) { + Log.w(TAG, e); + } + + Log.i(TAG, "Falling back to legacy connection..."); + } + + if (subscriptionId == -1) { + Log.i(TAG, "Sending via legacy connection"); + try { + SendConf result = new OutgoingLegacyMmsConnection(context).send(pduBytes, subscriptionId); + + if (result != null && result.getResponseStatus() == PduHeaders.RESPONSE_STATUS_OK) { + return result; + } else { + Log.w(TAG, "Got bad legacy response: " + (result != null ? result.getResponseStatus() : null)); + } + } catch (UndeliverableMessageException | ApnUnavailableException e) { + Log.w(TAG, e); + } + } + + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && VERSION.SDK_INT < VERSION_CODES.LOLLIPOP_MR1) { + Log.i(TAG, "Falling back to sending via Lollipop API"); + return new OutgoingLollipopMmsConnection(context).send(pduBytes, subscriptionId); + } + + throw new UndeliverableMessageException("Both lollipop and legacy connections failed..."); + } + + @Nullable + @Override + public RetrieveConf retrieve(@NonNull String contentLocation, + byte[] transactionId, + int subscriptionId) + throws MmsException, MmsRadioException, ApnUnavailableException, IOException + { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP_MR1) { + Log.i(TAG, "Receiving via Lollipop API"); + try { + return new IncomingLollipopMmsConnection(context).retrieve(contentLocation, transactionId, subscriptionId); + } catch (MmsException e) { + Log.w(TAG, e); + } + + Log.i(TAG, "Falling back to receiving via legacy connection"); + } + + if (VERSION.SDK_INT < 22 || subscriptionId == -1) { + Log.i(TAG, "Receiving via legacy API"); + try { + return new IncomingLegacyMmsConnection(context).retrieve(contentLocation, transactionId, subscriptionId); + } catch (MmsRadioException | ApnUnavailableException | IOException e) { + Log.w(TAG, e); + } + } + + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && VERSION.SDK_INT < VERSION_CODES.LOLLIPOP_MR1) { + Log.i(TAG, "Falling back to receiving via Lollipop API"); + return new IncomingLollipopMmsConnection(context).retrieve(contentLocation, transactionId, subscriptionId); + } + + throw new IOException("Both lollipop and fallback APIs failed..."); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ContactPhotoLocalUriFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ContactPhotoLocalUriFetcher.java new file mode 100644 index 00000000..374800a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ContactPhotoLocalUriFetcher.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract; + +import com.bumptech.glide.load.data.StreamLocalUriFetcher; + +import java.io.FileNotFoundException; +import java.io.InputStream; + +class ContactPhotoLocalUriFetcher extends StreamLocalUriFetcher { + + private static final String TAG = ContactPhotoLocalUriFetcher.class.getSimpleName(); + + ContactPhotoLocalUriFetcher(Context context, Uri uri) { + super(context.getContentResolver(), uri); + } + + @Override + protected InputStream loadResource(Uri uri, ContentResolver contentResolver) + throws FileNotFoundException + { + if (VERSION.SDK_INT >= VERSION_CODES.ICE_CREAM_SANDWICH) { + return ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri, true); + } else { + return ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java new file mode 100644 index 00000000..1ff387f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; + +import com.bumptech.glide.load.data.StreamLocalUriFetcher; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher { + + private static final String TAG = DecryptableStreamLocalUriFetcher.class.getSimpleName(); + + private Context context; + + DecryptableStreamLocalUriFetcher(Context context, Uri uri) { + super(context.getContentResolver(), uri); + this.context = context; + } + + @Override + protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException { + if (MediaUtil.hasVideoThumbnail(context, uri)) { + Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, 1000); + + if (thumbnail != null) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, baos); + ByteArrayInputStream thumbnailStream = new ByteArrayInputStream(baos.toByteArray()); + thumbnail.recycle(); + return thumbnailStream; + } + } + + try { + return PartAuthority.getAttachmentThumbnailStream(context, uri); + } catch (IOException ioe) { + Log.w(TAG, ioe); + throw new FileNotFoundException("PartAuthority couldn't load Uri resource."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java new file mode 100644 index 00000000..98f33192 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamUriLoader.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; + +import java.io.InputStream; +import java.security.MessageDigest; + +public class DecryptableStreamUriLoader implements ModelLoader { + + private final Context context; + + private DecryptableStreamUriLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData buildLoadData(@NonNull DecryptableUri decryptableUri, int width, int height, @NonNull Options options) { + return new LoadData<>(decryptableUri, new DecryptableStreamLocalUriFetcher(context, decryptableUri.uri)); + } + + @Override + public boolean handles(@NonNull DecryptableUri decryptableUri) { + return true; + } + + static class Factory implements ModelLoaderFactory { + + private final Context context; + + Factory(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new DecryptableStreamUriLoader(context); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + public static class DecryptableUri implements Key { + public @NonNull Uri uri; + + public DecryptableUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(uri.toString().getBytes()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DecryptableUri that = (DecryptableUri)o; + + return uri.equals(that.uri); + + } + + @Override + public int hashCode() { + return uri.hashCode(); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java new file mode 100644 index 00000000..43873718 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.StorageUtil; + +public class DocumentSlide extends Slide { + + public DocumentSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + } + + public DocumentSlide(@NonNull Context context, @NonNull Uri uri, + @NonNull String contentType, long size, + @Nullable String fileName) + { + super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, null, null, null, false, false, false)); + } + + @Override + public boolean hasDocument() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java new file mode 100644 index 00000000..3e303c62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class GifSlide extends ImageSlide { + + private final boolean borderless; + + public GifSlide(Context context, Attachment attachment) { + super(context, attachment); + this.borderless = attachment.isBorderless(); + } + + public GifSlide(Context context, Uri uri, long size, int width, int height) { + this(context, uri, size, width, height, false, null); + } + + public GifSlide(Context context, Uri uri, long size, int width, int height, boolean borderless, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, null, null, null, false, borderless, false)); + this.borderless = borderless; + } + + @Override + public boolean isBorderless() { + return borderless; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java new file mode 100644 index 00000000..f2998834 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class ImageSlide extends Slide { + + private final boolean borderless; + + @SuppressWarnings("unused") + private static final String TAG = ImageSlide.class.getSimpleName(); + + public ImageSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + this.borderless = attachment.isBorderless(); + } + + public ImageSlide(Context context, Uri uri, long size, int width, int height, @Nullable BlurHash blurHash) { + this(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, false, null, blurHash); + } + + public ImageSlide(Context context, Uri uri, String contentType, long size, int width, int height, boolean borderless, @Nullable String caption, @Nullable BlurHash blurHash) { + super(context, constructAttachmentFromUri(context, uri, contentType, size, width, height, true, null, caption, null, blurHash, null, false, borderless, false)); + this.borderless = borderless; + } + + @Override + public @DrawableRes int getPlaceholderRes(Theme theme) { + return 0; + } + + @Override + public boolean hasImage() { + return true; + } + + @Override + public boolean hasPlaceholder() { + return getPlaceholderBlur() != null; + } + + @Override + public boolean isBorderless() { + return borderless; + } + + @NonNull + @Override + public String getContentDescription() { + return context.getString(R.string.Slide_image); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLegacyMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLegacyMmsConnection.java new file mode 100644 index 00000000..7bccf2e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLegacyMmsConnection.java @@ -0,0 +1,157 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.InvalidHeaderValueException; +import com.google.android.mms.pdu_alt.NotifyRespInd; +import com.google.android.mms.pdu_alt.PduComposer; +import com.google.android.mms.pdu_alt.PduHeaders; +import com.google.android.mms.pdu_alt.PduParser; +import com.google.android.mms.pdu_alt.RetrieveConf; + +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpGetHC4; +import org.apache.http.client.methods.HttpUriRequest; +import org.signal.core.util.logging.Log; + +import java.io.IOException; +import java.util.Arrays; + + +@SuppressWarnings("deprecation") +public class IncomingLegacyMmsConnection extends LegacyMmsConnection implements IncomingMmsConnection { + private static final String TAG = IncomingLegacyMmsConnection.class.getSimpleName(); + + public IncomingLegacyMmsConnection(Context context) throws ApnUnavailableException { + super(context); + } + + private HttpUriRequest constructRequest(Apn contentApn, boolean useProxy) throws IOException { + HttpGetHC4 request; + + try { + request = new HttpGetHC4(contentApn.getMmsc()); + } catch (IllegalArgumentException e) { + // #7339 + throw new IOException(e); + } + + for (Header header : getBaseHeaders()) { + request.addHeader(header); + } + + if (useProxy) { + HttpHost proxy = new HttpHost(contentApn.getProxy(), contentApn.getPort()); + request.setConfig(RequestConfig.custom().setProxy(proxy).build()); + } + + return request; + } + + @Override + public @Nullable RetrieveConf retrieve(@NonNull String contentLocation, + byte[] transactionId, int subscriptionId) + throws MmsRadioException, ApnUnavailableException, IOException + { + MmsRadio radio = MmsRadio.getInstance(context); + Apn contentApn = new Apn(contentLocation, apn.getProxy(), Integer.toString(apn.getPort()), apn.getUsername(), apn.getPassword()); + + if (isDirectConnect()) { + Log.i(TAG, "Connecting directly..."); + try { + return retrieve(contentApn, transactionId, false, false); + } catch (IOException | ApnUnavailableException e) { + Log.w(TAG, e); + } + } + + Log.i(TAG, "Changing radio to MMS mode.."); + radio.connect(); + + try { + Log.i(TAG, "Downloading in MMS mode with proxy..."); + + try { + return retrieve(contentApn, transactionId, true, true); + } catch (IOException | ApnUnavailableException e) { + Log.w(TAG, e); + } + + Log.i(TAG, "Downloading in MMS mode without proxy..."); + + return retrieve(contentApn, transactionId, true, false); + + } finally { + radio.disconnect(); + } + } + + public RetrieveConf retrieve(Apn contentApn, byte[] transactionId, boolean usingMmsRadio, boolean useProxyIfAvailable) + throws IOException, ApnUnavailableException + { + byte[] pdu = null; + + final boolean useProxy = useProxyIfAvailable && contentApn.hasProxy(); + final String targetHost = useProxy + ? contentApn.getProxy() + : Uri.parse(contentApn.getMmsc()).getHost(); + if (checkRouteToHost(context, targetHost, usingMmsRadio)) { + Log.i(TAG, "got successful route to host " + targetHost); + pdu = execute(constructRequest(contentApn, useProxy)); + } + + if (pdu == null) { + throw new IOException("Connection manager could not obtain route to host."); + } + + RetrieveConf retrieved = (RetrieveConf)new PduParser(pdu).parse(); + + if (retrieved == null) { + Log.w(TAG, "Couldn't parse PDU, byte response: " + Arrays.toString(pdu)); + Log.w(TAG, "Couldn't parse PDU, ASCII: " + new String(pdu)); + throw new IOException("Bad retrieved PDU"); + } + + sendRetrievedAcknowledgement(transactionId, usingMmsRadio, useProxy); + return retrieved; + } + + private void sendRetrievedAcknowledgement(byte[] transactionId, + boolean usingRadio, + boolean useProxy) + throws ApnUnavailableException + { + try { + NotifyRespInd notifyResponse = new NotifyRespInd(PduHeaders.CURRENT_MMS_VERSION, + transactionId, + PduHeaders.STATUS_RETRIEVED); + + OutgoingLegacyMmsConnection connection = new OutgoingLegacyMmsConnection(context); + connection.sendNotificationReceived(new PduComposer(context, notifyResponse).make(), usingRadio, useProxy); + } catch (InvalidHeaderValueException | IOException e) { + Log.w(TAG, e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLollipopMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLollipopMmsConnection.java new file mode 100644 index 00000000..b6576448 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingLollipopMmsConnection.java @@ -0,0 +1,153 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.telephony.SmsManager; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.InvalidHeaderValueException; +import com.google.android.mms.pdu_alt.NotifyRespInd; +import com.google.android.mms.pdu_alt.PduComposer; +import com.google.android.mms.pdu_alt.PduHeaders; +import com.google.android.mms.pdu_alt.PduParser; +import com.google.android.mms.pdu_alt.RetrieveConf; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.providers.MmsBodyProvider; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Locale; +import java.util.concurrent.TimeoutException; + +public class IncomingLollipopMmsConnection extends LollipopMmsConnection implements IncomingMmsConnection { + + public static final String ACTION = IncomingLollipopMmsConnection.class.getCanonicalName() + "MMS_DOWNLOADED_ACTION"; + private static final String TAG = IncomingLollipopMmsConnection.class.getSimpleName(); + + public IncomingLollipopMmsConnection(Context context) { + super(context, ACTION); + } + + @TargetApi(VERSION_CODES.LOLLIPOP) + @Override + public synchronized void onResult(Context context, Intent intent) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP_MR1) { + Log.i(TAG, "HTTP status: " + intent.getIntExtra(SmsManager.EXTRA_MMS_HTTP_STATUS, -1)); + } + Log.i(TAG, "code: " + getResultCode() + ", result string: " + getResultData()); + } + + @Override + @TargetApi(VERSION_CODES.LOLLIPOP) + public synchronized @Nullable RetrieveConf retrieve(@NonNull String contentLocation, + byte[] transactionId, + int subscriptionId) throws MmsException + { + beginTransaction(); + + try { + MmsBodyProvider.Pointer pointer = MmsBodyProvider.makeTemporaryPointer(getContext()); + + final String transactionIdString = Util.toIsoString(transactionId); + Log.i(TAG, String.format(Locale.ENGLISH, "Downloading subscriptionId=%s multimedia from '%s' [transactionId='%s'] to '%s'", + subscriptionId, + contentLocation, + transactionIdString, + pointer.getUri())); + + SmsManager smsManager; + + if (VERSION.SDK_INT >= 22 && subscriptionId != -1) { + smsManager = SmsManager.getSmsManagerForSubscriptionId(subscriptionId); + } else { + smsManager = SmsManager.getDefault(); + } + + final Bundle configOverrides = smsManager.getCarrierConfigValues(); + + if (configOverrides.getBoolean(SmsManager.MMS_CONFIG_APPEND_TRANSACTION_ID)) { + if (!contentLocation.contains(transactionIdString)) { + Log.i(TAG, "Appending transactionId to contentLocation at the direction of CarrierConfigValues. New location: " + contentLocation); + contentLocation += transactionIdString; + } else { + Log.i(TAG, "Skipping 'append transaction id' as contentLocation already contains it"); + } + } + + if (TextUtils.isEmpty(configOverrides.getString(SmsManager.MMS_CONFIG_USER_AGENT))) { + configOverrides.remove(SmsManager.MMS_CONFIG_USER_AGENT); + } + + if (TextUtils.isEmpty(configOverrides.getString(SmsManager.MMS_CONFIG_UA_PROF_URL))) { + configOverrides.remove(SmsManager.MMS_CONFIG_UA_PROF_URL); + } + + smsManager.downloadMultimediaMessage(getContext(), + contentLocation, + pointer.getUri(), + configOverrides, + getPendingIntent()); + + waitForResult(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + StreamUtil.copy(pointer.getInputStream(), baos); + pointer.close(); + + Log.i(TAG, baos.size() + "-byte response: ");// + Hex.dump(baos.toByteArray())); + + Bundle configValues = smsManager.getCarrierConfigValues(); + boolean parseContentDisposition = configValues.getBoolean(SmsManager.MMS_CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION); + + RetrieveConf retrieved = (RetrieveConf) new PduParser(baos.toByteArray(), parseContentDisposition).parse(); + + if (retrieved == null) return null; + + sendRetrievedAcknowledgement(transactionId, retrieved.getMmsVersion(), subscriptionId); + return retrieved; + } catch (IOException | TimeoutException e) { + Log.w(TAG, e); + throw new MmsException(e); + } finally { + endTransaction(); + } + } + + private void sendRetrievedAcknowledgement(byte[] transactionId, int mmsVersion, int subscriptionId) { + try { + NotifyRespInd retrieveResponse = new NotifyRespInd(mmsVersion, transactionId, PduHeaders.STATUS_RETRIEVED); + new OutgoingLollipopMmsConnection(getContext()).send(new PduComposer(getContext(), retrieveResponse).make(), subscriptionId); + } catch (UndeliverableMessageException e) { + Log.w(TAG, e); + } catch (InvalidHeaderValueException e) { + Log.w(TAG, e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java new file mode 100644 index 00000000..a93410a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java @@ -0,0 +1,181 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.PointerAttachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class IncomingMediaMessage { + + private final RecipientId from; + private final GroupId groupId; + private final String body; + private final boolean push; + private final long sentTimeMillis; + private final long serverTimeMillis; + private final int subscriptionId; + private final long expiresIn; + private final boolean expirationUpdate; + private final QuoteModel quote; + private final boolean unidentified; + private final boolean viewOnce; + + private final List attachments = new LinkedList<>(); + private final List sharedContacts = new LinkedList<>(); + private final List linkPreviews = new LinkedList<>(); + private final List mentions = new LinkedList<>(); + + public IncomingMediaMessage(@NonNull RecipientId from, + Optional groupId, + String body, + long sentTimeMillis, + long serverTimeMillis, + List attachments, + int subscriptionId, + long expiresIn, + boolean expirationUpdate, + boolean viewOnce, + boolean unidentified, + Optional> sharedContacts) + { + this.from = from; + this.groupId = groupId.orNull(); + this.sentTimeMillis = sentTimeMillis; + this.serverTimeMillis = serverTimeMillis; + this.body = body; + this.push = false; + this.subscriptionId = subscriptionId; + this.expiresIn = expiresIn; + this.expirationUpdate = expirationUpdate; + this.viewOnce = viewOnce; + this.quote = null; + this.unidentified = unidentified; + + this.attachments.addAll(attachments); + this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); + + } + + public IncomingMediaMessage(@NonNull RecipientId from, + long sentTimeMillis, + long serverTimeMillis, + int subscriptionId, + long expiresIn, + boolean expirationUpdate, + boolean viewOnce, + boolean unidentified, + Optional body, + Optional group, + Optional> attachments, + Optional quote, + Optional> sharedContacts, + Optional> linkPreviews, + Optional> mentions, + Optional sticker) + { + this.push = true; + this.from = from; + this.sentTimeMillis = sentTimeMillis; + this.serverTimeMillis = serverTimeMillis; + this.body = body.orNull(); + this.subscriptionId = subscriptionId; + this.expiresIn = expiresIn; + this.expirationUpdate = expirationUpdate; + this.viewOnce = viewOnce; + this.quote = quote.orNull(); + this.unidentified = unidentified; + + if (group.isPresent()) this.groupId = GroupUtil.idFromGroupContextOrThrow(group.get()); + else this.groupId = null; + + this.attachments.addAll(PointerAttachment.forPointers(attachments));//ddddd + this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); + this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList())); + this.mentions.addAll(mentions.or(Collections.emptyList())); + + if (sticker.isPresent()) { + this.attachments.add(sticker.get()); + } + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public String getBody() { + return body; + } + + public List getAttachments() { + return attachments; + } + + public @NonNull RecipientId getFrom() { + return from; + } + + public GroupId getGroupId() { + return groupId; + } + + public boolean isPushMessage() { + return push; + } + + public boolean isExpirationUpdate() { + return expirationUpdate; + } + + public long getSentTimeMillis() { + return sentTimeMillis; + } + + public long getServerTimeMillis() { + return serverTimeMillis; + } + + public long getExpiresIn() { + return expiresIn; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public boolean isGroupMessage() { + return groupId != null; + } + + public QuoteModel getQuote() { + return quote; + } + + public List getSharedContacts() { + return sharedContacts; + } + + public List getLinkPreviews() { + return linkPreviews; + } + + public @NonNull List getMentions() { + return mentions; + } + + public boolean isUnidentified() { + return unidentified; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMmsConnection.java new file mode 100644 index 00000000..44827068 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMmsConnection.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.pdu_alt.RetrieveConf; + +import java.io.IOException; + +public interface IncomingMmsConnection { + @Nullable + RetrieveConf retrieve(@NonNull String contentLocation, byte[] transactionId, int subscriptionId) throws MmsException, MmsRadioException, ApnUnavailableException, IOException; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/LegacyMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/LegacyMmsConnection.java new file mode 100644 index 00000000..b4d1d820 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/LegacyMmsConnection.java @@ -0,0 +1,313 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; + +import org.apache.http.Header; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.NoConnectionReuseStrategyHC4; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.client.LaxRedirectStrategy; +import org.apache.http.impl.conn.BasicHttpClientConnectionManager; +import org.apache.http.message.BasicHeader; +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.ApnDatabase; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TelephonyUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.URL; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +@SuppressWarnings("deprecation") +public abstract class LegacyMmsConnection { + + public static final String USER_AGENT = "Android-Mms/2.0"; + + private static final String TAG = LegacyMmsConnection.class.getSimpleName(); + + protected final Context context; + protected final Apn apn; + + protected LegacyMmsConnection(Context context) throws ApnUnavailableException { + this.context = context; + this.apn = getApn(context); + } + + public static Apn getApn(Context context) throws ApnUnavailableException { + + try { + Optional params = ApnDatabase.getInstance(context) + .getMmsConnectionParameters(TelephonyUtil.getMccMnc(context), + TelephonyUtil.getApn(context)); + + if (!params.isPresent()) { + throw new ApnUnavailableException("No parameters available from ApnDefaults."); + } + + return params.get(); + } catch (IOException ioe) { + throw new ApnUnavailableException("ApnDatabase threw an IOException", ioe); + } + } + + protected boolean isDirectConnect() { + // We think Sprint supports direct connection over wifi/data, but not Verizon + Set sprintMccMncs = new HashSet() {{ + add("312530"); + add("311880"); + add("311870"); + add("311490"); + add("310120"); + add("316010"); + add("312190"); + }}; + + return ServiceUtil.getTelephonyManager(context).getPhoneType() == TelephonyManager.PHONE_TYPE_CDMA && + sprintMccMncs.contains(TelephonyUtil.getMccMnc(context)); + } + + @SuppressWarnings("TryWithIdenticalCatches") + protected static boolean checkRouteToHost(Context context, String host, boolean usingMmsRadio) + throws IOException + { + InetAddress inetAddress = InetAddress.getByName(host); + if (!usingMmsRadio) { + if (inetAddress.isSiteLocalAddress()) { + throw new IOException("RFC1918 address in non-MMS radio situation!"); + } + Log.w(TAG, "returning vacuous success since MMS radio is not in use"); + return true; + } + + if (inetAddress == null) { + throw new IOException("Unable to lookup host: InetAddress.getByName() returned null."); + } + + byte[] ipAddressBytes = inetAddress.getAddress(); + if (ipAddressBytes == null) { + Log.w(TAG, "resolved IP address bytes are null, returning true to attempt a connection anyway."); + return true; + } + + Log.i(TAG, "Checking route to address: " + host + ", " + inetAddress.getHostAddress()); + ConnectivityManager manager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + + try { + final Method requestRouteMethod = manager.getClass().getMethod("requestRouteToHostAddress", Integer.TYPE, InetAddress.class); + final boolean routeToHostObtained = (Boolean) requestRouteMethod.invoke(manager, MmsRadio.TYPE_MOBILE_MMS, inetAddress); + Log.i(TAG, "requestRouteToHostAddress(" + inetAddress + ") -> " + routeToHostObtained); + return routeToHostObtained; + } catch (NoSuchMethodException nsme) { + Log.w(TAG, nsme); + } catch (IllegalAccessException iae) { + Log.w(TAG, iae); + } catch (InvocationTargetException ite) { + Log.w(TAG, ite); + } + + return false; + } + + protected static byte[] parseResponse(InputStream is) throws IOException { + InputStream in = new BufferedInputStream(is); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + StreamUtil.copy(in, baos); + + Log.i(TAG, "Received full server response, " + baos.size() + " bytes"); + + return baos.toByteArray(); + } + + protected CloseableHttpClient constructHttpClient() throws IOException { + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(20 * 1000) + .setConnectionRequestTimeout(20 * 1000) + .setSocketTimeout(20 * 1000) + .setMaxRedirects(20) + .build(); + + URL mmsc = new URL(apn.getMmsc()); + CredentialsProvider credsProvider = new BasicCredentialsProvider(); + + if (apn.hasAuthentication()) { + credsProvider.setCredentials(new AuthScope(mmsc.getHost(), mmsc.getPort() > -1 ? mmsc.getPort() : mmsc.getDefaultPort()), + new UsernamePasswordCredentials(apn.getUsername(), apn.getPassword())); + } + + return HttpClients.custom() + .setConnectionReuseStrategy(new NoConnectionReuseStrategyHC4()) + .setRedirectStrategy(new LaxRedirectStrategy()) + .setUserAgent(TextSecurePreferences.getMmsUserAgent(context, USER_AGENT)) + .setConnectionManager(new BasicHttpClientConnectionManager()) + .setDefaultRequestConfig(config) + .setDefaultCredentialsProvider(credsProvider) + .build(); + } + + protected byte[] execute(HttpUriRequest request) throws IOException { + Log.i(TAG, "connecting to " + apn.getMmsc()); + + CloseableHttpClient client = null; + CloseableHttpResponse response = null; + try { + client = constructHttpClient(); + response = client.execute(request); + + Log.i(TAG, "* response code: " + response.getStatusLine()); + + if (response.getStatusLine().getStatusCode() == 200) { + return parseResponse(response.getEntity().getContent()); + } + } catch (NullPointerException npe) { + // TODO determine root cause + // see: https://github.com/signalapp/Signal-Android/issues/4379 + throw new IOException(npe); + } finally { + if (response != null) response.close(); + if (client != null) client.close(); + } + + throw new IOException("unhandled response code"); + } + + protected List
getBaseHeaders() { + final String number = getLine1Number(context); + + return new LinkedList
() {{ + add(new BasicHeader("Accept", "*/*, application/vnd.wap.mms-message, application/vnd.wap.sic")); + add(new BasicHeader("x-wap-profile", "http://www.google.com/oha/rdf/ua-profile-kila.xml")); + add(new BasicHeader("Content-Type", "application/vnd.wap.mms-message")); + add(new BasicHeader("x-carrier-magic", "http://magic.google.com")); + if (!TextUtils.isEmpty(number)) { + add(new BasicHeader("x-up-calling-line-id", number)); + add(new BasicHeader("X-MDN", number)); + } + }}; + } + + @SuppressLint("HardwareIds") + private static String getLine1Number(@NonNull Context context) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_NUMBERS) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + return TelephonyUtil.getManager(context).getLine1Number(); + } else { + return ""; + } + } + + public static class Apn { + + public static Apn EMPTY = new Apn("", "", "", "", ""); + + private final String mmsc; + private final String proxy; + private final String port; + private final String username; + private final String password; + + public Apn(String mmsc, String proxy, String port, String username, String password) { + this.mmsc = mmsc; + this.proxy = proxy; + this.port = port; + this.username = username; + this.password = password; + } + + public Apn(Apn customApn, Apn defaultApn, + boolean useCustomMmsc, + boolean useCustomProxy, + boolean useCustomProxyPort, + boolean useCustomUsername, + boolean useCustomPassword) + { + this.mmsc = useCustomMmsc ? customApn.mmsc : defaultApn.mmsc; + this.proxy = useCustomProxy ? customApn.proxy : defaultApn.proxy; + this.port = useCustomProxyPort ? customApn.port : defaultApn.port; + this.username = useCustomUsername ? customApn.username : defaultApn.username; + this.password = useCustomPassword ? customApn.password : defaultApn.password; + } + + public boolean hasProxy() { + return !TextUtils.isEmpty(proxy); + } + + public String getMmsc() { + return mmsc; + } + + public String getProxy() { + return hasProxy() ? proxy : null; + } + + public int getPort() { + return TextUtils.isEmpty(port) ? 80 : Integer.parseInt(port); + } + + public boolean hasAuthentication() { + return !TextUtils.isEmpty(username); + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public @NonNull String toString() { + return Apn.class.getSimpleName() + + "{ mmsc: \"" + mmsc + "\"" + + ", proxy: " + (proxy == null ? "none" : '"' + proxy + '"') + + ", port: " + (port == null ? "(none)" : port) + + ", user: " + (username == null ? "none" : '"' + username + '"') + + ", pass: " + (password == null ? "none" : '"' + password + '"') + " }"; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/LocationSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/LocationSlide.java new file mode 100644 index 00000000..a6159f2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/LocationSlide.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.components.location.SignalPlace; +import org.whispersystems.libsignal.util.guava.Optional; + +public class LocationSlide extends ImageSlide { + + @NonNull + private final SignalPlace place; + + public LocationSlide(@NonNull Context context, @NonNull Uri uri, long size, @NonNull SignalPlace place) + { + super(context, uri, size, 0, 0, null); + this.place = place; + } + + @Override + @NonNull + public Optional getBody() { + return Optional.of(place.getDescription()); + } + + @NonNull + public SignalPlace getPlace() { + return place; + } + + @Override + public boolean hasLocation() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/LollipopMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/LollipopMmsConnection.java new file mode 100644 index 00000000..cdd8052f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/LollipopMmsConnection.java @@ -0,0 +1,86 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.TimeoutException; + +public abstract class LollipopMmsConnection extends BroadcastReceiver { + private static final String TAG = LollipopMmsConnection.class.getSimpleName(); + + private final Context context; + private final String action; + + private boolean resultAvailable; + + public abstract void onResult(Context context, Intent intent); + + protected LollipopMmsConnection(Context context, String action) { + super(); + this.context = context; + this.action = action; + } + + @Override + public synchronized void onReceive(Context context, Intent intent) { + Log.i(TAG, "onReceive()"); + if (!action.equals(intent.getAction())) { + Log.w(TAG, "received broadcast with unexpected action " + intent.getAction()); + return; + } + + onResult(context, intent); + + resultAvailable = true; + notifyAll(); + } + + protected void beginTransaction() { + getContext().getApplicationContext().registerReceiver(this, new IntentFilter(action)); + } + + protected void endTransaction() { + getContext().getApplicationContext().unregisterReceiver(this); + resultAvailable = false; + } + + protected void waitForResult() throws TimeoutException { + long timeoutExpiration = System.currentTimeMillis() + 60000; + while (!resultAvailable) { + Util.wait(this, Math.max(1, timeoutExpiration - System.currentTimeMillis())); + if (System.currentTimeMillis() >= timeoutExpiration) { + throw new TimeoutException("timeout when waiting for MMS"); + } + } + } + + protected PendingIntent getPendingIntent() { + return PendingIntent.getBroadcast(getContext(), 1, new Intent(action), PendingIntent.FLAG_ONE_SHOT); + } + + protected Context getContext() { + return context; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java new file mode 100644 index 00000000..5810a184 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.util.Pair; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor; + +import java.io.IOException; +import java.io.InputStream; + +public abstract class MediaConstraints { + private static final String TAG = MediaConstraints.class.getSimpleName(); + + public static MediaConstraints getPushMediaConstraints() { + return new PushMediaConstraints(); + } + + public static MediaConstraints getMmsMediaConstraints(int subscriptionId) { + return new MmsMediaConstraints(subscriptionId); + } + + public abstract int getImageMaxWidth(Context context); + public abstract int getImageMaxHeight(Context context); + public abstract int getImageMaxSize(Context context); + + /** + * Provide a list of dimensions that should be attempted during compression. We will keep moving + * down the list until the image can be scaled to fit under {@link #getImageMaxSize(Context)}. + * The first entry in the list should match your max width/height. + */ + public abstract int[] getImageDimensionTargets(Context context); + + public abstract int getGifMaxSize(Context context); + public abstract int getVideoMaxSize(Context context); + + public int getUncompressedVideoMaxSize(Context context) { + return getVideoMaxSize(context); + } + + public int getCompressedVideoMaxSize(Context context) { + return getVideoMaxSize(context); + } + + public abstract int getAudioMaxSize(Context context); + public abstract int getDocumentMaxSize(Context context); + + public boolean isSatisfied(@NonNull Context context, @NonNull Attachment attachment) { + try { + return (MediaUtil.isGif(attachment) && attachment.getSize() <= getGifMaxSize(context) && isWithinBounds(context, attachment.getUri())) || + (MediaUtil.isImage(attachment) && attachment.getSize() <= getImageMaxSize(context) && isWithinBounds(context, attachment.getUri())) || + (MediaUtil.isAudio(attachment) && attachment.getSize() <= getAudioMaxSize(context)) || + (MediaUtil.isVideo(attachment) && attachment.getSize() <= getVideoMaxSize(context)) || + (MediaUtil.isFile(attachment) && attachment.getSize() <= getDocumentMaxSize(context)); + } catch (IOException ioe) { + Log.w(TAG, "Failed to determine if media's constraints are satisfied.", ioe); + return false; + } + } + + private boolean isWithinBounds(Context context, Uri uri) throws IOException { + try { + InputStream is = PartAuthority.getAttachmentStream(context, uri); + Pair dimensions = BitmapUtil.getDimensions(is); + return dimensions.first > 0 && dimensions.first <= getImageMaxWidth(context) && + dimensions.second > 0 && dimensions.second <= getImageMaxHeight(context); + } catch (BitmapDecodingException e) { + throw new IOException(e); + } + } + + public boolean canResize(@NonNull Attachment attachment) { + return MediaUtil.isImage(attachment) && !MediaUtil.isGif(attachment) || + MediaUtil.isVideo(attachment) && isVideoTranscodeAvailable(); + } + + public static boolean isVideoTranscodeAvailable() { + return Build.VERSION.SDK_INT >= 26 && (FeatureFlags.useStreamingVideoMuxer() || MemoryFileDescriptor.supported()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaNotFoundException.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaNotFoundException.java new file mode 100644 index 00000000..1215dda9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaNotFoundException.java @@ -0,0 +1,36 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +public class MediaNotFoundException extends Exception { + + public MediaNotFoundException() { + } + + public MediaNotFoundException(String detailMessage) { + super(detailMessage); + } + + public MediaNotFoundException(Throwable throwable) { + super(throwable); + } + + public MediaNotFoundException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java new file mode 100644 index 00000000..c736dd3a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import java.io.InputStream; + +public class MediaStream { + private final InputStream stream; + private final String mimeType; + private final int width; + private final int height; + + public MediaStream(InputStream stream, String mimeType, int width, int height) { + this.stream = stream; + this.mimeType = mimeType; + this.width = width; + this.height = height; + } + + public InputStream getStream() { + return stream; + } + + public String getMimeType() { + return mimeType; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaTooLargeException.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaTooLargeException.java new file mode 100644 index 00000000..2b07920c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaTooLargeException.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +public class MediaTooLargeException extends Exception { + + public MediaTooLargeException() { + // TODO Auto-generated constructor stub + } + + public MediaTooLargeException(String detailMessage) { + super(detailMessage); + // TODO Auto-generated constructor stub + } + + public MediaTooLargeException(Throwable throwable) { + super(throwable); + // TODO Auto-generated constructor stub + } + + public MediaTooLargeException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + // TODO Auto-generated constructor stub + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java new file mode 100644 index 00000000..3757f3c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +/** + * Represents either a GroupV1 or GroupV2 encoded context. + */ +public final class MessageGroupContext { + + @NonNull private final String encodedGroupContext; + @NonNull private final GroupProperties group; + @Nullable private final GroupV1Properties groupV1; + @Nullable private final GroupV2Properties groupV2; + + public MessageGroupContext(@NonNull String encodedGroupContext, boolean v2) + throws IOException + { + this.encodedGroupContext = encodedGroupContext; + if (v2) { + this.groupV1 = null; + this.groupV2 = new GroupV2Properties(DecryptedGroupV2Context.parseFrom(Base64.decode(encodedGroupContext))); + this.group = groupV2; + } else { + this.groupV1 = new GroupV1Properties(GroupContext.parseFrom(Base64.decode(encodedGroupContext))); + this.groupV2 = null; + this.group = groupV1; + } + } + + public MessageGroupContext(@NonNull GroupContext group) { + this.encodedGroupContext = Base64.encodeBytes(group.toByteArray()); + this.groupV1 = new GroupV1Properties(group); + this.groupV2 = null; + this.group = groupV1; + } + + public MessageGroupContext(@NonNull DecryptedGroupV2Context group) { + this.encodedGroupContext = Base64.encodeBytes(group.toByteArray()); + this.groupV1 = null; + this.groupV2 = new GroupV2Properties(group); + this.group = groupV2; + } + + public @NonNull GroupV1Properties requireGroupV1Properties() { + if (groupV1 == null) { + throw new AssertionError(); + } + return groupV1; + } + + public @NonNull GroupV2Properties requireGroupV2Properties() { + if (groupV2 == null) { + throw new AssertionError(); + } + return groupV2; + } + + public boolean isV2Group() { + return groupV2 != null; + } + + public @NonNull String getEncodedGroupContext() { + return encodedGroupContext; + } + + public String getName() { + return group.getName(); + } + + public List getMembersListExcludingSelf() { + return group.getMembersListExcludingSelf(); + } + + interface GroupProperties { + @NonNull String getName(); + @NonNull List getMembersListExcludingSelf(); + } + + public static class GroupV1Properties implements GroupProperties { + + private final GroupContext groupContext; + + private GroupV1Properties(GroupContext groupContext) { + this.groupContext = groupContext; + } + + public @NonNull GroupContext getGroupContext() { + return groupContext; + } + + public boolean isQuit() { + return groupContext.getType().getNumber() == GroupContext.Type.QUIT_VALUE; + } + + public boolean isUpdate() { + return groupContext.getType().getNumber() == GroupContext.Type.UPDATE_VALUE; + } + + @Override + public @NonNull String getName() { + return groupContext.getName(); + } + + @Override + public @NonNull List getMembersListExcludingSelf() { + RecipientId selfId = Recipient.self().getId(); + + return Stream.of(groupContext.getMembersList()) + .map(GroupContext.Member::getE164) + .withoutNulls() + .map(e164 -> new SignalServiceAddress(null, e164)) + .map(RecipientId::from) + .filterNot(selfId::equals) + .toList(); + } + } + + public static class GroupV2Properties implements GroupProperties { + + private final DecryptedGroupV2Context decryptedGroupV2Context; + private final GroupContextV2 groupContext; + private final GroupMasterKey groupMasterKey; + + private GroupV2Properties(DecryptedGroupV2Context decryptedGroupV2Context) { + this.decryptedGroupV2Context = decryptedGroupV2Context; + this.groupContext = decryptedGroupV2Context.getContext(); + try { + groupMasterKey = new GroupMasterKey(groupContext.getMasterKey().toByteArray()); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } + + public @NonNull GroupContextV2 getGroupContext() { + return groupContext; + } + + public @NonNull GroupMasterKey getGroupMasterKey() { + return groupMasterKey; + } + + public @NonNull DecryptedGroupChange getChange() { + return decryptedGroupV2Context.getChange(); + } + + public @NonNull List getAllActivePendingAndRemovedMembers() { + LinkedList memberUuids = new LinkedList<>(); + DecryptedGroup groupState = decryptedGroupV2Context.getGroupState(); + DecryptedGroupChange groupChange = decryptedGroupV2Context.getChange(); + + memberUuids.addAll(DecryptedGroupUtil.membersToUuidList(groupState.getMembersList())); + memberUuids.addAll(DecryptedGroupUtil.pendingToUuidList(groupState.getPendingMembersList())); + + memberUuids.addAll(DecryptedGroupUtil.removedMembersUuidList(groupChange)); + memberUuids.addAll(DecryptedGroupUtil.removedPendingMembersUuidList(groupChange)); + memberUuids.addAll(DecryptedGroupUtil.removedRequestingMembersUuidList(groupChange)); + + return UuidUtil.filterKnown(memberUuids); + } + + @Override + public @NonNull String getName() { + return decryptedGroupV2Context.getGroupState().getTitle(); + } + + @Override + public @NonNull List getMembersListExcludingSelf() { + List members = new ArrayList<>(decryptedGroupV2Context.getGroupState().getMembersCount()); + + for (DecryptedMember member : decryptedGroupV2Context.getGroupState().getMembersList()) { + RecipientId recipient = RecipientId.from(UuidUtil.fromByteString(member.getUuid()), null); + if (!Recipient.self().getId().equals(recipient)) { + members.add(recipient); + } + } + + return members; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsConfigManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsConfigManager.java new file mode 100644 index 00000000..9cc320a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsConfigManager.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; +import android.content.res.Configuration; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.android.mms.service_alt.MmsConfig; + +import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat; +import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.HashMap; +import java.util.Map; + +final class MmsConfigManager { + + private static final Map mmsConfigMap = new HashMap<>(); + + @WorkerThread + synchronized static @NonNull MmsConfig getMmsConfig(Context context, int subscriptionId) { + MmsConfig mmsConfig = mmsConfigMap.get(subscriptionId); + if (mmsConfig != null) { + return mmsConfig; + } + + MmsConfig loadedConfig = loadMmsConfig(context, subscriptionId); + + mmsConfigMap.put(subscriptionId, loadedConfig); + + return loadedConfig; + } + + private static @NonNull MmsConfig loadMmsConfig(Context context, int subscriptionId) { + Optional subscriptionInfo = new SubscriptionManagerCompat(context).getActiveSubscriptionInfo(subscriptionId); + + if (subscriptionInfo.isPresent()) { + SubscriptionInfoCompat subscriptionInfoCompat = subscriptionInfo.get(); + Configuration configuration = context.getResources().getConfiguration(); + configuration.mcc = subscriptionInfoCompat.getMcc(); + configuration.mnc = subscriptionInfoCompat.getMnc(); + + Context subContext = context.createConfigurationContext(configuration); + return new MmsConfig(subContext, subscriptionId); + } + + return new MmsConfig(context, subscriptionId); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsException.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsException.java new file mode 100644 index 00000000..6aaa40b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsException.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mms; + +/** + * A generic exception that is thrown by the Mms client. + */ +public class MmsException extends Exception { + private static final long serialVersionUID = -7323249827281485390L; + + /** + * Creates a new MmsException. + */ + public MmsException() { + super(); + } + + /** + * Creates a new MmsException with the specified detail message. + * + * @param message the detail message. + */ + public MmsException(String message) { + super(message); + } + + /** + * Creates a new MmsException with the specified cause. + * + * @param cause the cause. + */ + public MmsException(Throwable cause) { + super(cause); + } + + /** + * Creates a new MmsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause. + */ + public MmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java new file mode 100644 index 00000000..7b3b3bb0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; + +import com.android.mms.service_alt.MmsConfig; + +final class MmsMediaConstraints extends MediaConstraints { + + private final int subscriptionId; + + private static final int MIN_IMAGE_DIMEN = 1024; + + MmsMediaConstraints(int subscriptionId) { + this.subscriptionId = subscriptionId; + } + + @Override + public int getImageMaxWidth(Context context) { + return Math.max(MIN_IMAGE_DIMEN, getOverriddenMmsConfig(context).getMaxImageWidth()); + } + + @Override + public int getImageMaxHeight(Context context) { + return Math.max(MIN_IMAGE_DIMEN, getOverriddenMmsConfig(context).getMaxImageHeight()); + } + + @Override + public int[] getImageDimensionTargets(Context context) { + int[] targets = new int[4]; + + targets[0] = getImageMaxHeight(context); + + for (int i = 1; i < targets.length; i++) { + targets[i] = targets[i - 1] / 2; + } + + return targets; + } + + @Override + public int getImageMaxSize(Context context) { + return getMaxMessageSize(context); + } + + @Override + public int getGifMaxSize(Context context) { + return getMaxMessageSize(context); + } + + @Override + public int getVideoMaxSize(Context context) { + return getMaxMessageSize(context); + } + + @Override + public int getUncompressedVideoMaxSize(Context context) { + return Math.max(getVideoMaxSize(context), 15 * 1024 * 1024); + } + + @Override + public int getAudioMaxSize(Context context) { + return getMaxMessageSize(context); + } + + @Override + public int getDocumentMaxSize(Context context) { + return getMaxMessageSize(context); + } + + private int getMaxMessageSize(Context context) { + return getOverriddenMmsConfig(context).getMaxMessageSize(); + } + + private MmsConfig.Overridden getOverriddenMmsConfig(Context context) { + MmsConfig mmsConfig = MmsConfigManager.getMmsConfig(context, subscriptionId); + + return new MmsConfig.Overridden(mmsConfig, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadio.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadio.java new file mode 100644 index 00000000..456b0600 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadio.java @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.PowerManager; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public class MmsRadio { + + private static final String TAG = MmsRadio.class.getSimpleName(); + + private static MmsRadio instance; + + public static synchronized MmsRadio getInstance(Context context) { + if (instance == null) + instance = new MmsRadio(context.getApplicationContext()); + + return instance; + } + + /// + + private static final String FEATURE_ENABLE_MMS = "enableMMS"; + private static final int APN_ALREADY_ACTIVE = 0; + public static final int TYPE_MOBILE_MMS = 2; + + private final Context context; + + private ConnectivityManager connectivityManager; + private ConnectivityListener connectivityListener; + private PowerManager.WakeLock wakeLock; + private int connectedCounter = 0; + + private MmsRadio(Context context) { + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + this.context = context; + this.connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + this.wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:mms"); + this.wakeLock.setReferenceCounted(true); + } + + public synchronized void disconnect() { + Log.i(TAG, "MMS Radio Disconnect Called..."); + wakeLock.release(); + connectedCounter--; + + Log.i(TAG, "Reference count: " + connectedCounter); + + if (connectedCounter == 0) { + Log.i(TAG, "Turning off MMS radio..."); + try { + final Method stopUsingNetworkFeatureMethod = connectivityManager.getClass().getMethod("stopUsingNetworkFeature", Integer.TYPE, String.class); + stopUsingNetworkFeatureMethod.invoke(connectivityManager, ConnectivityManager.TYPE_MOBILE, FEATURE_ENABLE_MMS); + } catch (NoSuchMethodException nsme) { + Log.w(TAG, nsme); + } catch (IllegalAccessException iae) { + Log.w(TAG, iae); + } catch (InvocationTargetException ite) { + Log.w(TAG, ite); + } + + if (connectivityListener != null) { + Log.i(TAG, "Unregistering receiver..."); + context.unregisterReceiver(connectivityListener); + connectivityListener = null; + } + } + } + + public synchronized void connect() throws MmsRadioException { + int status; + + try { + final Method startUsingNetworkFeatureMethod = connectivityManager.getClass().getMethod("startUsingNetworkFeature", Integer.TYPE, String.class); + status = (int)startUsingNetworkFeatureMethod.invoke(connectivityManager, ConnectivityManager.TYPE_MOBILE, FEATURE_ENABLE_MMS); + } catch (NoSuchMethodException nsme) { + throw new MmsRadioException(nsme); + } catch (IllegalAccessException iae) { + throw new MmsRadioException(iae); + } catch (InvocationTargetException ite) { + throw new MmsRadioException(ite); + } + + Log.i(TAG, "startUsingNetworkFeature status: " + status); + + if (status == APN_ALREADY_ACTIVE) { + wakeLock.acquire(); + connectedCounter++; + return; + } else { + wakeLock.acquire(); + connectedCounter++; + + if (connectivityListener == null) { + IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + connectivityListener = new ConnectivityListener(); + context.registerReceiver(connectivityListener, filter); + } + + Util.wait(this, 30000); + + if (!isConnected()) { + Log.w(TAG, "Got back from connectivity wait, and not connected..."); + disconnect(); + throw new MmsRadioException("Unable to successfully enable MMS radio."); + } + } + } + + private boolean isConnected() { + NetworkInfo info = connectivityManager.getNetworkInfo(TYPE_MOBILE_MMS); + + Log.i(TAG, "Connected: " + info); + + if ((info == null) || (info.getType() != TYPE_MOBILE_MMS) || !info.isConnected()) + return false; + + return true; + } + + private boolean isConnectivityPossible() { + NetworkInfo networkInfo = connectivityManager.getNetworkInfo(TYPE_MOBILE_MMS); + + return networkInfo != null && networkInfo.isAvailable(); + } + + private boolean isConnectivityFailure() { + NetworkInfo networkInfo = connectivityManager.getNetworkInfo(TYPE_MOBILE_MMS); + + return networkInfo == null || networkInfo.getDetailedState() == NetworkInfo.DetailedState.FAILED; + } + + private synchronized void issueConnectivityChange() { + if (isConnected()) { + Log.i(TAG, "Notifying connected..."); + notifyAll(); + return; + } + + if (!isConnected() && (isConnectivityFailure() || !isConnectivityPossible())) { + Log.i(TAG, "Notifying not connected..."); + notifyAll(); + return; + } + } + + private class ConnectivityListener extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Got connectivity change..."); + issueConnectivityChange(); + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadioException.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadioException.java new file mode 100644 index 00000000..dfac78de --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsRadioException.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.mms; + +public class MmsRadioException extends Throwable { + public MmsRadioException(String s) { + super(s); + } + + public MmsRadioException(Exception e) { + super(e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSendResult.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSendResult.java new file mode 100644 index 00000000..bd9ce36f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSendResult.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.mms; + +public class MmsSendResult { + + private final byte[] messageId; + private final int responseStatus; + + public MmsSendResult(byte[] messageId, int responseStatus) { + this.messageId = messageId; + this.responseStatus = responseStatus; + } + + public int getResponseStatus() { + return responseStatus; + } + + public byte[] getMessageId() { + return messageId; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java new file mode 100644 index 00000000..7eb51968 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; + +public class MmsSlide extends ImageSlide { + + public MmsSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + } + + @NonNull + @Override + public String getContentDescription() { + return "MMS"; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java new file mode 100644 index 00000000..1efad1d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.mms; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.LinkedList; + +public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage { + + public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) { + super(recipient, "", new LinkedList(), sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, false, null, Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + } + + @Override + public boolean isExpirationUpdate() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java new file mode 100644 index 00000000..3f72e8a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; + +import java.util.Collections; +import java.util.List; + +public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage { + + private final MessageGroupContext messageGroupContext; + + public OutgoingGroupUpdateMessage(@NonNull Recipient recipient, + @NonNull MessageGroupContext groupContext, + @NonNull List avatar, + long sentTimeMillis, + long expiresIn, + boolean viewOnce, + @Nullable QuoteModel quote, + @NonNull List contacts, + @NonNull List previews, + @NonNull List mentions) + { + super(recipient, groupContext.getEncodedGroupContext(), avatar, sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, viewOnce, quote, contacts, previews, mentions); + + this.messageGroupContext = groupContext; + } + + public OutgoingGroupUpdateMessage(@NonNull Recipient recipient, + @NonNull GroupContext group, + @Nullable final Attachment avatar, + long sentTimeMillis, + long expireIn, + boolean viewOnce, + @Nullable QuoteModel quote, + @NonNull List contacts, + @NonNull List previews, + @NonNull List mentions) + { + this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews, mentions); + } + + public OutgoingGroupUpdateMessage(@NonNull Recipient recipient, + @NonNull DecryptedGroupV2Context group, + @Nullable final Attachment avatar, + long sentTimeMillis, + long expireIn, + boolean viewOnce, + @Nullable QuoteModel quote, + @NonNull List contacts, + @NonNull List previews, + @NonNull List mentions) + { + this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews, mentions); + } + + @Override + public boolean isGroup() { + return true; + } + + public boolean isV2Group() { + return messageGroupContext.isV2Group(); + } + + public @NonNull MessageGroupContext.GroupV1Properties requireGroupV1Properties() { + return messageGroupContext.requireGroupV1Properties(); + } + + public @NonNull MessageGroupContext.GroupV2Properties requireGroupV2Properties() { + return messageGroupContext.requireGroupV2Properties(); + } + + private static List getAttachments(@Nullable Attachment avatar) { + return avatar == null ? Collections.emptyList() : Collections.singletonList(avatar); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLegacyMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLegacyMmsConnection.java new file mode 100644 index 00000000..39d8144a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLegacyMmsConnection.java @@ -0,0 +1,163 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.pdu_alt.PduParser; +import com.google.android.mms.pdu_alt.SendConf; + +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpPostHC4; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.ByteArrayEntityHC4; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; + +import java.io.IOException; + + +@SuppressWarnings("deprecation") +public class OutgoingLegacyMmsConnection extends LegacyMmsConnection implements OutgoingMmsConnection { + private final static String TAG = OutgoingLegacyMmsConnection.class.getSimpleName(); + + public OutgoingLegacyMmsConnection(Context context) throws ApnUnavailableException { + super(context); + } + + private HttpUriRequest constructRequest(byte[] pduBytes, boolean useProxy) + throws IOException + { + try { + HttpPostHC4 request = new HttpPostHC4(apn.getMmsc()); + for (Header header : getBaseHeaders()) { + request.addHeader(header); + } + + request.setEntity(new ByteArrayEntityHC4(pduBytes)); + if (useProxy) { + HttpHost proxy = new HttpHost(apn.getProxy(), apn.getPort()); + request.setConfig(RequestConfig.custom().setProxy(proxy).build()); + } + return request; + } catch (IllegalArgumentException iae) { + throw new IOException(iae); + } + } + + public void sendNotificationReceived(byte[] pduBytes, boolean usingMmsRadio, boolean useProxyIfAvailable) + throws IOException + { + sendBytes(pduBytes, usingMmsRadio, useProxyIfAvailable); + } + + @Override + public @Nullable SendConf send(@NonNull byte[] pduBytes, int subscriptionId) throws UndeliverableMessageException { + try { + MmsRadio radio = MmsRadio.getInstance(context); + + if (isDirectConnect()) { + Log.i(TAG, "Sending MMS directly without radio change..."); + try { + return send(pduBytes, false, false); + } catch (IOException e) { + Log.w(TAG, e); + } + } + + Log.i(TAG, "Sending MMS with radio change and proxy..."); + radio.connect(); + + try { + try { + return send(pduBytes, true, true); + } catch (IOException e) { + Log.w(TAG, e); + } + + Log.i(TAG, "Sending MMS with radio change and without proxy..."); + + try { + return send(pduBytes, true, false); + } catch (IOException ioe) { + Log.w(TAG, ioe); + throw new UndeliverableMessageException(ioe); + } + } finally { + radio.disconnect(); + } + + } catch (MmsRadioException e) { + Log.w(TAG, e); + throw new UndeliverableMessageException(e); + } + + } + + private SendConf send(byte[] pduBytes, boolean useMmsRadio, boolean useProxyIfAvailable) throws IOException { + byte[] response = sendBytes(pduBytes, useMmsRadio, useProxyIfAvailable); + return (SendConf) new PduParser(response).parse(); + } + + private byte[] sendBytes(byte[] pduBytes, boolean useMmsRadio, boolean useProxyIfAvailable) throws IOException { + final boolean useProxy = useProxyIfAvailable && apn.hasProxy(); + final String targetHost = useProxy + ? apn.getProxy() + : Uri.parse(apn.getMmsc()).getHost(); + + Log.i(TAG, "Sending MMS of length: " + pduBytes.length + + (useMmsRadio ? ", using mms radio" : "") + + (useProxy ? ", using proxy" : "")); + + try { + if (checkRouteToHost(context, targetHost, useMmsRadio)) { + Log.i(TAG, "got successful route to host " + targetHost); + byte[] response = execute(constructRequest(pduBytes, useProxy)); + if (response != null) return response; + } + } catch (IOException ioe) { + Log.w(TAG, ioe); + } + throw new IOException("Connection manager could not obtain route to host."); + } + + + public static boolean isConnectionPossible(Context context) { + try { + ConnectivityManager connectivityManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = connectivityManager.getNetworkInfo(MmsRadio.TYPE_MOBILE_MMS); + if (networkInfo == null) { + Log.w(TAG, "MMS network info was null, unsupported by this device"); + return false; + } + + getApn(context); + return true; + } catch (ApnUnavailableException e) { + Log.w(TAG, e); + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLollipopMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLollipopMmsConnection.java new file mode 100644 index 00000000..7735dfea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingLollipopMmsConnection.java @@ -0,0 +1,115 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.telephony.SmsManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.mms.service_alt.MmsConfig; +import com.google.android.mms.pdu_alt.PduParser; +import com.google.android.mms.pdu_alt.SendConf; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.providers.MmsBodyProvider; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.concurrent.TimeoutException; + +public class OutgoingLollipopMmsConnection extends LollipopMmsConnection implements OutgoingMmsConnection { + private static final String TAG = OutgoingLollipopMmsConnection.class.getSimpleName(); + private static final String ACTION = OutgoingLollipopMmsConnection.class.getCanonicalName() + "MMS_SENT_ACTION"; + + private byte[] response; + + public OutgoingLollipopMmsConnection(Context context) { + super(context, ACTION); + } + + @TargetApi(VERSION_CODES.LOLLIPOP_MR1) + @Override + public synchronized void onResult(Context context, Intent intent) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP_MR1) { + Log.i(TAG, "HTTP status: " + intent.getIntExtra(SmsManager.EXTRA_MMS_HTTP_STATUS, -1)); + } + + response = intent.getByteArrayExtra(SmsManager.EXTRA_MMS_DATA); + } + + @Override + @TargetApi(VERSION_CODES.LOLLIPOP) + public @Nullable synchronized SendConf send(@NonNull byte[] pduBytes, int subscriptionId) + throws UndeliverableMessageException + { + beginTransaction(); + try { + MmsBodyProvider.Pointer pointer = MmsBodyProvider.makeTemporaryPointer(getContext()); + StreamUtil.copy(new ByteArrayInputStream(pduBytes), pointer.getOutputStream()); + + SmsManager smsManager; + + if (VERSION.SDK_INT >= 22 && subscriptionId != -1) { + smsManager = SmsManager.getSmsManagerForSubscriptionId(subscriptionId); + } else { + smsManager = SmsManager.getDefault(); + } + + Bundle configOverrides = new Bundle(); + configOverrides.putBoolean(SmsManager.MMS_CONFIG_GROUP_MMS_ENABLED, true); + + MmsConfig mmsConfig = MmsConfigManager.getMmsConfig(getContext(), subscriptionId); + + if (mmsConfig != null) { + MmsConfig.Overridden overridden = new MmsConfig.Overridden(mmsConfig, new Bundle()); + configOverrides.putString(SmsManager.MMS_CONFIG_HTTP_PARAMS, overridden.getHttpParams()); + configOverrides.putInt(SmsManager.MMS_CONFIG_MAX_MESSAGE_SIZE, overridden.getMaxMessageSize()); + } + + smsManager.sendMultimediaMessage(getContext(), + pointer.getUri(), + null, + configOverrides, + getPendingIntent()); + + waitForResult(); + + Log.i(TAG, "MMS broadcast received and processed."); + pointer.close(); + + if (response == null) { + throw new UndeliverableMessageException("Null response."); + } + + return (SendConf) new PduParser(response).parse(); + } catch (IOException | TimeoutException e) { + throw new UndeliverableMessageException(e); + } finally { + endTransaction(); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java new file mode 100644 index 00000000..505fd503 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.mms; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.LinkedList; +import java.util.List; + +public class OutgoingMediaMessage { + + private final Recipient recipient; + protected final String body; + protected final List attachments; + private final long sentTimeMillis; + private final int distributionType; + private final int subscriptionId; + private final long expiresIn; + private final boolean viewOnce; + private final QuoteModel outgoingQuote; + + private final List networkFailures = new LinkedList<>(); + private final List identityKeyMismatches = new LinkedList<>(); + private final List contacts = new LinkedList<>(); + private final List linkPreviews = new LinkedList<>(); + private final List mentions = new LinkedList<>(); + + public OutgoingMediaMessage(Recipient recipient, String message, + List attachments, long sentTimeMillis, + int subscriptionId, long expiresIn, boolean viewOnce, + int distributionType, + @Nullable QuoteModel outgoingQuote, + @NonNull List contacts, + @NonNull List linkPreviews, + @NonNull List mentions, + @NonNull List networkFailures, + @NonNull List identityKeyMismatches) + { + this.recipient = recipient; + this.body = message; + this.sentTimeMillis = sentTimeMillis; + this.distributionType = distributionType; + this.attachments = attachments; + this.subscriptionId = subscriptionId; + this.expiresIn = expiresIn; + this.viewOnce = viewOnce; + this.outgoingQuote = outgoingQuote; + + this.contacts.addAll(contacts); + this.linkPreviews.addAll(linkPreviews); + this.mentions.addAll(mentions); + this.networkFailures.addAll(networkFailures); + this.identityKeyMismatches.addAll(identityKeyMismatches); + } + + public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message, + long sentTimeMillis, int subscriptionId, long expiresIn, + boolean viewOnce, int distributionType, + @Nullable QuoteModel outgoingQuote, + @NonNull List contacts, + @NonNull List linkPreviews, + @NonNull List mentions) + { + this(recipient, + buildMessage(slideDeck, message), + slideDeck.asAttachments(), + sentTimeMillis, subscriptionId, + expiresIn, viewOnce, distributionType, outgoingQuote, + contacts, linkPreviews, mentions, new LinkedList<>(), new LinkedList<>()); + } + + public OutgoingMediaMessage(OutgoingMediaMessage that) { + this.recipient = that.getRecipient(); + this.body = that.body; + this.distributionType = that.distributionType; + this.attachments = that.attachments; + this.sentTimeMillis = that.sentTimeMillis; + this.subscriptionId = that.subscriptionId; + this.expiresIn = that.expiresIn; + this.viewOnce = that.viewOnce; + this.outgoingQuote = that.outgoingQuote; + + this.identityKeyMismatches.addAll(that.identityKeyMismatches); + this.networkFailures.addAll(that.networkFailures); + this.contacts.addAll(that.contacts); + this.linkPreviews.addAll(that.linkPreviews); + this.mentions.addAll(that.mentions); + } + + public Recipient getRecipient() { + return recipient; + } + + public String getBody() { + return body; + } + + public List getAttachments() { + return attachments; + } + + public int getDistributionType() { + return distributionType; + } + + public boolean isSecure() { + return false; + } + + public boolean isGroup() { + return false; + } + + public boolean isExpirationUpdate() { + return false; + } + + public long getSentTimeMillis() { + return sentTimeMillis; + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public long getExpiresIn() { + return expiresIn; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public @Nullable QuoteModel getOutgoingQuote() { + return outgoingQuote; + } + + public @NonNull List getSharedContacts() { + return contacts; + } + + public @NonNull List getLinkPreviews() { + return linkPreviews; + } + + public @NonNull List getMentions() { + return mentions; + } + + public @NonNull List getNetworkFailures() { + return networkFailures; + } + + public @NonNull List getIdentityKeyMismatches() { + return identityKeyMismatches; + } + + private static String buildMessage(SlideDeck slideDeck, String message) { + if (!TextUtils.isEmpty(message) && !TextUtils.isEmpty(slideDeck.getBody())) { + return slideDeck.getBody() + "\n\n" + message; + } else if (!TextUtils.isEmpty(message)) { + return message; + } else { + return slideDeck.getBody(); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMmsConnection.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMmsConnection.java new file mode 100644 index 00000000..6f4b0cea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMmsConnection.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.mms.pdu_alt.SendConf; + +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; + + +public interface OutgoingMmsConnection { + @Nullable + SendConf send(@NonNull byte[] pduBytes, int subscriptionId) throws UndeliverableMessageException; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java new file mode 100644 index 00000000..4fd014cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.mms; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.List; + +public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { + + public OutgoingSecureMediaMessage(Recipient recipient, String body, + List attachments, + long sentTimeMillis, + int distributionType, + long expiresIn, + boolean viewOnce, + @Nullable QuoteModel quote, + @NonNull List contacts, + @NonNull List previews, + @NonNull List mentions) + { + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, Collections.emptyList(), Collections.emptyList()); + } + + public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { + super(base); + } + + @Override + public boolean isSecure() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java new file mode 100644 index 00000000..57f30568 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.ContentUris; +import android.content.Context; +import android.content.UriMatcher; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; +import org.thoughtcrime.securesms.providers.PartProvider; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; + +import java.io.IOException; +import java.io.InputStream; + +public class PartAuthority { + + private static final String AUTHORITY = BuildConfig.APPLICATION_ID; + private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part"; + private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker"; + private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper"; + private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); + private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); + private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING); + + private static final int PART_ROW = 1; + private static final int PERSISTENT_ROW = 2; + private static final int BLOB_ROW = 3; + private static final int STICKER_ROW = 4; + private static final int WALLPAPER_ROW = 5; + + private static final UriMatcher uriMatcher; + + static { + uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + uriMatcher.addURI(AUTHORITY, "part/*/#", PART_ROW); + uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW); + uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW); + uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW); + uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW); + uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW); + } + + public static InputStream getAttachmentThumbnailStream(@NonNull Context context, @NonNull Uri uri) + throws IOException + { + return getAttachmentStream(context, uri); + } + + public static InputStream getAttachmentStream(@NonNull Context context, @NonNull Uri uri) + throws IOException + { + int match = uriMatcher.match(uri); + try { + switch (match) { + case PART_ROW: return DatabaseFactory.getAttachmentDatabase(context).getAttachmentStream(new PartUriParser(uri).getPartId(), 0); + case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri)); + case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri)); + case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri); + case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri)); + default: return context.getContentResolver().openInputStream(uri); + } + } catch (SecurityException se) { + throw new IOException(se); + } + } + + public static @Nullable String getAttachmentFileName(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PART_ROW: + Attachment attachment = DatabaseFactory.getAttachmentDatabase(context).getAttachment(new PartUriParser(uri).getPartId()); + + if (attachment != null) return attachment.getFileName(); + else return null; + case PERSISTENT_ROW: + return DeprecatedPersistentBlobProvider.getFileName(context, uri); + case BLOB_ROW: + return BlobProvider.getFileName(uri); + default: + return null; + } + } + + public static @Nullable Long getAttachmentSize(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PART_ROW: + Attachment attachment = DatabaseFactory.getAttachmentDatabase(context).getAttachment(new PartUriParser(uri).getPartId()); + + if (attachment != null) return attachment.getSize(); + else return null; + case PERSISTENT_ROW: + return DeprecatedPersistentBlobProvider.getFileSize(context, uri); + case BLOB_ROW: + return BlobProvider.getFileSize(uri); + default: + return null; + } + } + + public static @Nullable String getAttachmentContentType(@NonNull Context context, @NonNull Uri uri) { + int match = uriMatcher.match(uri); + + switch (match) { + case PART_ROW: + Attachment attachment = DatabaseFactory.getAttachmentDatabase(context).getAttachment(new PartUriParser(uri).getPartId()); + + if (attachment != null) return attachment.getContentType(); + else return null; + case PERSISTENT_ROW: + return DeprecatedPersistentBlobProvider.getMimeType(context, uri); + case BLOB_ROW: + return BlobProvider.getMimeType(uri); + default: + return null; + } + } + + public static Uri getAttachmentPublicUri(Uri uri) { + PartUriParser partUri = new PartUriParser(uri); + return PartProvider.getContentUri(partUri.getPartId()); + } + + public static Uri getAttachmentDataUri(AttachmentId attachmentId) { + Uri uri = Uri.withAppendedPath(PART_CONTENT_URI, String.valueOf(attachmentId.getUniqueId())); + return ContentUris.withAppendedId(uri, attachmentId.getRowId()); + } + + public static Uri getAttachmentThumbnailUri(AttachmentId attachmentId) { + return getAttachmentDataUri(attachmentId); + } + + public static Uri getStickerUri(long id) { + return ContentUris.withAppendedId(STICKER_CONTENT_URI, id); + } + + public static Uri getWallpaperUri(String filename) { + return Uri.withAppendedPath(WALLPAPER_CONTENT_URI, filename); + } + + public static String getWallpaperFilename(Uri uri) { + return uri.getPathSegments().get(1); + } + + public static boolean isLocalUri(final @NonNull Uri uri) { + int match = uriMatcher.match(uri); + switch (match) { + case PART_ROW: + case PERSISTENT_ROW: + case BLOB_ROW: + return true; + } + return false; + } + + public static boolean isAttachmentUri(@NonNull Uri uri) { + return uriMatcher.match(uri) == PART_ROW; + } + + public static @NonNull AttachmentId requireAttachmentId(@NonNull Uri uri) { + return new PartUriParser(uri).getPartId(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartParser.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartParser.java new file mode 100644 index 00000000..e8f3563d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartParser.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.mms; + +import com.google.android.mms.ContentType; +import com.google.android.mms.pdu_alt.CharacterSets; +import com.google.android.mms.pdu_alt.PduBody; +import com.google.android.mms.pdu_alt.PduPart; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.io.UnsupportedEncodingException; +import java.util.Arrays; +import java.util.List; + + +public class PartParser { + + private static final String TAG = Log.tag(PartParser.class); + private static final List DOCUMENT_TYPES = Arrays.asList("text/vcard", "text/x-vcard"); + + public static String getMessageText(PduBody body) { + String bodyText = null; + + for (int i=0;i attachments; + private final List mentions; + + public QuoteModel(long id, @NonNull RecipientId author, String text, boolean missing, @Nullable List attachments, @Nullable List mentions) { + this.id = id; + this.author = author; + this.text = text; + this.missing = missing; + this.attachments = attachments; + this.mentions = mentions != null ? mentions : Collections.emptyList(); + } + + public long getId() { + return id; + } + + public RecipientId getAuthor() { + return author; + } + + public String getText() { + return text; + } + + public boolean isOriginalMissing() { + return missing; + } + + public List getAttachments() { + return attachments; + } + + public @NonNull List getMentions() { + return mentions; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java new file mode 100644 index 00000000..16872a62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.load.engine.cache.DiskCache; +import com.bumptech.glide.load.engine.cache.DiskCacheAdapter; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.UnitModelLoader; +import com.bumptech.glide.load.resource.bitmap.Downsampler; +import com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder; +import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.load.resource.gif.StreamGifDecoder; +import com.bumptech.glide.module.AppGlideModule; + +import org.signal.glide.apng.decode.APNGDecoder; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.blurhash.BlurHashModelLoader; +import org.thoughtcrime.securesms.blurhash.BlurHashResourceDecoder; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.glide.ChunkedImageUrlLoader; +import org.thoughtcrime.securesms.glide.ContactPhotoLoader; +import org.thoughtcrime.securesms.glide.OkHttpUrlLoader; +import org.thoughtcrime.securesms.glide.cache.ApngBufferCacheDecoder; +import org.thoughtcrime.securesms.glide.cache.ApngFrameDrawableTranscoder; +import org.thoughtcrime.securesms.glide.cache.ApngStreamCacheDecoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedApngCacheEncoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedCacheDecoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedGifDrawableResourceEncoder; +import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.stickers.StickerRemoteUri; +import org.thoughtcrime.securesms.stickers.StickerRemoteUriLoader; +import org.thoughtcrime.securesms.util.ConversationShortcutPhoto; + +import java.io.File; +import java.io.InputStream; +import java.nio.ByteBuffer; + +@GlideModule +public class SignalGlideModule extends AppGlideModule { + + @Override + public boolean isManifestParsingEnabled() { + return false; + } + + @Override + public void applyOptions(Context context, GlideBuilder builder) { + builder.setLogLevel(Log.ERROR); + } + + @Override + public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + byte[] secret = attachmentSecret.getModernKey(); + + registry.prepend(File.class, File.class, UnitModelLoader.Factory.getInstance()); + + registry.prepend(InputStream.class, new EncryptedCacheEncoder(secret, glide.getArrayPool())); + + registry.prepend(Bitmap.class, new EncryptedBitmapResourceEncoder(secret)); + registry.prepend(File.class, Bitmap.class, new EncryptedCacheDecoder<>(secret, new StreamBitmapDecoder(new Downsampler(registry.getImageHeaderParsers(), context.getResources().getDisplayMetrics(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool()))); + + registry.prepend(GifDrawable.class, new EncryptedGifDrawableResourceEncoder(secret)); + registry.prepend(File.class, GifDrawable.class, new EncryptedCacheDecoder<>(secret, new StreamGifDecoder(registry.getImageHeaderParsers(), new ByteBufferGifDecoder(context, registry.getImageHeaderParsers(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool()))); + + ApngBufferCacheDecoder apngBufferCacheDecoder = new ApngBufferCacheDecoder(); + ApngStreamCacheDecoder apngStreamCacheDecoder = new ApngStreamCacheDecoder(apngBufferCacheDecoder); + + registry.prepend(InputStream.class, APNGDecoder.class, apngStreamCacheDecoder); + registry.prepend(ByteBuffer.class, APNGDecoder.class, apngBufferCacheDecoder); + registry.prepend(APNGDecoder.class, new EncryptedApngCacheEncoder(secret)); + registry.prepend(File.class, APNGDecoder.class, new EncryptedCacheDecoder<>(secret, apngStreamCacheDecoder)); + registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder()); + + registry.prepend(BlurHash.class, Bitmap.class, new BlurHashResourceDecoder()); + + registry.append(ConversationShortcutPhoto.class, Bitmap.class, new ConversationShortcutPhoto.Loader.Factory(context)); + registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context)); + registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); + registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); + registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); + registry.append(StickerRemoteUri.class, InputStream.class, new StickerRemoteUriLoader.Factory()); + registry.append(BlurHash.class, BlurHash.class, new BlurHashModelLoader.Factory()); + registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); + } + + public static class NoopDiskCacheFactory implements DiskCache.Factory { + @Override + public DiskCache build() { + return new DiskCacheAdapter(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java new file mode 100644 index 00000000..47904d83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -0,0 +1,254 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.audio.AudioHash; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.security.SecureRandom; + +public abstract class Slide { + + protected final Attachment attachment; + protected final Context context; + + public Slide(@NonNull Context context, @NonNull Attachment attachment) { + this.context = context; + this.attachment = attachment; + } + + public String getContentType() { + return attachment.getContentType(); + } + + @Nullable + public Uri getUri() { + return attachment.getUri(); + } + + @NonNull + public Optional getBody() { + return Optional.absent(); + } + + @NonNull + public Optional getCaption() { + return Optional.fromNullable(attachment.getCaption()); + } + + @NonNull + public Optional getFileName() { + return Optional.fromNullable(attachment.getFileName()); + } + + @Nullable + public String getFastPreflightId() { + return attachment.getFastPreflightId(); + } + + public long getFileSize() { + return attachment.getSize(); + } + + public boolean hasImage() { + return false; + } + + public boolean hasSticker() { return false; } + + public boolean hasVideo() { + return false; + } + + public boolean hasAudio() { + return false; + } + + public boolean hasDocument() { + return false; + } + + public boolean hasLocation() { + return false; + } + + public boolean hasViewOnce() { + return false; + } + + public boolean isBorderless() { + return false; + } + + public @NonNull String getContentDescription() { return ""; } + + public @NonNull Attachment asAttachment() { + return attachment; + } + + public boolean isInProgress() { + return attachment.isInProgress(); + } + + public boolean isPendingDownload() { + return getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_FAILED || + getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING; + } + + public int getTransferState() { + return attachment.getTransferState(); + } + + public @DrawableRes int getPlaceholderRes(Theme theme) { + throw new AssertionError("getPlaceholderRes() called for non-drawable slide"); + } + + public @Nullable BlurHash getPlaceholderBlur() { + return attachment.getBlurHash(); + } + + public boolean hasPlaceholder() { + return false; + } + + public boolean hasPlayOverlay() { + return false; + } + + protected static Attachment constructAttachmentFromUri(@NonNull Context context, + @NonNull Uri uri, + @NonNull String defaultMime, + long size, + int width, + int height, + boolean hasThumbnail, + @Nullable String fileName, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + boolean voiceNote, + boolean borderless, + boolean quote) + { + return constructAttachmentFromUri(context, uri, defaultMime, size, width, height, hasThumbnail, fileName, caption, stickerLocator, blurHash, audioHash, voiceNote, borderless, quote, null); + } + + protected static Attachment constructAttachmentFromUri(@NonNull Context context, + @NonNull Uri uri, + @NonNull String defaultMime, + long size, + int width, + int height, + boolean hasThumbnail, + @Nullable String fileName, + @Nullable String caption, + @Nullable StickerLocator stickerLocator, + @Nullable BlurHash blurHash, + @Nullable AudioHash audioHash, + boolean voiceNote, + boolean borderless, + boolean quote, + @Nullable AttachmentDatabase.TransformProperties transformProperties) + { + String resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime); + String fastPreflightId = String.valueOf(new SecureRandom().nextLong()); + return new UriAttachment(uri, + resolvedType, + AttachmentDatabase.TRANSFER_PROGRESS_STARTED, + size, + width, + height, + fileName, + fastPreflightId, + voiceNote, + borderless, + quote, + caption, + stickerLocator, + blurHash, + audioHash, + transformProperties); + } + + public @NonNull Optional getFileType(@NonNull Context context) { + Optional fileName = getFileName(); + + if (fileName.isPresent()) { + String fileType = getFileType(fileName); + if (!fileType.isEmpty()) { + return Optional.of(fileType); + } + } + + return Optional.fromNullable(MediaUtil.getExtension(context, getUri())); + } + + private static @NonNull String getFileType(Optional fileName) { + if (!fileName.isPresent()) return ""; + + String[] parts = fileName.get().split("\\."); + + if (parts.length < 2) { + return ""; + } + + String suffix = parts[parts.length - 1]; + + if (suffix.length() <= 3) { + return suffix; + } + + return ""; + } + + @Override + public boolean equals(Object other) { + if (other == null) return false; + if (!(other instanceof Slide)) return false; + + Slide that = (Slide)other; + + return Util.equals(this.getContentType(), that.getContentType()) && + this.hasAudio() == that.hasAudio() && + this.hasImage() == that.hasImage() && + this.hasVideo() == that.hasVideo() && + this.getTransferState() == that.getTransferState() && + Util.equals(this.getUri(), that.getUri()); + } + + @Override + public int hashCode() { + return Util.hashCode(getContentType(), hasAudio(), hasImage(), + hasVideo(), getUri(), getTransferState()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java new file mode 100644 index 00000000..f7d1345c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideClickListener.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.mms; + +import android.view.View; + +public interface SlideClickListener { + void onClick(View v, Slide slide); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java new file mode 100644 index 00000000..bb70fedd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -0,0 +1,161 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.LinkedList; +import java.util.List; + +public class SlideDeck { + + private final List slides = new LinkedList<>(); + + public SlideDeck(@NonNull Context context, @NonNull List attachments) { + for (Attachment attachment : attachments) { + Slide slide = MediaUtil.getSlideForAttachment(context, attachment); + if (slide != null) slides.add(slide); + } + } + + public SlideDeck(@NonNull Context context, @NonNull Attachment attachment) { + Slide slide = MediaUtil.getSlideForAttachment(context, attachment); + if (slide != null) slides.add(slide); + } + + public SlideDeck() { + } + + public void clear() { + slides.clear(); + } + + @NonNull + public String getBody() { + String body = ""; + + for (Slide slide : slides) { + Optional slideBody = slide.getBody(); + + if (slideBody.isPresent()) { + body = slideBody.get(); + } + } + + return body; + } + + @NonNull + public List asAttachments() { + List attachments = new LinkedList<>(); + + for (Slide slide : slides) { + attachments.add(slide.asAttachment()); + } + + return attachments; + } + + public void addSlide(Slide slide) { + slides.add(slide); + } + + public List getSlides() { + return slides; + } + + public boolean containsMediaSlide() { + for (Slide slide : slides) { + if (slide.hasImage() || slide.hasVideo() || slide.hasAudio() || slide.hasDocument() || slide.hasSticker() || slide.hasViewOnce()) { + return true; + } + } + return false; + } + + public @Nullable Slide getThumbnailSlide() { + for (Slide slide : slides) { + if (slide.hasImage()) { + return slide; + } + } + + return null; + } + + public @NonNull List getThumbnailSlides() { + return Stream.of(slides).filter(Slide::hasImage).toList(); + } + + public @Nullable AudioSlide getAudioSlide() { + for (Slide slide : slides) { + if (slide.hasAudio()) { + return (AudioSlide)slide; + } + } + + return null; + } + + public @Nullable DocumentSlide getDocumentSlide() { + for (Slide slide: slides) { + if (slide.hasDocument()) { + return (DocumentSlide)slide; + } + } + + return null; + } + + public @Nullable TextSlide getTextSlide() { + for (Slide slide: slides) { + if (MediaUtil.isLongTextType(slide.getContentType())) { + return (TextSlide)slide; + } + } + + return null; + } + + public @Nullable StickerSlide getStickerSlide() { + for (Slide slide: slides) { + if (slide.hasSticker()) { + return (StickerSlide)slide; + } + } + + return null; + } + + public @Nullable String getFirstSlideContentType() { + if (Util.hasItems(slides)) { + return slides.get(0).getContentType(); + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java new file mode 100644 index 00000000..423e6fb2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; + +/** + * SlideFactory encapsulates logic related to constructing slides from a set of paramaeters as defined + * by {@link SlideFactory#getSlide}. + */ +public final class SlideFactory { + + private static final String TAG = Log.tag(SlideFactory.class); + + private SlideFactory() { + } + + /** + * Generates a slide from the given parameters. + * + * @param context Application context + * @param contentType The contentType of the given Uri + * @param uri The Uri pointing to the resource to create a slide out of + * @param width (Optional) width, can be 0. + * @param height (Optional) height, can be 0. + * + * @return A Slide with all the information we can gather about it. + */ + @WorkerThread + public static @Nullable Slide getSlide(@NonNull Context context, @Nullable String contentType, @NonNull Uri uri, int width, int height) { + MediaType mediaType = MediaType.from(contentType); + + try { + if (PartAuthority.isLocalUri(uri)) { + return getManuallyCalculatedSlideInfo(context, mediaType, uri, width, height); + } else { + Slide result = getContentResolverSlideInfo(context, mediaType, uri, width, height); + + if (result == null) return getManuallyCalculatedSlideInfo(context, mediaType, uri, width, height); + else return result; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private static @Nullable Slide getContentResolverSlideInfo(@NonNull Context context, @NonNull MediaType mediaType, @NonNull Uri uri, int width, int height) { + Cursor cursor = null; + long start = System.currentTimeMillis(); + + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + String mimeType = context.getContentResolver().getType(uri); + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, fileSize, width, height); + } + } finally { + if (cursor != null) cursor.close(); + } + + return null; + } + + private static @NonNull Slide getManuallyCalculatedSlideInfo(@NonNull Context context, @NonNull MediaType mediaType, @NonNull Uri uri, int width, int height) throws IOException { + long start = System.currentTimeMillis(); + Long mediaSize = null; + String fileName = null; + String mimeType = null; + + if (PartAuthority.isLocalUri(uri)) { + mediaSize = PartAuthority.getAttachmentSize(context, uri); + fileName = PartAuthority.getAttachmentFileName(context, uri); + mimeType = PartAuthority.getAttachmentContentType(context, uri); + } + + if (mediaSize == null) { + mediaSize = MediaUtil.getMediaSize(context, uri); + } + + if (mimeType == null) { + mimeType = MediaUtil.getMimeType(context, uri); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, mediaSize, width, height); + } + + public enum MediaType { + + IMAGE(MediaUtil.IMAGE_JPEG), + GIF(MediaUtil.IMAGE_GIF), + AUDIO(MediaUtil.AUDIO_AAC), + VIDEO(MediaUtil.VIDEO_MP4), + DOCUMENT(MediaUtil.UNKNOWN), + VCARD(MediaUtil.VCARD); + + private final String fallbackMimeType; + + MediaType(String fallbackMimeType) { + this.fallbackMimeType = fallbackMimeType; + } + + + public @NonNull Slide createSlide(@NonNull Context context, + @NonNull Uri uri, + @Nullable String fileName, + @Nullable String mimeType, + @Nullable BlurHash blurHash, + long dataSize, + int width, + int height) + { + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + + switch (this) { + case IMAGE: return new ImageSlide(context, uri, dataSize, width, height, blurHash); + case GIF: return new GifSlide(context, uri, dataSize, width, height); + case AUDIO: return new AudioSlide(context, uri, dataSize, false); + case VIDEO: return new VideoSlide(context, uri, dataSize); + case VCARD: + case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); + default: throw new AssertionError("unrecognized enum"); + } + } + + public static @Nullable MediaType from(final @Nullable String mimeType) { + if (TextUtils.isEmpty(mimeType)) return null; + if (MediaUtil.isGif(mimeType)) return GIF; + if (MediaUtil.isImageType(mimeType)) return IMAGE; + if (MediaUtil.isAudioType(mimeType)) return AUDIO; + if (MediaUtil.isVideoType(mimeType)) return VIDEO; + if (MediaUtil.isVcard(mimeType)) return VCARD; + + return DOCUMENT; + } + + public String toFallbackMimeType() { + return fallbackMimeType; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlidesClickedListener.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlidesClickedListener.java new file mode 100644 index 00000000..9e6914d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlidesClickedListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.mms; + +import android.view.View; + +import java.util.List; + +public interface SlidesClickedListener { + void onClick(View v, List slides); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java new file mode 100644 index 00000000..ded0f36a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.stickers.StickerLocator; + +import java.util.Objects; + +public class StickerSlide extends Slide { + + public static final int WIDTH = 512; + public static final int HEIGHT = 512; + + private final StickerLocator stickerLocator; + + public StickerSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + this.stickerLocator = Objects.requireNonNull(attachment.getSticker()); + } + + public StickerSlide(Context context, Uri uri, long size, @NonNull StickerLocator stickerLocator, @NonNull String contentType) { + super(context, constructAttachmentFromUri(context, uri, contentType, size, WIDTH, HEIGHT, true, null, null, stickerLocator, null, null, false, false, false)); + this.stickerLocator = Objects.requireNonNull(attachment.getSticker()); + } + + @Override + public @DrawableRes int getPlaceholderRes(Theme theme) { + return 0; + } + + @Override + public boolean hasSticker() { + return true; + } + + @Override + public boolean isBorderless() { + return true; + } + + @Override + public @NonNull String getContentDescription() { + return context.getString(R.string.Slide_sticker); + } + + public @Nullable String getEmoji() { + return stickerLocator.getEmoji(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java new file mode 100644 index 00000000..01626ad5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class TextSlide extends Slide { + + public TextSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + } + + public TextSlide(@NonNull Context context, @NonNull Uri uri, @Nullable String filename, long size) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.LONG_TEXT, size, 0, 0, true, filename, null, null, null, null, false, false, false)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java new file mode 100644 index 00000000..9a839237 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -0,0 +1,79 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.util.MediaUtil; + +public class VideoSlide extends Slide { + + public VideoSlide(Context context, Uri uri, long dataSize) { + this(context, uri, dataSize, null, null); + } + + public VideoSlide(Context context, Uri uri, long dataSize, @Nullable String caption, @Nullable AttachmentDatabase.TransformProperties transformProperties) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, false, transformProperties)); + } + + public VideoSlide(Context context, Uri uri, long dataSize, int width, int height, @Nullable String caption, @Nullable AttachmentDatabase.TransformProperties transformProperties) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, width, height, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, false, transformProperties)); + } + + public VideoSlide(Context context, Attachment attachment) { + super(context, attachment); + } + + @Override + public boolean hasPlaceholder() { + return true; + } + + @Override + public boolean hasPlayOverlay() { + return true; + } + + @Override + public @DrawableRes int getPlaceholderRes(Theme theme) { + return R.drawable.ic_video; + } + + @Override + public boolean hasImage() { + return true; + } + + @Override + public boolean hasVideo() { + return true; + } + + @NonNull @Override + public String getContentDescription() { + return context.getString(R.string.Slide_video); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java new file mode 100644 index 00000000..ec555a89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.mms; + + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.util.MediaUtil; + +/** + * Slide used for attachments with contentType {@link MediaUtil#VIEW_ONCE}. + * Attachments will only get this type *after* they've been viewed, or if they were synced from a + * linked device. Incoming unviewed messages will have the appropriate image/video contentType. + */ +public class ViewOnceSlide extends Slide { + + public ViewOnceSlide(@NonNull Context context, @NonNull Attachment attachment) { + super(context, attachment); + } + + @Override + public boolean hasViewOnce() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java b/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java new file mode 100644 index 00000000..f465890b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.net; + +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.InputStream; + +import okhttp3.Call; + +public class CallRequestController implements RequestController { + + private final Call call; + + private InputStream stream; + private boolean canceled; + + public CallRequestController(@NonNull Call call) { + this.call = call; + } + + @Override + public void cancel() { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + synchronized (CallRequestController.this) { + if (canceled) return; + + call.cancel(); + canceled = true; + } + }); + } + + public synchronized void setStream(@NonNull InputStream stream) { + this.stream = stream; + notifyAll(); + } + + /** + * Blocks until the stream is available or until the request is canceled. + */ + @WorkerThread + public synchronized Optional getStream() { + while(stream == null && !canceled) { + Util.wait(this, 0); + } + + return Optional.fromNullable(this.stream); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java new file mode 100644 index 00000000..e0addf44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java @@ -0,0 +1,405 @@ +package org.thoughtcrime.securesms.net; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.bumptech.glide.util.ContentLengthInputStream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import okhttp3.CacheControl; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class ChunkedDataFetcher { + + private static final String TAG = ChunkedDataFetcher.class.getSimpleName(); + + private static final CacheControl NO_CACHE = new CacheControl.Builder().noCache().build(); + + private static final long MB = 1024 * 1024; + private static final long KB = 1024; + + private final OkHttpClient client; + + public ChunkedDataFetcher(@NonNull OkHttpClient client) { + this.client = client; + } + + public RequestController fetch(@NonNull String url, long contentLength, @NonNull Callback callback) { + if (contentLength <= 0) { + return fetchChunksWithUnknownTotalSize(url, callback); + } + + CompositeRequestController compositeController = new CompositeRequestController(); + fetchChunks(url, contentLength, Optional.absent(), compositeController, callback); + return compositeController; + } + + private RequestController fetchChunksWithUnknownTotalSize(@NonNull String url, @NonNull Callback callback) { + CompositeRequestController compositeController = new CompositeRequestController(); + + long chunkSize = new SecureRandom().nextInt(1024) + 1024; + Request request = new Request.Builder() + .url(url) + .cacheControl(NO_CACHE) + .addHeader("Range", "bytes=0-" + (chunkSize - 1)) + .addHeader("Accept-Encoding", "identity") + .build(); + + Call firstChunkCall = client.newCall(request); + compositeController.addController(new CallRequestController(firstChunkCall)); + + firstChunkCall.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + if (!compositeController.isCanceled()) { + callback.onFailure(e); + compositeController.cancel(); + } + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + String contentRange = response.header("Content-Range"); + + if (!response.isSuccessful()) { + Log.w(TAG, "Non-successful response code: " + response.code()); + callback.onFailure(new IOException("Non-successful response code: " + response.code())); + compositeController.cancel(); + if (response.body() != null) response.body().close(); + return; + } + + if (TextUtils.isEmpty(contentRange)) { + Log.w(TAG, "Missing Content-Range header."); + callback.onFailure(new IOException("Missing Content-Length header.")); + compositeController.cancel(); + if (response.body() != null) response.body().close(); + return; + } + + if (response.body() == null) { + Log.w(TAG, "Missing body."); + callback.onFailure(new IOException("Missing body on initial request.")); + compositeController.cancel(); + return; + } + + Optional contentLength = parseLengthFromContentRange(contentRange); + + if (!contentLength.isPresent()) { + Log.w(TAG, "Unable to parse length from Content-Range."); + callback.onFailure(new IOException("Unable to get parse length from Content-Range.")); + compositeController.cancel(); + return; + } + + if (chunkSize >= contentLength.get()) { + try { + callback.onSuccess(response.body().byteStream()); + } catch (IOException e) { + callback.onFailure(e); + compositeController.cancel(); + } + } else { + InputStream stream = ContentLengthInputStream.obtain(response.body().byteStream(), chunkSize); + fetchChunks(url, contentLength.get(), Optional.of(new Pair<>(stream, chunkSize)), compositeController, callback); + } + } + }); + + return compositeController; + } + + private void fetchChunks(@NonNull String url, + long contentLength, + Optional> firstChunk, + CompositeRequestController compositeController, + Callback callback) + { + List requestPattern; + try { + if (firstChunk.isPresent()) { + requestPattern = Stream.of(getRequestPattern(contentLength - firstChunk.get().second())) + .map(b -> new ByteRange(b.start + firstChunk.get().second(), + b.end + firstChunk.get().second(), + b.ignoreFirst)) + .toList(); + } else { + requestPattern = getRequestPattern(contentLength); + } + } catch (IOException e) { + callback.onFailure(e); + compositeController.cancel(); + return; + } + + SignalExecutors.UNBOUNDED.execute(() -> { + List controllers = Stream.of(requestPattern).map(range -> makeChunkRequest(client, url, range)).toList(); + List streams = new ArrayList<>(controllers.size() + (firstChunk.isPresent() ? 1 : 0)); + + if (firstChunk.isPresent()) { + streams.add(firstChunk.get().first()); + } + + Stream.of(controllers).forEach(compositeController::addController); + + for (CallRequestController controller : controllers) { + Optional stream = controller.getStream(); + + if (!stream.isPresent()) { + Log.w(TAG, "Stream was canceled."); + callback.onFailure(new IOException("Failure")); + compositeController.cancel(); + return; + } + + streams.add(stream.get()); + } + + try { + callback.onSuccess(new InputStreamList(streams)); + } catch (IOException e) { + callback.onFailure(e); + compositeController.cancel(); + } + }); + } + + private CallRequestController makeChunkRequest(@NonNull OkHttpClient client, @NonNull String url, @NonNull ByteRange range) { + Request request = new Request.Builder() + .url(url) + .cacheControl(NO_CACHE) + .addHeader("Range", "bytes=" + range.start + "-" + range.end) + .addHeader("Accept-Encoding", "identity") + .build(); + + Call call = client.newCall(request); + CallRequestController callController = new CallRequestController(call); + + call.enqueue(new okhttp3.Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + callController.cancel(); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (!response.isSuccessful()) { + callController.cancel(); + if (response.body() != null) response.body().close(); + return; + } + + if (response.body() == null) { + callController.cancel(); + if (response.body() != null) response.body().close(); + return; + } + + InputStream stream = new SkippingInputStream(ContentLengthInputStream.obtain(response.body().byteStream(), response.body().contentLength()), range.ignoreFirst); + callController.setStream(stream); + } + }); + + return callController; + } + + private Optional parseLengthFromContentRange(@NonNull String contentRange) { + int totalStartPos = contentRange.indexOf('/'); + + if (totalStartPos >= 0 && contentRange.length() > totalStartPos + 1) { + String totalString = contentRange.substring(totalStartPos + 1); + + try { + return Optional.of(Long.parseLong(totalString)); + } catch (NumberFormatException e) { + return Optional.absent(); + } + } + + return Optional.absent(); + } + + private List getRequestPattern(long size) throws IOException { + if (size > MB) return getRequestPattern(size, MB); + else if (size > 500 * KB) return getRequestPattern(size, 500 * KB); + else if (size > 100 * KB) return getRequestPattern(size, 100 * KB); + else if (size > 50 * KB) return getRequestPattern(size, 50 * KB); + else if (size > 10 * KB) return getRequestPattern(size, 10 * KB); + else if (size > KB) return getRequestPattern(size, KB); + + throw new IOException("Unsupported size: " + size); + } + + private List getRequestPattern(long size, long increment) { + List results = new LinkedList<>(); + + long offset = 0; + + while (size - offset > increment) { + results.add(new ByteRange(offset, offset + increment - 1, 0)); + offset += increment; + } + + if (size - offset > 0) { + results.add(new ByteRange(size - increment, size-1, increment - (size - offset))); + } + + return results; + } + + private static class ByteRange { + private final long start; + private final long end; + private final long ignoreFirst; + + private ByteRange(long start, long end, long ignoreFirst) { + this.start = start; + this.end = end; + this.ignoreFirst = ignoreFirst; + } + } + + private static class SkippingInputStream extends FilterInputStream { + + private long skip; + + SkippingInputStream(InputStream in, long skip) { + super(in); + this.skip = skip; + } + + @Override + public int read() throws IOException { + if (skip != 0) { + skipFully(skip); + skip = 0; + } + + return super.read(); + } + + @Override + public int read(@NonNull byte[] buffer) throws IOException { + if (skip != 0) { + skipFully(skip); + skip = 0; + } + + return super.read(buffer); + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws IOException { + if (skip != 0) { + skipFully(skip); + skip = 0; + } + + return super.read(buffer, offset, length); + } + + @Override + public int available() throws IOException { + return Util.toIntExact(super.available() - skip); + } + + private void skipFully(long amount) throws IOException { + byte[] buffer = new byte[4096]; + + while (amount > 0) { + int read = super.read(buffer, 0, Math.min(buffer.length, Util.toIntExact(amount))); + + if (read != -1) amount -= read; + else return; + } + } + } + + private static class InputStreamList extends InputStream { + + private final List inputStreams; + + private int currentStreamIndex = 0; + + InputStreamList(List inputStreams) { + this.inputStreams = inputStreams; + } + + @Override + public int read() throws IOException { + while (currentStreamIndex < inputStreams.size()) { + int result = inputStreams.get(currentStreamIndex).read(); + + if (result == -1) currentStreamIndex++; + else return result; + } + + return -1; + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws IOException { + while (currentStreamIndex < inputStreams.size()) { + int result = inputStreams.get(currentStreamIndex).read(buffer, offset, length); + + if (result == -1) currentStreamIndex++; + else return result; + } + + return -1; + } + + @Override + public int read(@NonNull byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public void close() throws IOException { + for (InputStream stream : inputStreams) { + try { + stream.close(); + } catch (IOException ignored) {} + } + } + + @Override + public int available() { + int total = 0; + + for (int i=currentStreamIndex;i controllers = new ArrayList<>(); + private boolean canceled = false; + + public synchronized void addController(@NonNull RequestController controller) { + if (canceled) { + controller.cancel(); + } else { + controllers.add(controller); + } + } + + @Override + public synchronized void cancel() { + canceled = true; + Stream.of(controllers).forEach(RequestController::cancel); + } + + public synchronized boolean isCanceled() { + return canceled; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java new file mode 100644 index 00000000..c59d8632 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySafetyInterceptor.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +import java.io.IOException; + +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.Response; + +/** + * Interceptor to do extra safety checks on requests through the {@link ContentProxySelector} + * to prevent non-whitelisted requests from getting to it. In particular, this guards against + * requests redirecting to non-whitelisted domains. + * + * Note that because of the way interceptors are ordered, OkHttp will hit the proxy with the + * bad-redirected-domain before we can intercept the request, so we have to "look ahead" by + * detecting a redirected response on the first pass. + */ +public class ContentProxySafetyInterceptor implements Interceptor { + + private static final String TAG = Log.tag(ContentProxySafetyInterceptor.class); + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + if (isWhitelisted(chain.request().url())) { + Response response = chain.proceed(chain.request()); + + if (response.isRedirect()) { + if (isWhitelisted(response.header("Location"))) { + return response; + } else { + Log.w(TAG, "Tried to redirect to a non-whitelisted domain!"); + chain.call().cancel(); + throw new IOException("Tried to redirect to a non-whitelisted domain!"); + } + } else { + return response; + } + } else { + Log.w(TAG, "Request was for a non-whitelisted domain!"); + chain.call().cancel(); + throw new IOException("Request was for a non-whitelisted domain!"); + } + } + + private static boolean isWhitelisted(@NonNull HttpUrl url) { + return isWhitelisted(url.toString()); + } + + private static boolean isWhitelisted(@Nullable String rawUrl) { + if (rawUrl == null) return false; + + HttpUrl url = HttpUrl.parse(rawUrl); + + return url != null && + "https".equals(url.scheme()) && + ContentProxySelector.WHITELISTED_DOMAINS.contains(url.topPrivateDomain()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySelector.java b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySelector.java new file mode 100644 index 00000000..db77bf90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ContentProxySelector.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.net; + + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.SocketException; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ContentProxySelector extends ProxySelector { + + private static final String TAG = ContentProxySelector.class.getSimpleName(); + + public static final Set WHITELISTED_DOMAINS = new HashSet<>(); + static { + WHITELISTED_DOMAINS.add("giphy.com"); + } + + private final List CONTENT = new ArrayList(1) {{ + add(new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(BuildConfig.CONTENT_PROXY_HOST, + BuildConfig.CONTENT_PROXY_PORT))); + }}; + + @Override + public List select(URI uri) { + for (String domain : WHITELISTED_DOMAINS) { + if (uri.getHost().endsWith(domain)) { + return CONTENT; + } + } + throw new IllegalArgumentException("Tried to proxy a non-whitelisted domain."); + } + + @Override + public void connectFailed(URI uri, SocketAddress address, IOException failure) { + if (failure instanceof SocketException) { + Log.d(TAG, "Socket exception. Likely a cancellation."); + } else { + Log.w(TAG, "Connection failed.", failure); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/CustomDns.java b/app/src/main/java/org/thoughtcrime/securesms/net/CustomDns.java new file mode 100644 index 00000000..2882f26d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/CustomDns.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.xbill.DNS.ARecord; +import org.xbill.DNS.Lookup; +import org.xbill.DNS.Record; +import org.xbill.DNS.Resolver; +import org.xbill.DNS.SimpleResolver; +import org.xbill.DNS.Type; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import okhttp3.Dns; + +/** + * A {@link Dns} implementation that specifies the hostname of a specific DNS. + */ +public class CustomDns implements Dns { + + private static final String TAG = Log.tag(CustomDns.class); + + private final String dnsHostname; + + public CustomDns(@NonNull String dnsHostname) { + this.dnsHostname = dnsHostname; + } + + @Override + public @NonNull List lookup(@NonNull String hostname) throws UnknownHostException { + Resolver resolver = new SimpleResolver(dnsHostname); + Lookup lookup = doLookup(hostname); + + lookup.setResolver(resolver); + + Record[] records = lookup.run(); + + if (records != null) { + List ipv4Addresses = Stream.of(records) + .filter(r -> r.getType() == Type.A) + .map(r -> (ARecord) r) + .map(ARecord::getAddress) + .toList(); + if (ipv4Addresses.size() > 0) { + return ipv4Addresses; + } + } + + throw new UnknownHostException(hostname); + } + + private static @NonNull Lookup doLookup(@NonNull String hostname) throws UnknownHostException { + try { + return new Lookup(hostname); + } catch (Throwable e) { + Log.w(TAG, e); + throw new UnknownHostException(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java new file mode 100644 index 00000000..d577c295 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/DeprecatedClientPreventionInterceptor.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Disallows network requests when your client has been deprecated. When the client is deprecated, + * we simply fake a 499 response. + */ +public final class DeprecatedClientPreventionInterceptor implements Interceptor { + + private static final String TAG = Log.tag(DeprecatedClientPreventionInterceptor.class); + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + if (SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Preventing request because client is deprecated."); + return new Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .receivedResponseAtMillis(System.currentTimeMillis()) + .message("") + .body(ResponseBody.create(null, "")) + .code(499) + .build(); + } else { + return chain.proceed(chain.request()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java b/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java new file mode 100644 index 00000000..628f878e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.net; + +import android.app.Application; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.ReminderUpdateEvent; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.websocket.ConnectivityListener; + +import okhttp3.Response; + +/** + * Our standard listener for reacting to the state of the websocket. Translates the state into a + * LiveData for observation. + */ +public class PipeConnectivityListener implements ConnectivityListener { + + private static final String TAG = Log.tag(PipeConnectivityListener.class); + + private final Application application; + private final DefaultValueLiveData state; + + public PipeConnectivityListener(@NonNull Application application) { + this.application = application; + this.state = new DefaultValueLiveData<>(State.DISCONNECTED); + } + + @Override + public void onConnected() { + Log.i(TAG, "onConnected()"); + TextSecurePreferences.setUnauthorizedReceived(application, false); + state.postValue(State.CONNECTED); + } + + @Override + public void onConnecting() { + Log.i(TAG, "onConnecting()"); + state.postValue(State.CONNECTING); + } + + @Override + public void onDisconnected() { + Log.w(TAG, "onDisconnected()"); + + if (state.getValue() != State.FAILURE) { + state.postValue(State.DISCONNECTED); + } + } + + @Override + public void onAuthenticationFailure() { + Log.w(TAG, "onAuthenticationFailure()"); + TextSecurePreferences.setUnauthorizedReceived(application, true); + EventBus.getDefault().post(new ReminderUpdateEvent()); + state.postValue(State.FAILURE); + } + + @Override + public boolean onGenericFailure(Response response, Throwable throwable) { + Log.w(TAG, "onGenericFailure() Response: " + response, throwable); + state.postValue(State.FAILURE); + + if (SignalStore.proxy().isProxyEnabled()) { + Log.w(TAG, "Encountered an error while we had a proxy set! Terminating the connection to prevent retry spam."); + ApplicationDependencies.closeConnectionsAfterProxyFailure(); + return false; + } else { + return true; + } + } + + public void reset() { + state.postValue(State.DISCONNECTED); + } + + public @NonNull DefaultValueLiveData getState() { + return state; + } + + public enum State { + DISCONNECTED, CONNECTING, CONNECTED, FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java new file mode 100644 index 00000000..e88570fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Response; + +/** + * Marks the client as remotely-deprecated when it receives a 499 response. + */ +public final class RemoteDeprecationDetectorInterceptor implements Interceptor { + + private static final String TAG = Log.tag(RemoteDeprecationDetectorInterceptor.class); + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + Response response = chain.proceed(chain.request()); + + if (response.code() == 499 && !SignalStore.misc().isClientDeprecated()) { + Log.w(TAG, "Received 499. Client version is deprecated."); + SignalStore.misc().markClientDeprecated(); + } + + return response; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/RequestController.java b/app/src/main/java/org/thoughtcrime/securesms/net/RequestController.java new file mode 100644 index 00000000..0d667f45 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/RequestController.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.net; + +public interface RequestController { + + /** + * Best-effort cancellation of any outstanding requests. Will also release any resources held by + * the underlying request. + */ + void cancel(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SequentialDns.java b/app/src/main/java/org/thoughtcrime/securesms/net/SequentialDns.java new file mode 100644 index 00000000..b0478a79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SequentialDns.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import okhttp3.Dns; + +/** + * Iterates through an ordered list of {@link Dns}, trying each one in sequence. + */ +public class SequentialDns implements Dns { + + private static final String TAG = Log.tag(SequentialDns.class); + + private List dnsList; + + public SequentialDns(Dns... dns) { + this.dnsList = Arrays.asList(dns); + } + + @Override + public @NonNull List lookup(@NonNull String hostname) throws UnknownHostException { + for (Dns dns : dnsList) { + try { + List addresses = dns.lookup(hostname); + if (addresses.size() > 0) { + return addresses; + } else { + Log.w(TAG, String.format(Locale.ENGLISH, "Didn't find any addresses for %s using %s. Continuing.", hostname, dns.getClass().getSimpleName())); + } + } catch (UnknownHostException e) { + Log.w(TAG, String.format(Locale.ENGLISH, "Failed to resolve %s using %s. Continuing.", hostname, dns.getClass().getSimpleName())); + } + } + Log.w(TAG, "Failed to resolve using any DNS."); + throw new UnknownHostException(hostname); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java new file mode 100644 index 00000000..7b17fa1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/StandardUserAgentInterceptor.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.net; + +import android.os.Build; + +import org.thoughtcrime.securesms.BuildConfig; + +/** + * The user agent that should be used by default -- includes app name, version, etc. + */ +public class StandardUserAgentInterceptor extends UserAgentInterceptor { + + public StandardUserAgentInterceptor() { + super("Signal-Android/" + BuildConfig.VERSION_NAME + " Android/" + Build.VERSION.SDK_INT); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/StaticDns.java b/app/src/main/java/org/thoughtcrime/securesms/net/StaticDns.java new file mode 100644 index 00000000..18d00507 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/StaticDns.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import okhttp3.Dns; + +/** + * A super simple {@link Dns} implementation that maps hostnames to a static IP addresses. + */ +public class StaticDns implements Dns { + + private final Map hostnameMap; + + public StaticDns(@NonNull Map hostnameMap) { + this.hostnameMap = hostnameMap; + } + + @Override + public @NonNull List lookup(@NonNull String hostname) throws UnknownHostException { + String ip = hostnameMap.get(hostname); + + if (ip != null) { + return Collections.singletonList(InetAddress.getByName(ip)); + } else { + throw new UnknownHostException(hostname); + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/UserAgentInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/UserAgentInterceptor.java new file mode 100644 index 00000000..0cde9ee1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/UserAgentInterceptor.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Response; + +public class UserAgentInterceptor implements Interceptor { + + private final String userAgent; + + public UserAgentInterceptor(@NonNull String userAgent) { + this.userAgent = userAgent; + } + + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + return chain.proceed(chain.request().newBuilder() + .header("User-Agent", userAgent) + .build()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java new file mode 100644 index 00000000..20a11e61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.Notification; +import android.content.Context; +import android.graphics.Color; +import android.net.Uri; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +public abstract class AbstractNotificationBuilder extends NotificationCompat.Builder { + + @SuppressWarnings("unused") + private static final String TAG = AbstractNotificationBuilder.class.getSimpleName(); + + private static final int MAX_DISPLAY_LENGTH = 500; + + protected Context context; + protected NotificationPrivacyPreference privacy; + + public AbstractNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { + super(context); + + this.context = context; + this.privacy = privacy; + + setChannelId(NotificationChannels.getMessagesChannel(context)); + setLed(); + } + + protected CharSequence getStyledMessage(@NonNull Recipient recipient, @Nullable CharSequence message) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append(Util.getBoldedString(recipient.getDisplayName(context))); + builder.append(": "); + builder.append(message == null ? "" : message); + + return builder; + } + + public void setAlarms(@Nullable Uri ringtone, RecipientDatabase.VibrateState vibrate) { + Uri defaultRingtone = NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context) : TextSecurePreferences.getNotificationRingtone(context); + boolean defaultVibrate = NotificationChannels.supported() ? NotificationChannels.getMessageVibrate(context) : TextSecurePreferences.isNotificationVibrateEnabled(context); + + if (ringtone == null && !TextUtils.isEmpty(defaultRingtone.toString())) setSound(defaultRingtone); + else if (ringtone != null && !ringtone.toString().isEmpty()) setSound(ringtone); + + if (vibrate == RecipientDatabase.VibrateState.ENABLED || + (vibrate == RecipientDatabase.VibrateState.DEFAULT && defaultVibrate)) + { + setDefaults(Notification.DEFAULT_VIBRATE); + } + } + + private void setLed() { + String ledColor = TextSecurePreferences.getNotificationLedColor(context); + String ledBlinkPattern = TextSecurePreferences.getNotificationLedPattern(context); + String ledBlinkPatternCustom = TextSecurePreferences.getNotificationLedPatternCustom(context); + + if (!ledColor.equals("none")) { + String[] blinkPatternArray = parseBlinkPattern(ledBlinkPattern, ledBlinkPatternCustom); + + setLights(Color.parseColor(ledColor), + Integer.parseInt(blinkPatternArray[0]), + Integer.parseInt(blinkPatternArray[1])); + } + } + + public void setTicker(@NonNull Recipient recipient, @Nullable CharSequence message) { + if (privacy.isDisplayMessage()) { + setTicker(getStyledMessage(recipient, trimToDisplayLength(message))); + } else if (privacy.isDisplayContact()) { + setTicker(getStyledMessage(recipient, context.getString(R.string.AbstractNotificationBuilder_new_message))); + } else { + setTicker(context.getString(R.string.AbstractNotificationBuilder_new_message)); + } + } + + private String[] parseBlinkPattern(String blinkPattern, String blinkPatternCustom) { + if (blinkPattern.equals("custom")) + blinkPattern = blinkPatternCustom; + + return blinkPattern.split(","); + } + + protected @NonNull CharSequence trimToDisplayLength(@Nullable CharSequence text) { + text = text == null ? "" : text; + + return text.length() <= MAX_DISPLAY_LENGTH ? text + : text.subSequence(0, MAX_DISPLAY_LENGTH); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java new file mode 100644 index 00000000..d8f2465c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.notifications; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.util.LinkedList; +import java.util.List; + +/** + * Marks an Android Auto as read after the driver have listened to it + */ +public class AndroidAutoHeardReceiver extends BroadcastReceiver { + + public static final String TAG = Log.tag(AndroidAutoHeardReceiver.class); + public static final String HEARD_ACTION = "org.thoughtcrime.securesms.notifications.ANDROID_AUTO_HEARD"; + public static final String THREAD_IDS_EXTRA = "car_heard_thread_ids"; + public static final String NOTIFICATION_ID_EXTRA = "car_notification_id"; + + @SuppressLint("StaticFieldLeak") + @Override + public void onReceive(final Context context, Intent intent) + { + if (!HEARD_ACTION.equals(intent.getAction())) + return; + + final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA); + + if (threadIds != null) { + int notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1); + NotificationCancellationHelper.cancelLegacy(context, notificationId); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + List messageIdsCollection = new LinkedList<>(); + + for (long threadId : threadIds) { + Log.i(TAG, "Marking meassage as read: " + threadId); + List messageIds = DatabaseFactory.getThreadDatabase(context).setRead(threadId, true); + + messageIdsCollection.addAll(messageIds); + } + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIdsCollection); + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java new file mode 100644 index 00000000..ca6632d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.notifications; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; + +import androidx.core.app.RemoteInput; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Get the response text from the Android Auto and sends an message as a reply + */ +public class AndroidAutoReplyReceiver extends BroadcastReceiver { + + public static final String TAG = Log.tag(AndroidAutoReplyReceiver.class); + public static final String REPLY_ACTION = "org.thoughtcrime.securesms.notifications.ANDROID_AUTO_REPLY"; + public static final String RECIPIENT_EXTRA = "car_recipient"; + public static final String VOICE_REPLY_KEY = "car_voice_reply_key"; + public static final String THREAD_ID_EXTRA = "car_reply_thread_id"; + + @SuppressLint("StaticFieldLeak") + @Override + public void onReceive(final Context context, Intent intent) + { + if (!REPLY_ACTION.equals(intent.getAction())) return; + + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + + if (remoteInput == null) return; + + final long threadId = intent.getLongExtra(THREAD_ID_EXTRA, -1); + final CharSequence responseText = getMessageText(intent); + final Recipient recipient = Recipient.resolved(intent.getParcelableExtra(RECIPIENT_EXTRA)); + + if (responseText != null) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + + long replyThreadId; + + int subscriptionId = recipient.getDefaultSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + + if (recipient.resolve().isGroup()) { + Log.w(TAG, "GroupRecipient, Sending media message"); + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, + responseText.toString(), + new LinkedList<>(), + System.currentTimeMillis(), + subscriptionId, + expiresIn, + false, + 0, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + replyThreadId = MessageSender.send(context, reply, threadId, false, null); + } else { + Log.w(TAG, "Sending regular message "); + OutgoingTextMessage reply = new OutgoingTextMessage(recipient, responseText.toString(), expiresIn, subscriptionId); + replyThreadId = MessageSender.send(context, reply, threadId, false, null); + } + + List messageIds = DatabaseFactory.getThreadDatabase(context).setRead(replyThreadId, true); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private CharSequence getMessageText(Intent intent) { + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput != null) { + return remoteInput.getCharSequence(VOICE_REPLY_KEY); + } + return null; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java new file mode 100644 index 00000000..e91ac95e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -0,0 +1,818 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.notifications; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Build; +import android.os.TransactionTooLargeException; +import android.service.notification.StatusBarNotification; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.ThreadBodyUtil; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; +import org.whispersystems.signalservice.internal.util.Util; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import me.leolin.shortcutbadger.ShortcutBadger; + + +/** + * Handles posting system notifications for new messages. + * + * + * @author Moxie Marlinspike + */ +public class DefaultMessageNotifier implements MessageNotifier { + + private static final String TAG = DefaultMessageNotifier.class.getSimpleName(); + + public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; + public static final String NOTIFICATION_GROUP = "messages"; + + private static final String EMOJI_REPLACEMENT_STRING = "__EMOJI__"; + private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); + private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); + + private volatile long visibleThread = -1; + private volatile long lastDesktopActivityTimestamp = -1; + private volatile long lastAudibleNotification = -1; + private final CancelableExecutor executor = new CancelableExecutor(); + + @Override + public void setVisibleThread(long threadId) { + visibleThread = threadId; + } + + @Override + public long getVisibleThread() { + return visibleThread; + } + + @Override + public void clearVisibleThread() { + setVisibleThread(-1); + } + + @Override + public void setLastDesktopActivityTimestamp(long timestamp) { + lastDesktopActivityTimestamp = timestamp; + } + + @Override + public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { + if (visibleThread == threadId) { + sendInThreadNotification(context, recipient); + } else { + Intent intent = ConversationIntents.createBuilder(context, recipient.getId(), threadId) + .withDataUri(Uri.parse("custom://" + System.currentTimeMillis())) + .build(); + FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent); + + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify((int)threadId, builder.build()); + } + } + + @Override + public void cancelDelayedNotifications() { + executor.cancel(); + } + + private static boolean isDisplayingSummaryNotification(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 23) { + try { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); + + for (StatusBarNotification activeNotification : activeNotifications) { + if (activeNotification.getId() == NotificationIds.MESSAGE_SUMMARY) { + return true; + } + } + + return false; + + } catch (Throwable e) { + // XXX Android ROM Bug, see #6043 + Log.w(TAG, e); + return false; + } + } else { + return false; + } + } + + private static void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { + if (Build.VERSION.SDK_INT >= 23) { + try { + NotificationManager notifications = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); + + for (StatusBarNotification notification : activeNotifications) { + boolean validNotification = false; + + if (notification.getId() != NotificationIds.MESSAGE_SUMMARY && + notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && + notification.getId() != IncomingMessageObserver.FOREGROUND_ID && + notification.getId() != NotificationIds.PENDING_MESSAGES && + !CallNotificationBuilder.isWebRtcNotification(notification.getId())) + { + for (NotificationItem item : notificationState.getNotifications()) { + if (notification.getId() == NotificationIds.getNotificationIdForThread(item.getThreadId())) { + validNotification = true; + break; + } + } + + if (!validNotification) { + NotificationCancellationHelper.cancel(context, notification.getId()); + } + } + } + } catch (Throwable e) { + // XXX Android ROM Bug, see #6043 + Log.w(TAG, e); + } + } + } + + @Override + public void updateNotification(@NonNull Context context) { + if (!TextSecurePreferences.isNotificationsEnabled(context)) { + return; + } + + updateNotification(context, -1, false, 0, BubbleUtil.BubbleState.HIDDEN); + } + + @Override + public void updateNotification(@NonNull Context context, long threadId) + { + if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { + Log.i(TAG, "Scheduling delayed notification..."); + executor.execute(new DelayedNotification(context, threadId)); + } else { + updateNotification(context, threadId, true); + } + } + + @Override + public void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + updateNotification(context, threadId, false, 0, defaultBubbleState); + } + + @Override + public void updateNotification(@NonNull Context context, + long threadId, + boolean signal) + { + boolean isVisible = visibleThread == threadId; + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (shouldNotify(context, recipient, threadId)) { + if (isVisible) { + sendInThreadNotification(context, recipient); + } else { + updateNotification(context, threadId, signal, 0, BubbleUtil.BubbleState.HIDDEN); + } + } + } + + private boolean shouldNotify(@NonNull Context context, @Nullable Recipient recipient, long threadId) { + if (!TextSecurePreferences.isNotificationsEnabled(context)) { + return false; + } + + if (recipient == null || !recipient.isMuted()) { + return true; + } + + return recipient.isPushV2Group() && + recipient.getMentionSetting() == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY && + DatabaseFactory.getMmsDatabase(context).getUnreadMentionCount(threadId) > 0; + } + + @Override + public void updateNotification(@NonNull Context context, + long targetThread, + boolean signal, + int reminderCount, + @NonNull BubbleUtil.BubbleState defaultBubbleState) + { + if (!TextSecurePreferences.isNotificationsEnabled(context)) { + return; + } + + boolean isReminder = reminderCount > 0; + Cursor telcoCursor = null; + + try { + telcoCursor = DatabaseFactory.getMmsSmsDatabase(context).getUnread(); + + if (telcoCursor == null || telcoCursor.isAfterLast()) { + NotificationCancellationHelper.cancelAllMessageNotifications(context); + updateBadge(context, 0); + clearReminder(context); + return; + } + + NotificationState notificationState = constructNotificationState(context, telcoCursor); + + if (signal && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { + signal = false; + } else if (signal) { + lastAudibleNotification = System.currentTimeMillis(); + } + + boolean shouldScheduleReminder = signal; + + if (notificationState.hasMultipleThreads()) { + if (Build.VERSION.SDK_INT >= 23) { + for (long threadId : notificationState.getThreads()) { + if (targetThread < 1 || targetThread == threadId) { + sendSingleThreadNotification(context, + new NotificationState(notificationState.getNotificationsForThread(threadId)), + signal && (threadId == targetThread), + true, + isReminder, + (threadId == targetThread) ? defaultBubbleState : BubbleUtil.BubbleState.HIDDEN); + } + } + } + + sendMultipleThreadNotification(context, notificationState, signal && (Build.VERSION.SDK_INT < 23)); + } else { + long thread = notificationState.getNotifications().isEmpty() ? -1 : notificationState.getNotifications().get(0).getThreadId(); + BubbleUtil.BubbleState bubbleState = thread == targetThread ? defaultBubbleState : BubbleUtil.BubbleState.HIDDEN; + + shouldScheduleReminder = sendSingleThreadNotification(context, notificationState, signal, false, isReminder, bubbleState); + + if (isDisplayingSummaryNotification(context)) { + sendMultipleThreadNotification(context, notificationState, false); + } + } + + cancelOrphanedNotifications(context, notificationState); + updateBadge(context, notificationState.getMessageCount()); + + List smsIds = new LinkedList<>(); + List mmsIds = new LinkedList<>(); + for (NotificationItem item : notificationState.getNotifications()) { + if (item.isMms()) { + mmsIds.add(item.getId()); + } else { + smsIds.add(item.getId()); + } + } + DatabaseFactory.getMmsSmsDatabase(context).setNotifiedTimestamp(System.currentTimeMillis(), smsIds, mmsIds); + + if (shouldScheduleReminder) { + scheduleReminder(context, reminderCount); + } + } finally { + if (telcoCursor != null) telcoCursor.close(); + } + } + + private static boolean sendSingleThreadNotification(@NonNull Context context, + @NonNull NotificationState notificationState, + boolean signal, + boolean bundled, + boolean isReminder, + @NonNull BubbleUtil.BubbleState defaultBubbleState) + { + Log.i(TAG, "sendSingleThreadNotification() signal: " + signal + " bundled: " + bundled); + + if (notificationState.getNotifications().isEmpty()) { + if (!bundled) NotificationCancellationHelper.cancelAllMessageNotifications(context); + Log.i(TAG, "[sendSingleThreadNotification] Empty notification state. Skipping."); + return false; + } + + NotificationPrivacyPreference notificationPrivacy = TextSecurePreferences.getNotificationPrivacy(context); + SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, notificationPrivacy); + List notifications = notificationState.getNotifications(); + Recipient recipient = notifications.get(0).getRecipient(); + boolean shouldAlert = signal && (isReminder || Stream.of(notifications).anyMatch(item -> item.getNotifiedTimestamp() == 0)); + int notificationId; + + if (Build.VERSION.SDK_INT >= 23) { + notificationId = NotificationIds.getNotificationIdForThread(notifications.get(0).getThreadId()); + } else { + notificationId = NotificationIds.MESSAGE_SUMMARY; + } + + builder.setThread(notifications.get(0).getRecipient()); + builder.setMessageCount(notificationState.getMessageCount()); + builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(), + notifications.get(0).getText(), notifications.get(0).getSlideDeck()); + builder.setContentIntent(notifications.get(0).getPendingIntent(context)); + builder.setDeleteIntent(notificationState.getDeleteIntent(context)); + builder.setOnlyAlertOnce(!shouldAlert); + builder.setSortKey(String.valueOf(Long.MAX_VALUE - notifications.get(0).getTimestamp())); + builder.setDefaultBubbleState(defaultBubbleState); + + long timestamp = notifications.get(0).getTimestamp(); + if (timestamp != 0) builder.setWhen(timestamp); + + boolean isSingleNotificationContactJoined = notifications.size() == 1 && notifications.get(0).isJoin(); + + if (notificationPrivacy.isDisplayMessage() && + !KeyCachingService.isLocked(context) && + RecipientUtil.isMessageRequestAccepted(context, recipient.resolve())) + { + ReplyMethod replyMethod = ReplyMethod.forRecipient(context, recipient); + + builder.addActions(notificationState.getMarkAsReadIntent(context, notificationId), + notificationState.getQuickReplyIntent(context, notifications.get(0).getRecipient()), + notificationState.getRemoteReplyIntent(context, notifications.get(0).getRecipient(), replyMethod), + replyMethod, + !isSingleNotificationContactJoined && notificationState.canReply()); + + builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, notifications.get(0).getRecipient()), + notificationState.getAndroidAutoHeardIntent(context, notificationId), notifications.get(0).getTimestamp()); + } + + if (!KeyCachingService.isLocked(context) && isSingleNotificationContactJoined) { + builder.addTurnOffTheseNotificationsAction(notificationState.getTurnOffTheseNotificationsIntent(context)); + } + + ListIterator iterator = notifications.listIterator(notifications.size()); + + while(iterator.hasPrevious()) { + NotificationItem item = iterator.previous(); + builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText(), item.getTimestamp(), item.getSlideDeck()); + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); + builder.setTicker(notifications.get(0).getIndividualRecipient(), + notifications.get(0).getText()); + } + + if (Build.VERSION.SDK_INT >= 23) { + builder.setGroup(NOTIFICATION_GROUP); + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); + } + + Notification notification = builder.build(); + try { + NotificationManagerCompat.from(context).notify(notificationId, notification); + Log.i(TAG, "Posted notification."); + } catch (SecurityException e) { + Uri defaultValue = TextSecurePreferences.getNotificationRingtone(context); + if (!defaultValue.equals(notificationState.getRingtone(context))) { + Log.e(TAG, "Security exception when posting notification with custom ringtone", e); + clearNotificationRingtone(context, notifications.get(0).getRecipient()); + } else { + throw e; + } + } + + return shouldAlert; + } + + private static void sendMultipleThreadNotification(@NonNull Context context, + @NonNull NotificationState notificationState, + boolean signal) + { + Log.i(TAG, "sendMultiThreadNotification() signal: " + signal); + + if (notificationState.getNotifications().isEmpty()) { + Log.i(TAG, "[sendMultiThreadNotification] Empty notification state. Skipping."); + return; + } + + NotificationPrivacyPreference notificationPrivacy = TextSecurePreferences.getNotificationPrivacy(context); + MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, notificationPrivacy); + List notifications = notificationState.getNotifications(); + boolean shouldAlert = signal && Stream.of(notifications).anyMatch(item -> item.getNotifiedTimestamp() == 0); + + builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount()); + builder.setMostRecentSender(notifications.get(0).getIndividualRecipient()); + builder.setDeleteIntent(notificationState.getDeleteIntent(context)); + builder.setOnlyAlertOnce(!shouldAlert); + + if (Build.VERSION.SDK_INT >= 23) { + builder.setGroup(NOTIFICATION_GROUP); + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); + } + + long timestamp = notifications.get(0).getTimestamp(); + if (timestamp != 0) builder.setWhen(timestamp); + + if (notificationPrivacy.isDisplayMessage()) { + builder.addActions(notificationState.getMarkAsReadIntent(context, NotificationIds.MESSAGE_SUMMARY)); + } + + ListIterator iterator = notifications.listIterator(notifications.size()); + + while(iterator.hasPrevious()) { + NotificationItem item = iterator.previous(); + builder.addMessageBody(item.getIndividualRecipient(), item.getText()); + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); + builder.setTicker(notifications.get(0).getIndividualRecipient(), + notifications.get(0).getText()); + } + + Notification notification = builder.build(); + + try { + NotificationManagerCompat.from(context).notify(NotificationIds.MESSAGE_SUMMARY, builder.build()); + Log.i(TAG, "Posted notification. " + notification.toString()); + } catch (SecurityException securityException) { + Uri defaultValue = TextSecurePreferences.getNotificationRingtone(context); + if (!defaultValue.equals(notificationState.getRingtone(context))) { + Log.e(TAG, "Security exception when posting notification with custom ringtone", securityException); + clearNotificationRingtone(context, notifications.get(0).getRecipient()); + } else { + throw securityException; + } + } catch (RuntimeException runtimeException) { + Throwable cause = runtimeException.getCause(); + if (cause instanceof TransactionTooLargeException) { + Log.e(TAG, "Transaction too large", runtimeException); + } else { + throw runtimeException; + } + } + } + + private static void sendInThreadNotification(Context context, Recipient recipient) { + if (!TextSecurePreferences.isInThreadNotifications(context) || + ServiceUtil.getAudioManager(context).getRingerMode() != AudioManager.RINGER_MODE_NORMAL) + { + return; + } + + Uri uri = null; + if (recipient != null) { + uri = NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context, recipient) : recipient.getMessageRingtone(); + } + + if (uri == null) { + uri = NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context) : TextSecurePreferences.getNotificationRingtone(context); + } + + if (uri.toString().isEmpty()) { + Log.d(TAG, "ringtone uri is empty"); + return; + } + + Ringtone ringtone = RingtoneManager.getRingtone(context, uri); + + if (ringtone == null) { + Log.w(TAG, "ringtone is null"); + return; + } + + if (Build.VERSION.SDK_INT >= 21) { + ringtone.setAudioAttributes(new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build()); + } else { + ringtone.setStreamType(AudioManager.STREAM_NOTIFICATION); + } + + ringtone.play(); + } + + private static NotificationState constructNotificationState(@NonNull Context context, + @NonNull Cursor cursor) + { + NotificationState notificationState = new NotificationState(); + MmsSmsDatabase.Reader reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor); + + MessageRecord record; + + while ((record = reader.getNext()) != null) { + long id = record.getId(); + boolean mms = record.isMms() || record.isMmsNotification(); + Recipient recipient = record.getIndividualRecipient().resolve(); + Recipient conversationRecipient = record.getRecipient().resolve(); + long threadId = record.getThreadId(); + CharSequence body = MentionUtil.updateBodyWithDisplayNames(context, record); + Recipient threadRecipients = null; + SlideDeck slideDeck = null; + long timestamp = record.getTimestamp(); + long receivedTimestamp = record.getDateReceived(); + boolean isUnreadMessage = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.READ)) == 0; + boolean hasUnreadReactions = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_UNREAD)) == 1; + long lastReactionRead = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_LAST_SEEN)); + long notifiedTimestamp = record.getNotifiedTimestamp(); + + if (threadId != -1) { + threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + } + + if (isUnreadMessage) { + boolean canReply = false; + + if (KeyCachingService.isLocked(context)) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); + } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { + Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); + body = ContactUtil.getStringSummary(context, contact); + } else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { + body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record))); + } else if (record.isRemoteDelete()) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_this_message_was_deleted));; + } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { + body = ThreadBodyUtil.getFormattedBodyFor(context, record); + slideDeck = ((MmsMessageRecord) record).getSlideDeck(); + canReply = true; + } else if (record.isGroupCall()) { + body = new SpannableString(MessageRecord.getGroupCallUpdateDescription(context, record.getBody(), false).getString()); + canReply = false; + } else { + canReply = true; + } + + boolean includeMessage = true; + if (threadRecipients != null && threadRecipients.isMuted()) { + boolean mentionsOverrideMute = threadRecipients.getMentionSetting() == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY; + + includeMessage = mentionsOverrideMute && record.hasSelfMention(); + } + + if (threadRecipients == null || includeMessage) { + notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, receivedTimestamp, slideDeck, false, record.isJoined(), canReply, notifiedTimestamp)); + } + } + + if (hasUnreadReactions) { + CharSequence originalBody = body; + for (ReactionRecord reaction : record.getReactions()) { + Recipient reactionSender = Recipient.resolved(reaction.getAuthor()); + if (reactionSender.equals(Recipient.self()) || !record.isOutgoing() || reaction.getDateReceived() <= lastReactionRead) { + continue; + } + + if (KeyCachingService.isLocked(context)) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); + } else { + String text = SpanUtil.italic(getReactionMessageBody(context, record, originalBody)).toString(); + String[] parts = text.split(EMOJI_REPLACEMENT_STRING); + + SpannableStringBuilder builder = new SpannableStringBuilder(); + for (int i = 0; i < parts.length; i++) { + builder.append(SpanUtil.italic(parts[i])); + + if (i != parts.length -1) { + builder.append(reaction.getEmoji()); + } + } + + if (text.endsWith(EMOJI_REPLACEMENT_STRING)) { + builder.append(reaction.getEmoji()); + } + + body = builder; + } + + if (threadRecipients == null || !threadRecipients.isMuted()) { + notificationState.addNotification(new NotificationItem(id, mms, reactionSender, conversationRecipient, threadRecipients, threadId, body, reaction.getDateReceived(), receivedTimestamp, null, true, record.isJoined(), false, 0)); + } + } + } + } + + reader.close(); + return notificationState; + } + + private static CharSequence getReactionMessageBody(@NonNull Context context, @NonNull MessageRecord record, @NonNull CharSequence body) { + boolean bodyIsEmpty = TextUtils.isEmpty(body); + + if (MessageRecordUtil.hasSharedContact(record)) { + Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); + CharSequence summary = ContactUtil.getStringSummary(context, contact); + + return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, summary); + } else if (MessageRecordUtil.hasSticker(record)) { + return context.getString(R.string.MessageNotifier_reacted_s_to_your_sticker, EMOJI_REPLACEMENT_STRING); + } else if (record.isMms() && record.isViewOnce()){ + return context.getString(R.string.MessageNotifier_reacted_s_to_your_view_once_media, EMOJI_REPLACEMENT_STRING); + } else if (!bodyIsEmpty) { + return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body); + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isVideoType(getMessageContentType((MmsMessageRecord) record))) { + return context.getString(R.string.MessageNotifier_reacted_s_to_your_video, EMOJI_REPLACEMENT_STRING); + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isImageType(getMessageContentType((MmsMessageRecord) record))) { + return context.getString(R.string.MessageNotifier_reacted_s_to_your_image, EMOJI_REPLACEMENT_STRING); + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isAudioType(getMessageContentType((MmsMessageRecord) record))) { + return context.getString(R.string.MessageNotifier_reacted_s_to_your_audio, EMOJI_REPLACEMENT_STRING); + } else if (MessageRecordUtil.isMediaMessage(record)) { + return context.getString(R.string.MessageNotifier_reacted_s_to_your_file, EMOJI_REPLACEMENT_STRING); + } else { + return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body); + } + } + + private static @StringRes int getViewOnceDescription(@NonNull MmsMessageRecord messageRecord) { + final String contentType = getMessageContentType(messageRecord); + + if (MediaUtil.isImageType(contentType)) { + return R.string.MessageNotifier_view_once_photo; + } + return R.string.MessageNotifier_view_once_video; + } + + private static String getMessageContentType(@NonNull MmsMessageRecord messageRecord) { + Slide thumbnailSlide = messageRecord.getSlideDeck().getThumbnailSlide(); + if (thumbnailSlide == null) { + String slideContentType = messageRecord.getSlideDeck().getFirstSlideContentType(); + if (slideContentType != null) { + return slideContentType; + } + Log.w(TAG, "Could not distinguish view-once content type from message record, defaulting to JPEG"); + return MediaUtil.IMAGE_JPEG; + } + return thumbnailSlide.getContentType(); + } + + private static void updateBadge(Context context, int count) { + try { + if (count == 0) ShortcutBadger.removeCount(context); + else ShortcutBadger.applyCount(context, count); + } catch (Throwable t) { + // NOTE :: I don't totally trust this thing, so I'm catching + // everything. + Log.w(TAG, t); + } + } + + private static void scheduleReminder(Context context, int count) { + if (count >= TextSecurePreferences.getRepeatAlertsCount(context)) { + return; + } + + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent alarmIntent = new Intent(context, ReminderReceiver.class); + alarmIntent.putExtra("reminder_count", count); + + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); + long timeout = TimeUnit.MINUTES.toMillis(2); + + alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent); + } + + private static void clearNotificationRingtone(@NonNull Context context, @NonNull Recipient recipient) { + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(recipient.getId(), null); + NotificationChannels.updateMessageRingtone(context, recipient, null); + }); + } + + @Override + public void clearReminder(@NonNull Context context) { + Intent alarmIntent = new Intent(context, ReminderReceiver.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + alarmManager.cancel(pendingIntent); + } + + private static class DelayedNotification implements Runnable { + + private static final long DELAY = TimeUnit.SECONDS.toMillis(5); + + private final AtomicBoolean canceled = new AtomicBoolean(false); + + private final Context context; + private final long threadId; + private final long delayUntil; + + private DelayedNotification(Context context, long threadId) { + this.context = context; + this.threadId = threadId; + this.delayUntil = System.currentTimeMillis() + DELAY; + } + + @Override + public void run() { + long delayMillis = delayUntil - System.currentTimeMillis(); + Log.i(TAG, "Waiting to notify: " + delayMillis); + + if (delayMillis > 0) { + Util.sleep(delayMillis); + } + + if (!canceled.get()) { + Log.i(TAG, "Not canceled, notifying..."); + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId, true); + ApplicationDependencies.getMessageNotifier().cancelDelayedNotifications(); + } else { + Log.w(TAG, "Canceled, not notifying..."); + } + } + + public void cancel() { + canceled.set(true); + } + } + + private static class CancelableExecutor { + + private final Executor executor = Executors.newSingleThreadExecutor(); + private final Set tasks = new HashSet<>(); + + public void execute(final DelayedNotification runnable) { + synchronized (tasks) { + tasks.add(runnable); + } + + Runnable wrapper = () -> { + runnable.run(); + + synchronized (tasks) { + tasks.remove(runnable); + } + }; + + executor.execute(wrapper); + } + + public void cancel() { + synchronized (tasks) { + for (DelayedNotification task : tasks) { + task.cancel(); + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java new file mode 100644 index 00000000..7d961ea4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.notifications; + + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +public class DeleteNotificationReceiver extends BroadcastReceiver { + + public static String DELETE_NOTIFICATION_ACTION = "org.thoughtcrime.securesms.DELETE_NOTIFICATION"; + + public static String EXTRA_IDS = "message_ids"; + public static String EXTRA_MMS = "is_mms"; + + @Override + public void onReceive(final Context context, Intent intent) { + if (DELETE_NOTIFICATION_ACTION.equals(intent.getAction())) { + ApplicationDependencies.getMessageNotifier().clearReminder(context); + + final long[] ids = intent.getLongArrayExtra(EXTRA_IDS); + final boolean[] mms = intent.getBooleanArrayExtra(EXTRA_MMS); + + if (ids == null || mms == null || ids.length != mms.length) return; + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + for (int i=0;i { + List messageIdsCollection = new LinkedList<>(); + + for (long threadId : threadIds) { + Log.i(TAG, "Marking as read: " + threadId); + List messageIds = DatabaseFactory.getThreadDatabase(context).setRead(threadId, true); + messageIdsCollection.addAll(messageIds); + } + + process(context, messageIdsCollection); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + }); + } + } + + public static void process(@NonNull Context context, @NonNull List markedReadMessages) { + if (markedReadMessages.isEmpty()) return; + + List syncMessageIds = Stream.of(markedReadMessages) + .map(MarkedMessageInfo::getSyncMessageId) + .toList(); + List mmsExpirationInfo = Stream.of(markedReadMessages) + .map(MarkedMessageInfo::getExpirationInfo) + .filter(ExpirationInfo::isMms) + .filter(info -> info.getExpiresIn() > 0 && info.getExpireStarted() <= 0) + .toList(); + List smsExpirationInfo = Stream.of(markedReadMessages) + .map(MarkedMessageInfo::getExpirationInfo) + .filterNot(ExpirationInfo::isMms) + .filter(info -> info.getExpiresIn() > 0 && info.getExpireStarted() <= 0) + .toList(); + + scheduleDeletion(context, smsExpirationInfo, mmsExpirationInfo); + + MultiDeviceReadUpdateJob.enqueue(syncMessageIds); + + Map> threadToInfo = Stream.of(markedReadMessages) + .collect(Collectors.groupingBy(MarkedMessageInfo::getThreadId)); + + Stream.of(threadToInfo).forEach(threadToInfoEntry -> { + Map> idMapForThread = Stream.of(threadToInfoEntry.getValue()) + .map(MarkedMessageInfo::getSyncMessageId) + .collect(Collectors.groupingBy(SyncMessageId::getRecipientId)); + + Stream.of(idMapForThread).forEach(entry -> { + List timestamps = Stream.of(entry.getValue()).map(SyncMessageId::getTimetamp).toList(); + + SendReadReceiptJob.enqueue(threadToInfoEntry.getKey(), entry.getKey(), timestamps); + }); + }); + } + + private static void scheduleDeletion(@NonNull Context context, + @NonNull List smsExpirationInfo, + @NonNull List mmsExpirationInfo) + { + if (smsExpirationInfo.size() > 0) { + DatabaseFactory.getSmsDatabase(context).markExpireStarted(Stream.of(smsExpirationInfo).map(ExpirationInfo::getId).toList(), System.currentTimeMillis()); + } + + if (mmsExpirationInfo.size() > 0) { + DatabaseFactory.getMmsDatabase(context).markExpireStarted(Stream.of(mmsExpirationInfo).map(ExpirationInfo::getId).toList(), System.currentTimeMillis()); + } + + if (smsExpirationInfo.size() + mmsExpirationInfo.size() > 0) { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); + + Stream.concat(Stream.of(smsExpirationInfo), Stream.of(mmsExpirationInfo)) + .forEach(info -> expirationManager.scheduleDeletion(info.getId(), info.isMms(), info.getExpiresIn())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java new file mode 100644 index 00000000..1c17129f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BubbleUtil; + +public interface MessageNotifier { + void setVisibleThread(long threadId); + long getVisibleThread(); + void clearVisibleThread(); + void setLastDesktopActivityTimestamp(long timestamp); + void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId); + void cancelDelayedNotifications(); + void updateNotification(@NonNull Context context); + void updateNotification(@NonNull Context context, long threadId); + void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState); + void updateNotification(@NonNull Context context, long threadId, boolean signal); + void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount, @NonNull BubbleUtil.BubbleState defaultBubbleState); + void clearReminder(@NonNull Context context); + + + class ReminderReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, final Intent intent) { + SignalExecutors.BOUNDED.execute(() -> { + int reminderCount = intent.getIntExtra("reminder_count", 0); + ApplicationDependencies.getMessageNotifier().updateNotification(context, -1, true, reminderCount + 1, BubbleUtil.BubbleState.HIDDEN); + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java new file mode 100644 index 00000000..f9ebfa83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +import java.util.LinkedList; +import java.util.List; + +public class MultipleRecipientNotificationBuilder extends AbstractNotificationBuilder { + + private final List messageBodies = new LinkedList<>(); + + public MultipleRecipientNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { + super(context, privacy); + + setColor(context.getResources().getColor(R.color.core_ultramarine)); + setSmallIcon(R.drawable.ic_notification); + setContentTitle(context.getString(R.string.app_name)); + // TODO [greyson] Navigation + setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)); + setCategory(NotificationCompat.CATEGORY_MESSAGE); + setGroupSummary(true); + + if (!NotificationChannels.supported()) { + setPriority(TextSecurePreferences.getNotificationPriority(context)); + } + } + + public void setMessageCount(int messageCount, int threadCount) { + setSubText(context.getString(R.string.MessageNotifier_d_new_messages_in_d_conversations, + messageCount, threadCount)); + setContentInfo(String.valueOf(messageCount)); + setNumber(messageCount); + } + + public void setMostRecentSender(Recipient recipient) { + if (privacy.isDisplayContact()) { + setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, + recipient.getDisplayName(context))); + } + + if (recipient.getNotificationChannel() != null) { + setChannelId(recipient.getNotificationChannel()); + } + } + + public void addActions(PendingIntent markAsReadIntent) { + NotificationCompat.Action markAllAsReadAction = new NotificationCompat.Action(R.drawable.check, + context.getString(R.string.MessageNotifier_mark_all_as_read), + markAsReadIntent); + addAction(markAllAsReadAction); + extend(new NotificationCompat.WearableExtender().addAction(markAllAsReadAction)); + } + + public void addMessageBody(@NonNull Recipient sender, @Nullable CharSequence body) { + if (privacy.isDisplayMessage()) { + messageBodies.add(getStyledMessage(sender, body)); + } else if (privacy.isDisplayContact()) { + messageBodies.add(Util.getBoldedString(sender.getDisplayName(context))); + } + + if (privacy.isDisplayContact() && sender.getContactUri() != null) { + addPerson(sender.getContactUri().toString()); + } + } + + @Override + public Notification build() { + if (privacy.isDisplayMessage() || privacy.isDisplayContact()) { + NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); + + for (CharSequence body : messageBodies) { + style.addLine(trimToDisplayLength(body)); + } + + setStyle(style); + } + + return super.build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java new file mode 100644 index 00000000..c324eef7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.service.notification.StatusBarNotification; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.util.Objects; + +/** + * Consolidates Notification Cancellation logic to one class. + * + * Because Bubbles are tied to Notifications, and disappear when those Notificaitons are cancelled, + * we want to be very surgical about what notifications we dismiss and when. Behaviour on API levels + * previous to {@link org.thoughtcrime.securesms.util.ConversationUtil#CONVERSATION_SUPPORT_VERSION} + * is preserved. + * + */ +public final class NotificationCancellationHelper { + + private static final String TAG = Log.tag(NotificationCancellationHelper.class); + + private NotificationCancellationHelper() {} + + /** + * Cancels all Message-Based notifications. Specifically, this is any notification that is not the + * summary notification assigned to the {@link DefaultMessageNotifier#NOTIFICATION_GROUP} group. + * + * We utilize our wrapped cancellation methods and a counter to make sure that we do not lose + * bubble notifications that do not have unread messages in them. + */ + static void cancelAllMessageNotifications(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 23) { + try { + NotificationManager notifications = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); + int activeCount = 0; + + for (StatusBarNotification activeNotification : activeNotifications) { + if (isSingleThreadNotification(activeNotification)) { + activeCount++; + if (cancel(context, activeNotification.getId())) { + activeCount--; + } + } + } + + if (activeCount == 0) { + cancelLegacy(context, NotificationIds.MESSAGE_SUMMARY); + } + } catch (Throwable e) { + // XXX Appears to be a ROM bug, see #6043 + Log.w(TAG, "Canceling all notifications.", e); + ServiceUtil.getNotificationManager(context).cancelAll(); + } + } else { + cancelLegacy(context, NotificationIds.MESSAGE_SUMMARY); + } + } + + /** + * @return whether this is a non-summary notification that is a member of the NOTIFICATION_GROUP group. + */ + @RequiresApi(23) + private static boolean isSingleThreadNotification(@NonNull StatusBarNotification statusBarNotification) { + return statusBarNotification.getId() != NotificationIds.MESSAGE_SUMMARY && + Objects.equals(statusBarNotification.getNotification().getGroup(), DefaultMessageNotifier.NOTIFICATION_GROUP); + } + + /** + * Attempts to cancel the given notification. If the notification is allowed to be displayed as a + * bubble, we do not cancel it. + * + * @return Whether or not the notification is considered cancelled. + */ + public static boolean cancel(@NonNull Context context, int notificationId) { + Log.d(TAG, "cancel() called with: notificationId = [" + notificationId + "]"); + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + return cancelWithConversationSupport(context, notificationId); + } else { + cancelLegacy(context, notificationId); + return true; + } + } + + /** + * Bypasses bubble check. + */ + public static void cancelLegacy(@NonNull Context context, int notificationId) { + Log.d(TAG, "cancelLegacy() called with: notificationId = [" + notificationId + "]"); + ServiceUtil.getNotificationManager(context).cancel(notificationId); + } + + /** + * Cancel method which first checks whether the notification in question is tied to a bubble that + * may or may not be displayed by the user. + * + * @return true if the notification was cancelled. + */ + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private static boolean cancelWithConversationSupport(@NonNull Context context, int notificationId) { + Log.d(TAG, "cancelWithConversationSupport() called with: notificationId = [" + notificationId + "]"); + if (isCancellable(context, notificationId)) { + cancelLegacy(context, notificationId); + return true; + } else { + return false; + } + } + + /** + * Checks whether the conversation for the given notification is allowed to be represented as a bubble. + * + * see {@link BubbleUtil#canBubble} for more information. + */ + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private static boolean isCancellable(@NonNull Context context, int notificationId) { + NotificationManager manager = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] notifications = manager.getActiveNotifications(); + Notification notification = Stream.of(notifications) + .filter(n -> n.getId() == notificationId) + .findFirst() + .map(StatusBarNotification::getNotification) + .orElse(null); + + if (notification == null || + notification.getShortcutId() == null || + notification.getBubbleMetadata() == null) { + Log.d(TAG, "isCancellable: bubbles not available or notification does not exist"); + return true; + } + + RecipientId recipientId = RecipientId.from(notification.getShortcutId()); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); + + long focusedThreadId = ApplicationDependencies.getMessageNotifier().getVisibleThread(); + if (Objects.equals(threadId, focusedThreadId)) { + Log.d(TAG, "isCancellable: user entered full screen thread."); + return true; + } + + return !BubbleUtil.canBubble(context, recipientId, threadId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java new file mode 100644 index 00000000..de9fd1e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -0,0 +1,637 @@ +package org.thoughtcrime.securesms.notifications; + +import android.annotation.TargetApi; +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.media.AudioAttributes; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.provider.Settings; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class NotificationChannels { + + private static final String TAG = Log.tag(NotificationChannels.class); + + private static class Version { + static final int MESSAGES_CATEGORY = 2; + static final int CALLS_PRIORITY_BUMP = 3; + static final int VIBRATE_OFF_OTHER = 4; + } + + private static final int VERSION = 4; + + private static final String CATEGORY_MESSAGES = "messages"; + private static final String CONTACT_PREFIX = "contact_"; + private static final String MESSAGES_PREFIX = "messages_"; + + public static final String CALLS = "calls_v3"; + public static final String FAILURES = "failures"; + public static final String APP_UPDATES = "app_updates"; + public static final String BACKUPS = "backups_v2"; + public static final String LOCKED_STATUS = "locked_status_v2"; + public static final String OTHER = "other_v3"; + public static final String VOICE_NOTES = "voice_notes"; + + /** + * Ensures all of the notification channels are created. No harm in repeat calls. Call is safely + * ignored for API < 26. + */ + public static synchronized void create(@NonNull Context context) { + if (!supported()) { + return; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + + int oldVersion = TextSecurePreferences.getNotificationChannelVersion(context); + if (oldVersion != VERSION) { + onUpgrade(notificationManager, oldVersion, VERSION); + TextSecurePreferences.setNotificationChannelVersion(context, VERSION); + } + + onCreate(context, notificationManager); + + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + ensureCustomChannelConsistency(context); + }); + } + + /** + * Recreates all notification channels for contacts with custom notifications enabled. Should be + * safe to call repeatedly. Needs to be executed on a background thread. + */ + @WorkerThread + public static synchronized void restoreContactNotificationChannels(@NonNull Context context) { + if (!NotificationChannels.supported()) { + return; + } + + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + + try (RecipientDatabase.RecipientReader reader = db.getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = reader.getNext()) != null) { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + if (!channelExists(notificationManager.getNotificationChannel(recipient.getNotificationChannel()))) { + String id = createChannelFor(context, recipient); + db.setNotificationChannel(recipient.getId(), id); + } + } + } + + ensureCustomChannelConsistency(context); + } + + /** + * @return The channel ID for the default messages channel. + */ + public static synchronized @NonNull String getMessagesChannel(@NonNull Context context) { + return getMessagesChannelId(TextSecurePreferences.getNotificationMessagesChannelVersion(context)); + } + + /** + * @return Whether or not notification channels are supported. + */ + public static boolean supported() { + return Build.VERSION.SDK_INT >= 26; + } + + /** + * @return A name suitable to be displayed as the notification channel title. + */ + public static @NonNull String getChannelDisplayNameFor(@NonNull Context context, + @Nullable String systemName, + @Nullable String profileName, + @Nullable String username, + @NonNull String address) + { + if (!TextUtils.isEmpty(systemName)) { + return systemName; + } else if (!TextUtils.isEmpty(profileName)) { + return profileName; + } else if (!TextUtils.isEmpty(username)) { + return username; + } else if (!TextUtils.isEmpty(address)) { + return address; + } else { + return context.getString(R.string.NotificationChannel_missing_display_name); + } + } + + /** + * Creates a channel for the specified recipient. + * @return The channel ID for the newly-created channel. + */ + public static synchronized @Nullable String createChannelFor(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.getId().isUnknown()) return null; + + VibrateState vibrateState = recipient.getMessageVibrate(); + boolean vibrationEnabled = vibrateState == VibrateState.DEFAULT ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == VibrateState.ENABLED; + Uri messageRingtone = recipient.getMessageRingtone() != null ? recipient.getMessageRingtone() : getMessageRingtone(context); + String displayName = recipient.getDisplayName(context); + + return createChannelFor(context, generateChannelIdFor(recipient), displayName, messageRingtone, vibrationEnabled); + } + + /** + * More verbose version of {@link #createChannelFor(Context, Recipient)}. + */ + public static synchronized @Nullable String createChannelFor(@NonNull Context context, + @NonNull String channelId, + @NonNull String displayName, + @Nullable Uri messageSound, + boolean vibrationEnabled) + { + if (!supported()) { + return null; + } + + NotificationChannel channel = new NotificationChannel(channelId, displayName, NotificationManager.IMPORTANCE_HIGH); + + setLedPreference(channel, TextSecurePreferences.getNotificationLedColor(context)); + channel.setGroup(CATEGORY_MESSAGES); + channel.enableVibration(vibrationEnabled); + + if (messageSound != null) { + channel.setSound(messageSound, new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build()); + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + notificationManager.createNotificationChannel(channel); + + return channelId; + } + + /** + * Deletes the channel generated for the provided recipient. Safe to call even if there was never + * a channel made for that recipient. + */ + public static synchronized void deleteChannelFor(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported()) { + return; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + String channel = recipient.getNotificationChannel(); + + if (channel != null) { + Log.i(TAG, "Deleting channel"); + notificationManager.deleteNotificationChannel(channel); + } + } + + /** + * Navigates the user to the system settings for the desired notification channel. + */ + public static void openChannelSettings(@NonNull Context context, @NonNull String channelId) { + if (!supported()) { + return; + } + + Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, channelId); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + context.startActivity(intent); + } + + /** + * Updates the LED color for message notifications and all contact-specific message notification + * channels. Performs database operations and should therefore be invoked on a background thread. + */ + @WorkerThread + public static synchronized void updateMessagesLedColor(@NonNull Context context, @NonNull String color) { + if (!supported()) { + return; + } + Log.i(TAG, "Updating LED color."); + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + + updateMessageChannel(context, channel -> setLedPreference(channel, color)); + updateAllRecipientChannelLedColors(context, notificationManager, color); + + ensureCustomChannelConsistency(context); + } + + /** + * @return The message ringtone set for the default message channel. + */ + public static synchronized @NonNull Uri getMessageRingtone(@NonNull Context context) { + if (!supported()) { + return Uri.EMPTY; + } + + Uri sound = ServiceUtil.getNotificationManager(context).getNotificationChannel(getMessagesChannel(context)).getSound(); + return sound == null ? Uri.EMPTY : sound; + } + + public static synchronized @Nullable Uri getMessageRingtone(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported() || recipient.resolve().getNotificationChannel() == null) { + return null; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + NotificationChannel channel = notificationManager.getNotificationChannel(recipient.getNotificationChannel()); + + if (!channelExists(channel)) { + Log.w(TAG, "Recipient had no channel. Returning null."); + return null; + } + + return channel.getSound(); + } + + /** + * Update the message ringtone for the default message channel. + */ + public static synchronized void updateMessageRingtone(@NonNull Context context, @Nullable Uri uri) { + if (!supported()) { + return; + } + Log.i(TAG, "Updating default message ringtone with URI: " + String.valueOf(uri)); + + updateMessageChannel(context, channel -> { + channel.setSound(uri == null ? Settings.System.DEFAULT_NOTIFICATION_URI : uri, getRingtoneAudioAttributes()); + }); + } + + /** + * Updates the message ringtone for a specific recipient. If that recipient has no channel, this + * does nothing. + * + * This has to update the database, and therefore should be run on a background thread. + */ + @WorkerThread + public static synchronized void updateMessageRingtone(@NonNull Context context, @NonNull Recipient recipient, @Nullable Uri uri) { + if (!supported() || recipient.getNotificationChannel() == null) { + return; + } + Log.i(TAG, "Updating recipient message ringtone with URI: " + String.valueOf(uri)); + + String newChannelId = generateChannelIdFor(recipient); + boolean success = updateExistingChannel(ServiceUtil.getNotificationManager(context), + recipient.getNotificationChannel(), + generateChannelIdFor(recipient), + channel -> channel.setSound(uri == null ? Settings.System.DEFAULT_NOTIFICATION_URI : uri, getRingtoneAudioAttributes())); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), success ? newChannelId : null); + ensureCustomChannelConsistency(context); + } + + /** + * @return The vibrate settings for the default message channel. + */ + public static synchronized boolean getMessageVibrate(@NonNull Context context) { + if (!supported()) { + return false; + } + + return ServiceUtil.getNotificationManager(context).getNotificationChannel(getMessagesChannel(context)).shouldVibrate(); + } + + /** + * @return The vibrate setting for a specific recipient. If that recipient has no channel, this + * will return the setting for the default message channel. + */ + public static synchronized boolean getMessageVibrate(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported()) { + return getMessageVibrate(context); + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + NotificationChannel channel = notificationManager.getNotificationChannel(recipient.getNotificationChannel()); + + if (!channelExists(channel)) { + Log.w(TAG, "Recipient didn't have a channel. Returning message default."); + return getMessageVibrate(context); + } + + return channel.shouldVibrate(); + } + + /** + * Sets the vibrate property for the default message channel. + */ + public static synchronized void updateMessageVibrate(@NonNull Context context, boolean enabled) { + if (!supported()) { + return; + } + Log.i(TAG, "Updating default vibrate with value: " + enabled); + + updateMessageChannel(context, channel -> channel.enableVibration(enabled)); + } + + /** + * Updates the message ringtone for a specific recipient. If that recipient has no channel, this + * does nothing. + * + * This has to update the database and should therefore be run on a background thread. + */ + @WorkerThread + public static synchronized void updateMessageVibrate(@NonNull Context context, @NonNull Recipient recipient, VibrateState vibrateState) { + if (!supported() || recipient.getNotificationChannel() == null) { + return ; + } + Log.i(TAG, "Updating recipient vibrate with value: " + vibrateState); + + boolean enabled = vibrateState == VibrateState.DEFAULT ? getMessageVibrate(context) : vibrateState == VibrateState.ENABLED; + String newChannelId = generateChannelIdFor(recipient); + boolean success = updateExistingChannel(ServiceUtil.getNotificationManager(context), + recipient.getNotificationChannel(), + newChannelId, + channel -> channel.enableVibration(enabled)); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), success ? newChannelId : null); + ensureCustomChannelConsistency(context); + } + + /** + * Whether or not the default messages notification channel is enabled. Note that "enabled" just + * means receiving notifications in some capacity -- a user could have it enabled, but set it to a + * lower importance. + * + * This could also return true if the specific channnel is enabled, but notifications *overall* + * are disabled. Check {@link #areNotificationsEnabled(Context)} to be safe. + */ + public static synchronized boolean isMessageChannelEnabled(@NonNull Context context) { + if (!supported()) { + return true; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + NotificationChannel channel = notificationManager.getNotificationChannel(getMessagesChannel(context)); + + return channel != null && channel.getImportance() != NotificationManager.IMPORTANCE_NONE; + } + + /** + * Whether or not notifications for the entire app are enabled. + */ + public static synchronized boolean areNotificationsEnabled(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 24) { + return ServiceUtil.getNotificationManager(context).areNotificationsEnabled(); + } else { + return true; + } + } + + /** + * Updates the name of an existing channel to match the recipient's current name. Will have no + * effect if the recipient doesn't have an existing valid channel. + */ + public static synchronized void updateContactChannelName(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported() || recipient.getNotificationChannel() == null) { + return; + } + Log.i(TAG, "Updating contact channel name"); + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + + if (notificationManager.getNotificationChannel(recipient.getNotificationChannel()) == null) { + Log.w(TAG, "Tried to update the name of a channel, but that channel doesn't exist."); + return; + } + + NotificationChannel channel = new NotificationChannel(recipient.getNotificationChannel(), + recipient.getDisplayName(context), + NotificationManager.IMPORTANCE_HIGH); + channel.setGroup(CATEGORY_MESSAGES); + notificationManager.createNotificationChannel(channel); + } + + @TargetApi(26) + @WorkerThread + public static synchronized void ensureCustomChannelConsistency(@NonNull Context context) { + if (!supported()) { + return; + } + Log.d(TAG, "ensureCustomChannelConsistency()"); + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + List customRecipients = new ArrayList<>(); + Set customChannelIds = new HashSet<>(); + + try (RecipientDatabase.RecipientReader reader = db.getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = reader.getNext()) != null) { + customRecipients.add(recipient); + customChannelIds.add(recipient.getNotificationChannel()); + } + } + + Set existingChannelIds = Stream.of(notificationManager.getNotificationChannels()).map(NotificationChannel::getId).collect(Collectors.toSet()); + + for (NotificationChannel existingChannel : notificationManager.getNotificationChannels()) { + if (existingChannel.getId().startsWith(CONTACT_PREFIX) && !customChannelIds.contains(existingChannel.getId())) { + Log.i(TAG, "Consistency: Deleting channel '"+ existingChannel.getId() + "' because the DB has no record of it."); + notificationManager.deleteNotificationChannel(existingChannel.getId()); + } else if (existingChannel.getId().startsWith(MESSAGES_PREFIX) && !existingChannel.getId().equals(getMessagesChannel(context))) { + Log.i(TAG, "Consistency: Deleting channel '"+ existingChannel.getId() + "' because it's out of date."); + notificationManager.deleteNotificationChannel(existingChannel.getId()); + } + } + + for (Recipient customRecipient : customRecipients) { + if (!existingChannelIds.contains(customRecipient.getNotificationChannel())) { + Log.i(TAG, "Consistency: Removing custom channel '"+ customRecipient.getNotificationChannel() + "' because the system doesn't have it."); + db.setNotificationChannel(customRecipient.getId(), null); + } + } + } + + @TargetApi(26) + private static void onCreate(@NonNull Context context, @NonNull NotificationManager notificationManager) { + NotificationChannelGroup messagesGroup = new NotificationChannelGroup(CATEGORY_MESSAGES, context.getResources().getString(R.string.NotificationChannel_group_messages)); + notificationManager.createNotificationChannelGroup(messagesGroup); + + NotificationChannel messages = new NotificationChannel(getMessagesChannel(context), context.getString(R.string.NotificationChannel_messages), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.NotificationChannel_calls), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel failures = new NotificationChannel(FAILURES, context.getString(R.string.NotificationChannel_failures), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel backups = new NotificationChannel(BACKUPS, context.getString(R.string.NotificationChannel_backups), NotificationManager.IMPORTANCE_LOW); + NotificationChannel lockedStatus = new NotificationChannel(LOCKED_STATUS, context.getString(R.string.NotificationChannel_locked_status), NotificationManager.IMPORTANCE_LOW); + NotificationChannel other = new NotificationChannel(OTHER, context.getString(R.string.NotificationChannel_other), NotificationManager.IMPORTANCE_LOW); + NotificationChannel voiceNotes = new NotificationChannel(VOICE_NOTES, context.getString(R.string.NotificationChannel_voice_notes), NotificationManager.IMPORTANCE_LOW); + + messages.setGroup(CATEGORY_MESSAGES); + messages.enableVibration(TextSecurePreferences.isNotificationVibrateEnabled(context)); + messages.setSound(TextSecurePreferences.getNotificationRingtone(context), getRingtoneAudioAttributes()); + setLedPreference(messages, TextSecurePreferences.getNotificationLedColor(context)); + + calls.setShowBadge(false); + backups.setShowBadge(false); + lockedStatus.setShowBadge(false); + other.setShowBadge(false); + other.setVibrationPattern(new long[]{0}); + other.enableVibration(true); + voiceNotes.setShowBadge(false); + + notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes)); + + if (BuildConfig.PLAY_STORE_DISABLED) { + NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_HIGH); + notificationManager.createNotificationChannel(appUpdates); + } else { + notificationManager.deleteNotificationChannel(APP_UPDATES); + } + } + + @TargetApi(26) + private static void onUpgrade(@NonNull NotificationManager notificationManager, int oldVersion, int newVersion) { + Log.i(TAG, "Upgrading channels from " + oldVersion + " to " + newVersion); + + if (oldVersion < Version.MESSAGES_CATEGORY) { + notificationManager.deleteNotificationChannel("messages"); + notificationManager.deleteNotificationChannel("calls"); + notificationManager.deleteNotificationChannel("locked_status"); + notificationManager.deleteNotificationChannel("backups"); + notificationManager.deleteNotificationChannel("other"); + } + + if (oldVersion < Version.CALLS_PRIORITY_BUMP) { + notificationManager.deleteNotificationChannel("calls_v2"); + } + + if (oldVersion < Version.VIBRATE_OFF_OTHER) { + notificationManager.deleteNotificationChannel("other_v2"); + } + } + + @TargetApi(26) + private static void setLedPreference(@NonNull NotificationChannel channel, @NonNull String ledColor) { + if ("none".equals(ledColor)) { + channel.enableLights(false); + } else { + channel.enableLights(true); + channel.setLightColor(Color.parseColor(ledColor)); + } + } + + + private static @NonNull String generateChannelIdFor(@NonNull Recipient recipient) { + return CONTACT_PREFIX + recipient.getId().serialize() + "_" + System.currentTimeMillis(); + } + + @TargetApi(26) + private static @NonNull NotificationChannel copyChannel(@NonNull NotificationChannel original, @NonNull String id) { + NotificationChannel copy = new NotificationChannel(id, original.getName(), original.getImportance()); + + copy.setGroup(original.getGroup()); + copy.setSound(original.getSound(), original.getAudioAttributes()); + copy.setBypassDnd(original.canBypassDnd()); + copy.setVibrationPattern(original.getVibrationPattern()); + copy.enableVibration(original.shouldVibrate()); + copy.setLockscreenVisibility(original.getLockscreenVisibility()); + copy.setShowBadge(original.canShowBadge()); + copy.setLightColor(original.getLightColor()); + copy.enableLights(original.shouldShowLights()); + + return copy; + } + + private static String getMessagesChannelId(int version) { + return MESSAGES_PREFIX + version; + } + + @WorkerThread + @TargetApi(26) + private static void updateAllRecipientChannelLedColors(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull String color) { + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + + try (RecipientDatabase.RecipientReader recipients = database.getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = recipients.getNext()) != null) { + assert recipient.getNotificationChannel() != null; + + String newChannelId = generateChannelIdFor(recipient); + boolean success = updateExistingChannel(notificationManager, recipient.getNotificationChannel(), newChannelId, channel -> setLedPreference(channel, color)); + + database.setNotificationChannel(recipient.getId(), success ? newChannelId : null); + } + } + + ensureCustomChannelConsistency(context); + } + + @TargetApi(26) + private static void updateMessageChannel(@NonNull Context context, @NonNull ChannelUpdater updater) { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + int existingVersion = TextSecurePreferences.getNotificationMessagesChannelVersion(context); + int newVersion = existingVersion + 1; + + Log.i(TAG, "Updating message channel from version " + existingVersion + " to " + newVersion); + if (updateExistingChannel(notificationManager, getMessagesChannelId(existingVersion), getMessagesChannelId(newVersion), updater)) { + TextSecurePreferences.setNotificationMessagesChannelVersion(context, newVersion); + } else { + onCreate(context, notificationManager); + } + } + + @TargetApi(26) + private static boolean updateExistingChannel(@NonNull NotificationManager notificationManager, + @NonNull String channelId, + @NonNull String newChannelId, + @NonNull ChannelUpdater updater) + { + NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId); + if (existingChannel == null) { + Log.w(TAG, "Tried to update a channel, but it didn't exist."); + return false; + } + + notificationManager.deleteNotificationChannel(existingChannel.getId()); + + NotificationChannel newChannel = copyChannel(existingChannel, newChannelId); + updater.update(newChannel); + notificationManager.createNotificationChannel(newChannel); + return true; + } + + @TargetApi(21) + private static AudioAttributes getRingtoneAudioAttributes() { + return new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build(); + } + + @TargetApi(26) + private static boolean channelExists(@Nullable NotificationChannel channel) { + return channel != null && !NotificationChannel.DEFAULT_CHANNEL_ID.equals(channel.getId()); + } + + private interface ChannelUpdater { + @TargetApi(26) + void update(@NonNull NotificationChannel channel); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java new file mode 100644 index 00000000..cafa8608 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.notifications; + +public final class NotificationIds { + + public static final int FCM_FAILURE = 12; + public static final int PENDING_MESSAGES = 1111; + public static final int MESSAGE_SUMMARY = 1338; + public static final int APPLICATION_MIGRATION = 4242; + public static final int SMS_IMPORT_COMPLETE = 31337; + public static final int PRE_REGISTRATION_SMS = 5050; + public static final int THREAD = 50000; + public static final int USER_NOTIFICATION_MIGRATION = 525600; + + private NotificationIds() { } + + public static int getNotificationIdForThread(long threadId) { + return THREAD + (int) threadId; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java new file mode 100644 index 00000000..66b679d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.TaskStackBuilder; + +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class NotificationItem { + + private final long id; + private final boolean mms; + @NonNull private final Recipient conversationRecipient; + @NonNull private final Recipient individualRecipient; + @Nullable private final Recipient threadRecipient; + private final long threadId; + @Nullable private final CharSequence text; + private final long timestamp; + private final long messageReceivedTimestamp; + @Nullable private final SlideDeck slideDeck; + private final boolean jumpToMessage; + private final boolean isJoin; + private final boolean canReply; + private final long notifiedTimestamp; + + public NotificationItem(long id, + boolean mms, + @NonNull Recipient individualRecipient, + @NonNull Recipient conversationRecipient, + @Nullable Recipient threadRecipient, + long threadId, + @Nullable CharSequence text, + long timestamp, + long messageReceivedTimestamp, + @Nullable SlideDeck slideDeck, + boolean jumpToMessage, + boolean isJoin, + boolean canReply, + long notifiedTimestamp) + { + this.id = id; + this.mms = mms; + this.individualRecipient = individualRecipient; + this.conversationRecipient = conversationRecipient; + this.threadRecipient = threadRecipient; + this.text = text; + this.threadId = threadId; + this.timestamp = timestamp; + this.messageReceivedTimestamp = messageReceivedTimestamp; + this.slideDeck = slideDeck; + this.jumpToMessage = jumpToMessage; + this.isJoin = isJoin; + this.canReply = canReply; + this.notifiedTimestamp = notifiedTimestamp; + } + + public @NonNull Recipient getRecipient() { + return threadRecipient == null ? conversationRecipient : threadRecipient; + } + + public @NonNull Recipient getIndividualRecipient() { + return individualRecipient; + } + + public @Nullable CharSequence getText() { + return text; + } + + public long getTimestamp() { + return timestamp; + } + + public long getThreadId() { + return threadId; + } + + public @Nullable SlideDeck getSlideDeck() { + return slideDeck; + } + + public PendingIntent getPendingIntent(Context context) { + Recipient recipient = threadRecipient != null ? threadRecipient : conversationRecipient; + int startingPosition = jumpToMessage ? getStartingPosition(context, threadId, messageReceivedTimestamp) : -1; + + Intent intent = ConversationIntents.createBuilder(context, recipient.getId(), threadId) + .withStartingPosition(startingPosition) + .build(); + + makeIntentUniqueToPreventMerging(intent); + + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public long getId() { + return id; + } + + public boolean isMms() { + return mms; + } + + public boolean isJoin() { + return isJoin; + } + + private static int getStartingPosition(@NonNull Context context, long threadId, long receivedTimestampMs) { + return DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionInConversation(threadId, receivedTimestampMs); + } + + private static void makeIntentUniqueToPreventMerging(@NonNull Intent intent) { + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + } + + public boolean canReply() { + return canReply; + } + + public long getNotifiedTimestamp() { + return notifiedTimestamp; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java new file mode 100644 index 00000000..be3d10ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -0,0 +1,213 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.TurnOffContactJoinedNotificationsActivity; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; + +public class NotificationState { + + private static final String TAG = NotificationState.class.getSimpleName(); + + private final Comparator notificationItemComparator = (a, b) -> -Long.compare(a.getTimestamp(), b.getTimestamp()); + private final List notifications = new LinkedList<>(); + private final LinkedHashSet threads = new LinkedHashSet<>(); + + public NotificationState() {} + + public NotificationState(@NonNull List items) { + for (NotificationItem item : items) { + addNotification(item); + } + } + + public void addNotification(NotificationItem item) { + notifications.add(item); + Collections.sort(notifications, notificationItemComparator); + + threads.remove(item.getThreadId()); + threads.add(item.getThreadId()); + } + + public @Nullable Uri getRingtone(@NonNull Context context) { + if (!notifications.isEmpty()) { + Recipient recipient = notifications.get(0).getRecipient(); + + if (recipient != null) { + return NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context, recipient) + : recipient.resolve().getMessageRingtone(); + } + } + + return null; + } + + public VibrateState getVibrate() { + if (!notifications.isEmpty()) { + Recipient recipient = notifications.get(0).getRecipient(); + + if (recipient != null) { + return recipient.resolve().getMessageVibrate(); + } + } + + return VibrateState.DEFAULT; + } + + public boolean hasMultipleThreads() { + return threads.size() > 1; + } + + public Collection getThreads() { + return threads; + } + + public int getThreadCount() { + return threads.size(); + } + + public int getMessageCount() { + return notifications.size(); + } + + public List getNotifications() { + return notifications; + } + + public List getNotificationsForThread(long threadId) { + List list = new LinkedList<>(); + + for (NotificationItem item : notifications) { + if (item.getThreadId() == threadId) list.add(item); + } + + Collections.sort(list, notificationItemComparator); + return list; + } + + public PendingIntent getTurnOffTheseNotificationsIntent(Context context) { + long threadId = threads.iterator().next(); + + return PendingIntent.getActivity(context, + 0, + TurnOffContactJoinedNotificationsActivity.newIntent(context, threadId), + PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getMarkAsReadIntent(Context context, int notificationId) { + long[] threadArray = new long[threads.size()]; + int index = 0; + StringBuilder threadString = new StringBuilder(); + + for (long thread : threads) { + threadString.append(thread).append(" "); + threadArray[index++] = thread; + } + + Log.i(TAG, "Added threads: " + threadString.toString()); + + Intent intent = new Intent(MarkReadReceiver.CLEAR_ACTION); + intent.setClass(context, MarkReadReceiver.class); + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.putExtra(MarkReadReceiver.THREAD_IDS_EXTRA, threadArray); + intent.putExtra(MarkReadReceiver.NOTIFICATION_ID_EXTRA, notificationId); + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getRemoteReplyIntent(Context context, Recipient recipient, ReplyMethod replyMethod) { + if (threads.size() != 1) throw new AssertionError("We only support replies to single thread notifications!"); + + Intent intent = new Intent(RemoteReplyReceiver.REPLY_ACTION); + intent.setClass(context, RemoteReplyReceiver.class); + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.putExtra(RemoteReplyReceiver.RECIPIENT_EXTRA, recipient.getId()); + intent.putExtra(RemoteReplyReceiver.REPLY_METHOD, replyMethod); + intent.setPackage(context.getPackageName()); + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getAndroidAutoReplyIntent(Context context, Recipient recipient) { + if (threads.size() != 1) throw new AssertionError("We only support replies to single thread notifications!"); + + Intent intent = new Intent(AndroidAutoReplyReceiver.REPLY_ACTION); + intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES); + intent.setClass(context, AndroidAutoReplyReceiver.class); + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.putExtra(AndroidAutoReplyReceiver.RECIPIENT_EXTRA, recipient.getId()); + intent.putExtra(AndroidAutoReplyReceiver.THREAD_ID_EXTRA, (long)threads.toArray()[0]); + intent.setPackage(context.getPackageName()); + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getAndroidAutoHeardIntent(Context context, int notificationId) { + long[] threadArray = new long[threads.size()]; + int index = 0; + for (long thread : threads) { + Log.i(TAG, "getAndroidAutoHeardIntent Added thread: " + thread); + threadArray[index++] = thread; + } + + Intent intent = new Intent(AndroidAutoHeardReceiver.HEARD_ACTION); + intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES); + intent.setClass(context, AndroidAutoHeardReceiver.class); + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.putExtra(AndroidAutoHeardReceiver.THREAD_IDS_EXTRA, threadArray); + intent.putExtra(AndroidAutoHeardReceiver.NOTIFICATION_ID_EXTRA, notificationId); + intent.setPackage(context.getPackageName()); + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getQuickReplyIntent(Context context, Recipient recipient) { + if (threads.size() != 1) throw new AssertionError("We only support replies to single thread notifications! " + threads.size()); + + Intent intent = ConversationIntents.createPopUpBuilder(context, recipient.getId(), (long) threads.toArray()[0]) + .withDataUri(Uri.parse("custom://"+System.currentTimeMillis())) + .build(); + + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public PendingIntent getDeleteIntent(Context context) { + int index = 0; + long[] ids = new long[notifications.size()]; + boolean[] mms = new boolean[ids.length]; + + for (NotificationItem notificationItem : notifications) { + ids[index] = notificationItem.getId(); + mms[index++] = notificationItem.isMms(); + } + + Intent intent = new Intent(context, DeleteNotificationReceiver.class); + intent.setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION); + intent.putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids); + intent.putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms); + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public boolean canReply() { + return notifications.size() >= 1 && notifications.get(0).canReply(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java new file mode 100644 index 00000000..6a345311 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.LeakyBucketLimiter; +import org.thoughtcrime.securesms.util.Util; + +/** + * Uses a leaky-bucket strategy to limiting notification updates. + */ +public class OptimizedMessageNotifier implements MessageNotifier { + + private final MessageNotifier wrapped; + private final LeakyBucketLimiter limiter; + + @MainThread + public OptimizedMessageNotifier(@NonNull MessageNotifier wrapped) { + this.wrapped = wrapped; + this.limiter = new LeakyBucketLimiter(5, 1000, new Handler(SignalExecutors.getAndStartHandlerThread("signal-notifier").getLooper())); + } + + @Override + public void setVisibleThread(long threadId) { + wrapped.setVisibleThread(threadId); + } + + @Override + public long getVisibleThread() { + return wrapped.getVisibleThread(); + } + + @Override + public void clearVisibleThread() { + wrapped.clearVisibleThread(); + } + + @Override + public void setLastDesktopActivityTimestamp(long timestamp) { + wrapped.setLastDesktopActivityTimestamp(timestamp); + } + + @Override + public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { + wrapped.notifyMessageDeliveryFailed(context, recipient, threadId); + } + + @Override + public void cancelDelayedNotifications() { + wrapped.cancelDelayedNotifications(); + } + + @Override + public void updateNotification(@NonNull Context context) { + runOnLimiter(() -> wrapped.updateNotification(context)); + } + + @Override + public void updateNotification(@NonNull Context context, long threadId) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId)); + } + + @Override + public void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId, defaultBubbleState)); + } + + @Override + public void updateNotification(@NonNull Context context, long threadId, boolean signal) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId, signal)); + } + + @Override + public void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId, signal, reminderCount, defaultBubbleState)); + } + + @Override + public void clearReminder(@NonNull Context context) { + wrapped.clearReminder(context); + } + + private void runOnLimiter(@NonNull Runnable runnable) { + Throwable prettyException = new Throwable(); + limiter.run(() -> { + try { + runnable.run(); + } catch (RuntimeException e) { + throw Util.appendStackTrace(e, prettyException); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java new file mode 100644 index 00000000..c07230d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.notifications; + + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.core.app.NotificationCompat; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class PendingMessageNotificationBuilder extends AbstractNotificationBuilder { + + public PendingMessageNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { + super(context, privacy); + + setSmallIcon(R.drawable.ic_notification); + setColor(context.getResources().getColor(R.color.core_ultramarine)); + setCategory(NotificationCompat.CATEGORY_MESSAGE); + + setContentTitle(context.getString(R.string.MessageNotifier_you_may_have_new_messages)); + setContentText(context.getString(R.string.MessageNotifier_open_signal_to_check_for_recent_notifications)); + setTicker(context.getString(R.string.MessageNotifier_open_signal_to_check_for_recent_notifications)); + + // TODO [greyson] Navigation + setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)); + setAutoCancel(true); + setAlarms(null, RecipientDatabase.VibrateState.DEFAULT); + + setOnlyAlertOnce(true); + + if (!NotificationChannels.supported()) { + setPriority(TextSecurePreferences.getNotificationPriority(context)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java new file mode 100644 index 00000000..36dc9daa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2016 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.notifications; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; + +import androidx.core.app.RemoteInput; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Get the response text from the Wearable Device and sends an message as a reply + */ +public class RemoteReplyReceiver extends BroadcastReceiver { + + public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); + public static final String REPLY_ACTION = "org.thoughtcrime.securesms.notifications.WEAR_REPLY"; + public static final String RECIPIENT_EXTRA = "recipient_extra"; + public static final String REPLY_METHOD = "reply_method"; + + @SuppressLint("StaticFieldLeak") + @Override + public void onReceive(final Context context, Intent intent) { + if (!REPLY_ACTION.equals(intent.getAction())) return; + + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + + if (remoteInput == null) return; + + final RecipientId recipientId = intent.getParcelableExtra(RECIPIENT_EXTRA); + final ReplyMethod replyMethod = (ReplyMethod) intent.getSerializableExtra(REPLY_METHOD); + final CharSequence responseText = remoteInput.getCharSequence(DefaultMessageNotifier.EXTRA_REMOTE_REPLY); + + if (recipientId == null) throw new AssertionError("No recipientId specified"); + if (replyMethod == null) throw new AssertionError("No reply method specified"); + + if (responseText != null) { + SignalExecutors.BOUNDED.execute(() -> { + long threadId; + + Recipient recipient = Recipient.resolved(recipientId); + int subscriptionId = recipient.getDefaultSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + + switch (replyMethod) { + case GroupMessage: { + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, + responseText.toString(), + new LinkedList<>(), + System.currentTimeMillis(), + subscriptionId, + expiresIn, + false, + 0, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + threadId = MessageSender.send(context, reply, -1, false, null); + break; + } + case SecureMessage: { + OutgoingEncryptedMessage reply = new OutgoingEncryptedMessage(recipient, responseText.toString(), expiresIn); + threadId = MessageSender.send(context, reply, -1, false, null); + break; + } + case UnsecuredSmsMessage: { + OutgoingTextMessage reply = new OutgoingTextMessage(recipient, responseText.toString(), expiresIn, subscriptionId); + threadId = MessageSender.send(context, reply, -1, true, null); + break; + } + default: + throw new AssertionError("Unknown Reply method"); + } + + List messageIds = DatabaseFactory.getThreadDatabase(context).setRead(threadId, true); + + ApplicationDependencies.getMessageNotifier().updateNotification(context); + MarkReadReceiver.process(context, messageIds); + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java new file mode 100644 index 00000000..84df213a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/ReplyMethod.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.notifications; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public enum ReplyMethod { + + GroupMessage, + SecureMessage, + UnsecuredSmsMessage; + + public static @NonNull ReplyMethod forRecipient(Context context, Recipient recipient) { + if (recipient.isGroup()) { + return ReplyMethod.GroupMessage; + } else if (TextSecurePreferences.isPushRegistered(context) && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isForceSmsSelection()) { + return ReplyMethod.SecureMessage; + } else { + return ReplyMethod.UnsecuredSmsMessage; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java new file mode 100644 index 00000000..3cc449fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -0,0 +1,438 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.text.SpannableStringBuilder; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.Action; +import androidx.core.app.Person; +import androidx.core.app.RemoteInput; +import androidx.core.graphics.drawable.IconCompat; + +import com.annimon.stream.Stream; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class SingleRecipientNotificationBuilder extends AbstractNotificationBuilder { + + private static final String TAG = SingleRecipientNotificationBuilder.class.getSimpleName(); + + private static final int BIG_PICTURE_DIMEN = 500; + private static final int LARGE_ICON_DIMEN = 250; + + private final List messages = new LinkedList<>(); + + private SlideDeck slideDeck; + private CharSequence contentTitle; + private CharSequence contentText; + private Recipient threadRecipient; + private BubbleUtil.BubbleState defaultBubbleState; + + public SingleRecipientNotificationBuilder(@NonNull Context context, @NonNull NotificationPrivacyPreference privacy) + { + super(new ContextThemeWrapper(context, R.style.TextSecure_LightTheme), privacy); + + setSmallIcon(R.drawable.ic_notification); + setColor(context.getResources().getColor(R.color.core_ultramarine)); + setCategory(NotificationCompat.CATEGORY_MESSAGE); + + if (!NotificationChannels.supported()) { + setPriority(TextSecurePreferences.getNotificationPriority(context)); + } + } + + public void setThread(@NonNull Recipient recipient) { + String channelId = recipient.getNotificationChannel(); + setChannelId(channelId != null ? channelId : NotificationChannels.getMessagesChannel(context)); + + if (privacy.isDisplayContact()) { + setContentTitle(recipient.getDisplayName(context)); + + if (recipient.getContactUri() != null) { + addPerson(recipient.getContactUri().toString()); + } + + setLargeIcon(getContactDrawable(recipient)); + + } else { + setContentTitle(context.getString(R.string.SingleRecipientNotificationBuilder_signal)); + setLargeIcon(new GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context))); + } + + setShortcutId(ConversationUtil.getShortcutId(recipient)); + } + + private Drawable getContactDrawable(@NonNull Recipient recipient) { + ContactPhoto contactPhoto = recipient.getContactPhoto(); + FallbackContactPhoto fallbackContactPhoto = recipient.getFallbackContactPhoto(); + + if (contactPhoto != null) { + try { + return GlideApp.with(context.getApplicationContext()) + .load(contactPhoto) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) + .get(); + } catch (InterruptedException | ExecutionException e) { + return fallbackContactPhoto.asDrawable(context, recipient.getColor().toConversationColor(context)); + } + } else { + return fallbackContactPhoto.asDrawable(context, recipient.getColor().toConversationColor(context)); + } + } + + public void setMessageCount(int messageCount) { + setContentInfo(String.valueOf(messageCount)); + setNumber(messageCount); + } + + public void setPrimaryMessageBody(@NonNull Recipient threadRecipients, + @NonNull Recipient individualRecipient, + @NonNull CharSequence message, + @Nullable SlideDeck slideDeck) + { + SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); + + if (privacy.isDisplayContact() && threadRecipients.isGroup()) { + stringBuilder.append(Util.getBoldedString(individualRecipient.getDisplayName(context) + ": ")); + } + + if (privacy.isDisplayMessage()) { + setContentText(stringBuilder.append(message)); + this.slideDeck = slideDeck; + } else { + setContentText(stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message))); + } + } + + public void addAndroidAutoAction(@NonNull PendingIntent androidAutoReplyIntent, + @NonNull PendingIntent androidAutoHeardIntent, long timestamp) + { + + if (contentTitle == null || contentText == null) + return; + + RemoteInput remoteInput = new RemoteInput.Builder(AndroidAutoReplyReceiver.VOICE_REPLY_KEY) + .setLabel(context.getString(R.string.MessageNotifier_reply)) + .build(); + + NotificationCompat.CarExtender.UnreadConversation.Builder unreadConversationBuilder = + new NotificationCompat.CarExtender.UnreadConversation.Builder(contentTitle.toString()) + .addMessage(contentText.toString()) + .setLatestTimestamp(timestamp) + .setReadPendingIntent(androidAutoHeardIntent) + .setReplyAction(androidAutoReplyIntent, remoteInput); + + extend(new NotificationCompat.CarExtender().setUnreadConversation(unreadConversationBuilder.build())); + } + + public void addTurnOffTheseNotificationsAction(@NonNull PendingIntent turnOffTheseNotificationsIntent) { + Action turnOffTheseNotifications = new Action(R.drawable.check, + context.getString(R.string.MessageNotifier_turn_off_these_notifications), + turnOffTheseNotificationsIntent); + + addAction(turnOffTheseNotifications); + } + + public void addActions(@NonNull PendingIntent markReadIntent, + @NonNull PendingIntent quickReplyIntent, + @NonNull PendingIntent wearableReplyIntent, + @NonNull ReplyMethod replyMethod, + boolean replyEnabled) + { + NotificationCompat.WearableExtender extender = new NotificationCompat.WearableExtender(); + Action markAsReadAction = new Action(R.drawable.check, + context.getString(R.string.MessageNotifier_mark_read), + markReadIntent); + + addAction(markAsReadAction); + extender.addAction(markAsReadAction); + + if (replyEnabled) { + String actionName = context.getString(R.string.MessageNotifier_reply); + String label = context.getString(replyMethodLongDescription(replyMethod)); + + Action replyAction = new Action(R.drawable.ic_reply_white_36dp, + actionName, + quickReplyIntent); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + replyAction = new Action.Builder(R.drawable.ic_reply_white_36dp, + actionName, + wearableReplyIntent) + .addRemoteInput(new RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY) + .setLabel(label) + .build()) + .build(); + } + + Action wearableReplyAction = new Action.Builder(R.drawable.ic_reply, + actionName, + wearableReplyIntent) + .addRemoteInput(new RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY) + .setLabel(label) + .build()) + .build(); + + addAction(replyAction); + extend(extender.addAction(wearableReplyAction)); + } + } + + @StringRes + private static int replyMethodLongDescription(@NonNull ReplyMethod replyMethod) { + switch (replyMethod) { + case GroupMessage: + return R.string.MessageNotifier_reply; + case SecureMessage: + return R.string.MessageNotifier_signal_message; + case UnsecuredSmsMessage: + return R.string.MessageNotifier_unsecured_sms; + default: + return R.string.MessageNotifier_reply; + } + } + + public void addMessageBody(@NonNull Recipient threadRecipient, + @NonNull Recipient individualRecipient, + @Nullable CharSequence messageBody, + long timestamp, + @Nullable SlideDeck slideDeck) + { + SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); + Person.Builder personBuilder = new Person.Builder() + .setKey(ConversationUtil.getShortcutId(individualRecipient)) + .setBot(false); + + this.threadRecipient = threadRecipient; + + if (privacy.isDisplayContact()) { + personBuilder.setName(individualRecipient.getDisplayName(context)); + personBuilder.setUri(individualRecipient.isSystemContact() ? individualRecipient.getContactUri().toString() : null); + + Bitmap bitmap = getLargeBitmap(getContactDrawable(individualRecipient)); + if (bitmap != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(bitmap)); + } + } else { + personBuilder.setName(""); + } + + final CharSequence text; + if (privacy.isDisplayMessage()) { + text = messageBody == null ? "" : messageBody; + } else { + text = stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message)); + } + + Uri dataUri = null; + String mimeType = null; + + if (slideDeck != null && slideDeck.getThumbnailSlide() != null) { + Slide thumbnail = slideDeck.getThumbnailSlide(); + + dataUri = thumbnail.getUri(); + mimeType = thumbnail.getContentType(); + } + + messages.add(new NotificationCompat.MessagingStyle.Message(text, timestamp, personBuilder.build()).setData(mimeType, dataUri)); + } + + public void setDefaultBubbleState(@NonNull BubbleUtil.BubbleState bubbleState) { + this.defaultBubbleState = bubbleState; + } + + @Override + public Notification build() { + if (privacy.isDisplayMessage()) { + Optional largeIconUri = getLargeIconUri(slideDeck); + Optional bigPictureUri = getBigPictureUri(slideDeck); + + if (messages.size() == 1 && largeIconUri.isPresent()) { + setLargeIcon(getNotificationPicture(largeIconUri.get(), LARGE_ICON_DIMEN)); + } + + if (messages.size() == 1 && bigPictureUri.isPresent() && Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + setStyle(new NotificationCompat.BigPictureStyle() + .bigPicture(getNotificationPicture(bigPictureUri.get(), BIG_PICTURE_DIMEN)) + .setSummaryText(getBigText())); + } else { + if (Build.VERSION.SDK_INT >= 24) { + applyMessageStyle(); + + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + applyBubbleMetadata(); + } + } else { + applyLegacy(); + } + } + } + + return super.build(); + } + + private void applyMessageStyle() { + ConversationUtil.pushShortcutForRecipientIfNeededSync(context, threadRecipient); + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(ConversationUtil.buildPersonCompat(context, Recipient.self())); + + if (threadRecipient.isGroup()) { + if (privacy.isDisplayContact()) { + messagingStyle.setConversationTitle(threadRecipient.getDisplayName(context)); + } else { + messagingStyle.setConversationTitle(context.getString(R.string.SingleRecipientNotificationBuilder_signal)); + } + + messagingStyle.setGroupConversation(true); + } + + Stream.of(messages).forEach(messagingStyle::addMessage); + setStyle(messagingStyle); + } + + private void applyBubbleMetadata() { + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(threadRecipient); + PendingIntent intent = PendingIntent.getActivity(context, 0, ConversationIntents.createBubbleIntent(context, threadRecipient.getId(), threadId), 0); + NotificationCompat.BubbleMetadata bubbleMetadata = new NotificationCompat.BubbleMetadata.Builder() + .setAutoExpandBubble(defaultBubbleState == BubbleUtil.BubbleState.SHOWN) + .setDesiredHeight(600) + .setIcon(AvatarUtil.getIconCompatForShortcut(context, threadRecipient)) + .setSuppressNotification(defaultBubbleState == BubbleUtil.BubbleState.SHOWN) + .setIntent(intent) + .build(); + setBubbleMetadata(bubbleMetadata); + } + + private void applyLegacy() { + setStyle(new NotificationCompat.BigTextStyle().bigText(getBigText())); + } + + private void setLargeIcon(@Nullable Drawable drawable) { + if (drawable != null) { + setLargeIcon(getLargeBitmap(drawable)); + } + } + + private @Nullable Bitmap getLargeBitmap(@Nullable Drawable drawable) { + if (drawable != null) { + int largeIconTargetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); + + return BitmapUtil.createFromDrawable(drawable, largeIconTargetSize, largeIconTargetSize); + } + + return null; + } + + private static Optional getLargeIconUri(@Nullable SlideDeck slideDeck) { + if (slideDeck == null) { + return Optional.absent(); + } + + Slide thumbnailSlide = Optional.fromNullable(slideDeck.getThumbnailSlide()).or(Optional.fromNullable(slideDeck.getStickerSlide())).orNull(); + return getThumbnailUri(thumbnailSlide); + } + + private static Optional getBigPictureUri(@Nullable SlideDeck slideDeck) { + if (slideDeck == null) { + return Optional.absent(); + } + + Slide thumbnailSlide = slideDeck.getThumbnailSlide(); + return getThumbnailUri(thumbnailSlide); + } + + private static Optional getThumbnailUri(@Nullable Slide slide) { + if (slide != null && !slide.isInProgress() && slide.getUri() != null) { + return Optional.of(slide.getUri()); + } else { + return Optional.absent(); + } + } + + private Bitmap getNotificationPicture(@NonNull Uri uri, int dimension) + { + try { + return GlideApp.with(context.getApplicationContext()) + .asBitmap() + .load(new DecryptableStreamUriLoader.DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit(dimension, dimension) + .get(); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, e); + return Bitmap.createBitmap(dimension, dimension, Bitmap.Config.RGB_565); + } + } + + @Override + public NotificationCompat.Builder setContentTitle(CharSequence contentTitle) { + this.contentTitle = contentTitle; + return super.setContentTitle(contentTitle); + } + + public NotificationCompat.Builder setContentText(CharSequence contentText) { + this.contentText = trimToDisplayLength(contentText); + return super.setContentText(this.contentText); + } + + private CharSequence getBigText() { + SpannableStringBuilder content = new SpannableStringBuilder(); + + for (int i = 0; i < messages.size(); i++) { + content.append(getBigTextFor(messages.get(i))); + if (i < messages.size() - 1) { + content.append('\n'); + } + } + + return content; + } + + private CharSequence getBigTextFor(NotificationCompat.MessagingStyle.Message message) { + SpannableStringBuilder content = new SpannableStringBuilder(); + + if (message.getPerson() != null && message.getPerson().getName() != null && threadRecipient.isGroup()) { + content.append(Util.getBoldedString(message.getPerson().getName().toString())).append(": "); + } + + return trimToDisplayLength(content.append(message.getText())); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java new file mode 100644 index 00000000..05b8ec88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -0,0 +1,359 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.ViewGroup; +import android.view.WindowManager; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.annimon.stream.Stream; +import com.annimon.stream.function.Consumer; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.lang.ref.WeakReference; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class Permissions { + + private static final Map OUTSTANDING = new LRUCache<>(2); + + public static PermissionsBuilder with(@NonNull Activity activity) { + return new PermissionsBuilder(new ActivityPermissionObject(activity)); + } + + public static PermissionsBuilder with(@NonNull Fragment fragment) { + return new PermissionsBuilder(new FragmentPermissionObject(fragment)); + } + + public static class PermissionsBuilder { + + private final PermissionObject permissionObject; + + private String[] requestedPermissions; + + private Runnable allGrantedListener; + + private Runnable anyDeniedListener; + private Runnable anyPermanentlyDeniedListener; + private Runnable anyResultListener; + + private Consumer> someGrantedListener; + private Consumer> someDeniedListener; + private Consumer> somePermanentlyDeniedListener; + + private @DrawableRes int[] rationalDialogHeader; + private String rationaleDialogMessage; + + private boolean ifNecesary; + + private boolean condition = true; + + PermissionsBuilder(PermissionObject permissionObject) { + this.permissionObject = permissionObject; + } + + public PermissionsBuilder request(String... requestedPermissions) { + this.requestedPermissions = requestedPermissions; + return this; + } + + public PermissionsBuilder ifNecessary() { + this.ifNecesary = true; + return this; + } + + public PermissionsBuilder ifNecessary(boolean condition) { + this.ifNecesary = true; + this.condition = condition; + return this; + } + + public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) { + this.rationalDialogHeader = headers; + this.rationaleDialogMessage = message; + return this; + } + + public PermissionsBuilder withPermanentDenialDialog(@NonNull String message) { + return onAnyPermanentlyDenied(new SettingsDialogListener(permissionObject.getContext(), message)); + } + + public PermissionsBuilder onAllGranted(Runnable allGrantedListener) { + this.allGrantedListener = allGrantedListener; + return this; + } + + public PermissionsBuilder onAnyDenied(Runnable anyDeniedListener) { + this.anyDeniedListener = anyDeniedListener; + return this; + } + + @SuppressWarnings("WeakerAccess") + public PermissionsBuilder onAnyPermanentlyDenied(Runnable anyPermanentlyDeniedListener) { + this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener; + return this; + } + + public PermissionsBuilder onAnyResult(Runnable anyResultListener) { + this.anyResultListener = anyResultListener; + return this; + } + + public PermissionsBuilder onSomeGranted(Consumer> someGrantedListener) { + this.someGrantedListener = someGrantedListener; + return this; + } + + public PermissionsBuilder onSomeDenied(Consumer> someDeniedListener) { + this.someDeniedListener = someDeniedListener; + return this; + } + + public PermissionsBuilder onSomePermanentlyDenied(Consumer> somePermanentlyDeniedListener) { + this.somePermanentlyDeniedListener = somePermanentlyDeniedListener; + return this; + } + + public void execute() { + PermissionsRequest request = new PermissionsRequest(allGrantedListener, anyDeniedListener, anyPermanentlyDeniedListener, anyResultListener, + someGrantedListener, someDeniedListener, somePermanentlyDeniedListener); + + if (ifNecesary && (permissionObject.hasAll(requestedPermissions) || !condition)) { + executePreGrantedPermissionsRequest(request); + } else if (rationaleDialogMessage != null && rationalDialogHeader != null) { + executePermissionsRequestWithRationale(request); + } else { + executePermissionsRequest(request); + } + } + + private void executePreGrantedPermissionsRequest(PermissionsRequest request) { + int[] grantResults = new int[requestedPermissions.length]; + for (int i=0;i executePermissionsRequest(request)) + .setNegativeButton(R.string.Permissions_not_now, (dialog, which) -> executeNoPermissionsRequest(request)) + .show() + .getWindow() + .setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private void executePermissionsRequest(PermissionsRequest request) { + int requestCode = new SecureRandom().nextInt(65434) + 100; + + synchronized (OUTSTANDING) { + OUTSTANDING.put(requestCode, request); + } + + for (String permission : requestedPermissions) { + request.addMapping(permission, permissionObject.shouldShouldPermissionRationale(permission)); + } + + permissionObject.requestPermissions(requestCode, requestedPermissions); + } + + private void executeNoPermissionsRequest(PermissionsRequest request) { + for (String permission : requestedPermissions) { + request.addMapping(permission, true); + } + + String[] permissions = filterNotGranted(permissionObject.getContext(), requestedPermissions); + int[] grantResults = Stream.of(permissions).mapToInt(permission -> PackageManager.PERMISSION_DENIED).toArray(); + boolean[] showDialog = new boolean[permissions.length]; + Arrays.fill(showDialog, true); + + request.onResult(permissions, grantResults, showDialog); + } + + } + + private static void requestPermissions(@NonNull Activity activity, int requestCode, String... permissions) { + ActivityCompat.requestPermissions(activity, filterNotGranted(activity, permissions), requestCode); + } + + private static void requestPermissions(@NonNull Fragment fragment, int requestCode, String... permissions) { + fragment.requestPermissions(filterNotGranted(fragment.getContext(), permissions), requestCode); + } + + private static String[] filterNotGranted(@NonNull Context context, String... permissions) { + return Stream.of(permissions) + .filter(permission -> ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) + .toList() + .toArray(new String[0]); + } + + public static boolean hasAny(@NonNull Context context, String... permissions) { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + Stream.of(permissions).anyMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED); + + } + + public static boolean hasAll(@NonNull Context context, String... permissions) { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + Stream.of(permissions).allMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED); + + } + + public static void onRequestPermissionsResult(Fragment fragment, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + onRequestPermissionsResult(new FragmentPermissionObject(fragment), requestCode, permissions, grantResults); + } + + public static void onRequestPermissionsResult(Activity activity, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + onRequestPermissionsResult(new ActivityPermissionObject(activity), requestCode, permissions, grantResults); + } + + private static void onRequestPermissionsResult(@NonNull PermissionObject context, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + PermissionsRequest resultListener; + + synchronized (OUTSTANDING) { + resultListener = OUTSTANDING.remove(requestCode); + } + + if (resultListener == null) return; + + boolean[] shouldShowRationaleDialog = new boolean[permissions.length]; + + for (int i=0;i context; + private final String message; + + SettingsDialogListener(Context context, String message) { + this.message = message; + this.context = new WeakReference<>(context); + } + + @Override + public void run() { + Context context = this.context.get(); + + if (context != null) { + new AlertDialog.Builder(context) + .setTitle(R.string.Permissions_permission_required) + .setMessage(message) + .setPositiveButton(R.string.Permissions_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java new file mode 100644 index 00000000..3c008fc1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionsRequest.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.content.pm.PackageManager; + +import androidx.annotation.Nullable; + +import com.annimon.stream.function.Consumer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class PermissionsRequest { + + private final Map PRE_REQUEST_MAPPING = new HashMap<>(); + + private final @Nullable Runnable allGrantedListener; + + private final @Nullable Runnable anyDeniedListener; + private final @Nullable Runnable anyPermanentlyDeniedListener; + private final @Nullable Runnable anyResultListener; + + private final @Nullable Consumer> someGrantedListener; + private final @Nullable Consumer> someDeniedListener; + private final @Nullable Consumer> somePermanentlyDeniedListener; + + PermissionsRequest(@Nullable Runnable allGrantedListener, + @Nullable Runnable anyDeniedListener, + @Nullable Runnable anyPermanentlyDeniedListener, + @Nullable Runnable anyResultListener, + @Nullable Consumer> someGrantedListener, + @Nullable Consumer> someDeniedListener, + @Nullable Consumer> somePermanentlyDeniedListener) + { + this.allGrantedListener = allGrantedListener; + + this.anyDeniedListener = anyDeniedListener; + this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener; + this.anyResultListener = anyResultListener; + + this.someGrantedListener = someGrantedListener; + this.someDeniedListener = someDeniedListener; + this.somePermanentlyDeniedListener = somePermanentlyDeniedListener; + } + + void onResult(String[] permissions, int[] grantResults, boolean[] shouldShowRationaleDialog) { + List granted = new ArrayList<>(permissions.length); + List denied = new ArrayList<>(permissions.length); + List permanentlyDenied = new ArrayList<>(permissions.length); + + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + granted.add(permissions[i]); + } else { + boolean preRequestShouldShowRationaleDialog = PRE_REQUEST_MAPPING.get(permissions[i]); + + if ((somePermanentlyDeniedListener != null || anyPermanentlyDeniedListener != null) && + !preRequestShouldShowRationaleDialog && !shouldShowRationaleDialog[i]) + { + permanentlyDenied.add(permissions[i]); + } else { + denied.add(permissions[i]); + } + } + } + + if (allGrantedListener != null && granted.size() > 0 && (denied.size() == 0 && permanentlyDenied.size() == 0)) { + allGrantedListener.run(); + } else if (someGrantedListener != null && granted.size() > 0) { + someGrantedListener.accept(granted); + } + + if (denied.size() > 0) { + if (anyDeniedListener != null) anyDeniedListener.run(); + if (someDeniedListener != null) someDeniedListener.accept(denied); + } + + if (permanentlyDenied.size() > 0) { + if (anyPermanentlyDeniedListener != null) anyPermanentlyDeniedListener.run(); + if (somePermanentlyDeniedListener != null) somePermanentlyDeniedListener.accept(permanentlyDenied); + } + + if (anyResultListener != null) { + anyResultListener.run(); + } + } + + void addMapping(String permission, boolean shouldShowRationaleDialog) { + PRE_REQUEST_MAPPING.put(permission, shouldShowRationaleDialog); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java new file mode 100644 index 00000000..7cc1e76f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.permissions; + + +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class RationaleDialog { + + public static AlertDialog.Builder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) { + View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null); + ViewGroup header = view.findViewById(R.id.header_container); + TextView text = view.findViewById(R.id.message); + + for (int i=0;i. + */ +package org.thoughtcrime.securesms.phonenumbers; + +import android.telephony.PhoneNumberUtils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class NumberUtil { + + private static final Pattern EMAIL_PATTERN = android.util.Patterns.EMAIL_ADDRESS; + private static final Pattern PHONE_PATTERN = android.util.Patterns.PHONE; + + public static boolean isValidEmail(String number) { + Matcher matcher = EMAIL_PATTERN.matcher(number); + return matcher.matches(); + } + + public static boolean isVisuallyValidNumber(String number) { + Matcher matcher = PHONE_PATTERN.matcher(number); + return matcher.matches(); + } + + /** + * Whether or not a number entered by the user is a valid phone or email address. Differs from + * {@link #isValidSmsOrEmail(String)} in that it only returns true for numbers that a user would + * enter themselves, as opposed to the crazy network prefixes that could theoretically be in an + * SMS address. + */ + public static boolean isVisuallyValidNumberOrEmail(String number) { + return isVisuallyValidNumber(number) || isValidEmail(number); + } + + public static boolean isValidSmsOrEmail(String number) { + return PhoneNumberUtils.isWellFormedSmsAddress(number) || isValidEmail(number); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java new file mode 100644 index 00000000..cff9a53a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.phonenumbers; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.i18n.phonenumbers.ShortNumberInfo; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PhoneNumberFormatter { + + private static final String TAG = PhoneNumberFormatter.class.getSimpleName(); + + private static final Set SHORT_COUNTRIES = new HashSet() {{ + add("NU"); + add("TK"); + add("NC"); + add("AC"); + }}; + + private static final Set NATIONAL_FORMAT_COUNTRY_CODES = new HashSet<>(Arrays.asList( + 1, // US + 44 // UK + )); + + private static final Pattern US_NO_AREACODE = Pattern.compile("^(\\d{7})$"); + private static final Pattern BR_NO_AREACODE = Pattern.compile("^(9?\\d{8})$"); + + private static final AtomicReference> cachedFormatter = new AtomicReference<>(); + + private final Optional localNumber; + private final String localCountryCode; + + private final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + private final Pattern ALPHA_PATTERN = Pattern.compile("[a-zA-Z]"); + + public static @NonNull PhoneNumberFormatter get(Context context) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + + if (!TextUtils.isEmpty(localNumber)) { + Pair cached = cachedFormatter.get(); + + if (cached != null && cached.first().equals(localNumber)) return cached.second(); + + PhoneNumberFormatter formatter = new PhoneNumberFormatter(localNumber); + cachedFormatter.set(new Pair<>(localNumber, formatter)); + + return formatter; + } else { + return new PhoneNumberFormatter(Util.getSimCountryIso(context).or("US"), true); + } + } + + PhoneNumberFormatter(@NonNull String localNumberString) { + try { + Phonenumber.PhoneNumber libNumber = phoneNumberUtil.parse(localNumberString, null); + int countryCode = libNumber.getCountryCode(); + + this.localNumber = Optional.of(new PhoneNumber(localNumberString, countryCode, parseAreaCode(localNumberString, countryCode))); + this.localCountryCode = phoneNumberUtil.getRegionCodeForNumber(libNumber); + } catch (NumberParseException e) { + throw new AssertionError(e); + } + } + + PhoneNumberFormatter(@NonNull String localCountryCode, boolean countryCode) { + this.localNumber = Optional.absent(); + this.localCountryCode = localCountryCode; + } + + public static @NonNull String prettyPrint(@NonNull String e164) { + return get(ApplicationDependencies.getApplication()).prettyPrintFormat(e164); + } + + public @NonNull String prettyPrintFormat(@NonNull String e164) { + try { + Phonenumber.PhoneNumber parsedNumber = phoneNumberUtil.parse(e164, localCountryCode); + + if (localNumber.isPresent() && + localNumber.get().countryCode == parsedNumber.getCountryCode() && + NATIONAL_FORMAT_COUNTRY_CODES.contains(localNumber.get().getCountryCode())) + { + return StringUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); + } else { + return StringUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)); + } + } catch (NumberParseException e) { + Log.w(TAG, "Failed to format number."); + return StringUtil.isolateBidi(e164); + } + } + + public static int getLocalCountryCode() { + Optional localNumber = get(ApplicationDependencies.getApplication()).localNumber; + return localNumber != null && localNumber.isPresent() ? localNumber.get().countryCode : 0; + } + + + public String format(@Nullable String number) { + if (number == null) return "Unknown"; + if (GroupId.isEncodedGroup(number)) return number; + if (ALPHA_PATTERN.matcher(number).find()) return number.trim(); + + String bareNumber = number.replaceAll("[^0-9+]", ""); + + if (bareNumber.length() == 0) { + if (number.trim().length() == 0) return "Unknown"; + else return number.trim(); + } + + // libphonenumber doesn't seem to be correct for Germany and Finland + if (bareNumber.length() <= 6 && ("DE".equals(localCountryCode) || "FI".equals(localCountryCode) || "SK".equals(localCountryCode))) { + return bareNumber; + } + + // libphonenumber seems incorrect for Russia and a few other countries with 4 digit short codes. + if (bareNumber.length() <= 4 && !SHORT_COUNTRIES.contains(localCountryCode)) { + return bareNumber; + } + + if (isShortCode(bareNumber, localCountryCode)) { + return bareNumber; + } + + String processedNumber = applyAreaCodeRules(localNumber, bareNumber); + + try { + Phonenumber.PhoneNumber parsedNumber = phoneNumberUtil.parse(processedNumber, localCountryCode); + return phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } catch (NumberParseException e) { + Log.w(TAG, e); + if (bareNumber.charAt(0) == '+') + return bareNumber; + + String localNumberImprecise = localNumber.isPresent() ? localNumber.get().getE164Number() : ""; + + if (localNumberImprecise.charAt(0) == '+') + localNumberImprecise = localNumberImprecise.substring(1); + + if (localNumberImprecise.length() == bareNumber.length() || bareNumber.length() > localNumberImprecise.length()) + return "+" + number; + + int difference = localNumberImprecise.length() - bareNumber.length(); + + return "+" + localNumberImprecise.substring(0, difference) + bareNumber; + } + } + + private boolean isShortCode(@NonNull String bareNumber, String localCountryCode) { + try { + Phonenumber.PhoneNumber parsedNumber = phoneNumberUtil.parse(bareNumber, localCountryCode); + return ShortNumberInfo.getInstance().isPossibleShortNumberForRegion(parsedNumber, localCountryCode); + } catch (NumberParseException e) { + return false; + } + } + + private @Nullable String parseAreaCode(@NonNull String e164Number, int countryCode) { + switch (countryCode) { + case 1: + return e164Number.substring(2, 5); + case 55: + return e164Number.substring(3, 5); + } + return null; + } + + + private @NonNull String applyAreaCodeRules(@NonNull Optional localNumber, @NonNull String testNumber) { + if (!localNumber.isPresent() || !localNumber.get().getAreaCode().isPresent()) { + return testNumber; + } + + Matcher matcher; + switch (localNumber.get().getCountryCode()) { + case 1: + matcher = US_NO_AREACODE.matcher(testNumber); + if (matcher.matches()) { + return localNumber.get().getAreaCode() + matcher.group(); + } + break; + + case 55: + matcher = BR_NO_AREACODE.matcher(testNumber); + if (matcher.matches()) { + return localNumber.get().getAreaCode() + matcher.group(); + } + } + return testNumber; + } + + private static class PhoneNumber { + private final String e164Number; + private final int countryCode; + private final Optional areaCode; + + PhoneNumber(String e164Number, int countryCode, @Nullable String areaCode) { + this.e164Number = e164Number; + this.countryCode = countryCode; + this.areaCode = Optional.fromNullable(areaCode); + } + + String getE164Number() { + return e164Number; + } + + int getCountryCode() { + return countryCode; + } + + Optional getAreaCode() { + return areaCode; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/KbsEnclaves.java b/app/src/main/java/org/thoughtcrime/securesms/pin/KbsEnclaves.java new file mode 100644 index 00000000..df0d209e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/KbsEnclaves.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.pin; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.KbsEnclave; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class KbsEnclaves { + + public static @NonNull KbsEnclave current() { + return BuildConfig.KBS_ENCLAVE; + } + + public static @NonNull List all() { + return Util.join(Collections.singletonList(BuildConfig.KBS_ENCLAVE), fallbacks()); + } + + public static @NonNull List fallbacks() { + return Arrays.asList(BuildConfig.KBS_FALLBACKS); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinOptOutDialog.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinOptOutDialog.java new file mode 100644 index 00000000..bc8eee21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinOptOutDialog.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.pin; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +public final class PinOptOutDialog { + + private static final String TAG = Log.tag(PinOptOutDialog.class); + + public static void show(@NonNull Context context, @NonNull Runnable onSuccess) { + Log.i(TAG, "show()"); + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(R.string.PinOptOutDialog_warning) + .setMessage(R.string.PinOptOutDialog_if_you_disable_the_pin_you_will_lose_all_data) + .setCancelable(true) + .setPositiveButton(R.string.PinOptOutDialog_disable_pin, (d, which) -> { + Log.i(TAG, "Disable clicked."); + d.dismiss(); + AlertDialog progress = SimpleProgressDialog.show(context); + + SimpleTask.run(() -> { + PinState.onPinOptOut(); + return null; + }, success -> { + Log.i(TAG, "Disable operation finished."); + onSuccess.run(); + progress.dismiss(); + }); + }) + .setNegativeButton(android.R.string.cancel, (d, which) -> { + Log.i(TAG, "Cancel clicked."); + d.dismiss(); + }) + .create(); + + dialog.setOnShowListener(dialogInterface -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(ContextCompat.getColor(context, R.color.signal_alert_primary)); + }); + + dialog.show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreActivity.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreActivity.java new file mode 100644 index 00000000..0b1e9da1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreActivity.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.pin; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; + +public final class PinRestoreActivity extends AppCompatActivity { + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_NO); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.pin_restore_activity); + } + + void navigateToPinCreation() { + final Intent main = MainActivity.clearTop(this); + final Intent createPin = CreateKbsPinActivity.getIntentForPinCreate(this); + final Intent chained = PassphraseRequiredActivity.chainIntent(createPin, main); + + startActivity(chained); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java new file mode 100644 index 00000000..8408c040 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.pin; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.InputType; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.autofill.HintConstants; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.KbsConstants; +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SupportEmailUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class PinRestoreEntryFragment extends LoggingFragment { + private static final String TAG = Log.tag(PinRestoreActivity.class); + + private static final int MINIMUM_PIN_LENGTH = 4; + + private EditText pinEntry; + private View helpButton; + private View skipButton; + private CircularProgressButton pinButton; + private TextView errorLabel; + private TextView keyboardToggle; + private PinRestoreViewModel viewModel; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.pin_restore_entry_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initViews(view); + initViewModel(); + } + + private void initViews(@NonNull View root) { + pinEntry = root.findViewById(R.id.pin_restore_pin_input); + pinButton = root.findViewById(R.id.pin_restore_pin_confirm); + errorLabel = root.findViewById(R.id.pin_restore_pin_input_label); + keyboardToggle = root.findViewById(R.id.pin_restore_keyboard_toggle); + helpButton = root.findViewById(R.id.pin_restore_forgot_pin); + skipButton = root.findViewById(R.id.pin_restore_skip_button); + + helpButton.setVisibility(View.GONE); + helpButton.setOnClickListener(v -> onNeedHelpClicked()); + + skipButton.setOnClickListener(v -> onSkipClicked()); + + pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE); + pinEntry.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + ViewUtil.hideKeyboard(requireContext(), v); + onPinSubmitted(); + return true; + } + return false; + }); + ViewCompat.setAutofillHints(pinEntry, HintConstants.AUTOFILL_HINT_PASSWORD); + + enableAndFocusPinEntry(); + + pinButton.setOnClickListener((v) -> { + ViewUtil.hideKeyboard(requireContext(), pinEntry); + onPinSubmitted(); + }); + + keyboardToggle.setOnClickListener((v) -> { + PinKeyboardType keyboardType = getPinEntryKeyboardType(); + + updateKeyboard(keyboardType.getOther()); + keyboardToggle.setText(resolveKeyboardToggleText(keyboardType)); + }); + + PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther(); + keyboardToggle.setText(resolveKeyboardToggleText(keyboardType)); + } + + private void initViewModel() { + viewModel = ViewModelProviders.of(this).get(PinRestoreViewModel.class); + + viewModel.getTriesRemaining().observe(getViewLifecycleOwner(), this::presentTriesRemaining); + viewModel.getEvent().observe(getViewLifecycleOwner(), this::presentEvent); + } + + private void presentTriesRemaining(PinRestoreViewModel.TriesRemaining triesRemaining) { + if (triesRemaining.hasIncorrectGuess()) { + if (triesRemaining.getCount() == 1) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_incorrect_pin) + .setMessage(getResources().getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining.getCount(), triesRemaining.getCount())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + errorLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin); + helpButton.setVisibility(View.VISIBLE); + } else { + if (triesRemaining.getCount() == 1) { + helpButton.setVisibility(View.VISIBLE); + new AlertDialog.Builder(requireContext()) + .setMessage(getResources().getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining.getCount(), triesRemaining.getCount())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + } + + if (triesRemaining.getCount() == 0) { + Log.w(TAG, "Account locked. User out of attempts on KBS."); + onAccountLocked(); + } + } + + private void presentEvent(@NonNull PinRestoreViewModel.Event event) { + switch (event) { + case SUCCESS: + handleSuccess(); + break; + case EMPTY_PIN: + Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show(); + cancelSpinning(pinButton); + pinEntry.getText().clear(); + enableAndFocusPinEntry(); + break; + case PIN_TOO_SHORT: + Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show(); + cancelSpinning(pinButton); + pinEntry.getText().clear(); + enableAndFocusPinEntry(); + break; + case PIN_INCORRECT: + cancelSpinning(pinButton); + pinEntry.getText().clear(); + enableAndFocusPinEntry(); + break; + case PIN_LOCKED: + onAccountLocked(); + break; + case NETWORK_ERROR: + Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show(); + cancelSpinning(pinButton); + pinEntry.setEnabled(true); + enableAndFocusPinEntry(); + break; + } + } + + private PinKeyboardType getPinEntryKeyboardType() { + boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER; + + return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC; + } + + private void onPinSubmitted() { + pinEntry.setEnabled(false); + viewModel.onPinSubmitted(pinEntry.getText().toString(), getPinEntryKeyboardType()); + setSpinning(pinButton); + } + + private void onNeedHelpClicked() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_need_help) + .setMessage(getString(R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code, KbsConstants.MINIMUM_PIN_LENGTH)) + .setPositiveButton(R.string.PinRestoreEntryFragment_create_new_pin, ((dialog, which) -> { + PinState.onPinRestoreForgottenOrSkipped(); + ((PinRestoreActivity) requireActivity()).navigateToPinCreation(); + })) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> { + String body = SupportEmailUtil.generateSupportEmailBody(requireContext(), + R.string.PinRestoreEntryFragment_signal_registration_need_help_with_pin, + null, + null); + CommunicationActions.openEmail(requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getString(R.string.PinRestoreEntryFragment_signal_registration_need_help_with_pin), + body); + }) + .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null) + .show(); + } + + private void onSkipClicked() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry) + .setMessage(R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin) + .setPositiveButton(R.string.PinRestoreEntryFragment_create_new_pin, (dialog, which) -> { + PinState.onPinRestoreForgottenOrSkipped(); + ((PinRestoreActivity) requireActivity()).navigateToPinCreation(); + }) + .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null) + .show(); + } + + private void onAccountLocked() { + Navigation.findNavController(requireView()).navigate(PinRestoreEntryFragmentDirections.actionAccountLocked()); + } + + private void handleSuccess() { + cancelSpinning(pinButton); + SignalStore.onboarding().clearAll(); + + Activity activity = requireActivity(); + + if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) { + final Intent main = MainActivity.clearTop(activity); + final Intent profile = EditProfileActivity.getIntentForUserProfile(activity); + + profile.putExtra("next_intent", main); + startActivity(profile); + } else { + RegistrationUtil.maybeMarkRegistrationComplete(requireContext()); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + startActivity(MainActivity.clearTop(activity)); + } + + activity.finish(); + } + + private void updateKeyboard(@NonNull PinKeyboardType keyboard) { + boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC; + + pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD + : InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + + pinEntry.getText().clear(); + } + + private @StringRes static int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) { + if (keyboard == PinKeyboardType.ALPHA_NUMERIC) { + return R.string.PinRestoreEntryFragment_enter_alphanumeric_pin; + } else { + return R.string.PinRestoreEntryFragment_enter_numeric_pin; + } + } + + private void enableAndFocusPinEntry() { + pinEntry.setEnabled(true); + pinEntry.setFocusable(true); + + if (pinEntry.requestFocus()) { + ServiceUtil.getInputMethodManager(pinEntry.getContext()).showSoftInput(pinEntry, 0); + } + } + + private static void setSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setClickable(false); + button.setIndeterminateProgressMode(true); + button.setProgress(50); + } + } + + private static void cancelSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setProgress(0); + button.setIndeterminateProgressMode(false); + button.setClickable(true); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreLockedFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreLockedFragment.java new file mode 100644 index 00000000..5effccaa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreLockedFragment.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.pin; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.CommunicationActions; + +public class PinRestoreLockedFragment extends LoggingFragment { + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.pin_restore_locked_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + View createPinButton = view.findViewById(R.id.pin_locked_next); + View learnMoreButton = view.findViewById(R.id.pin_locked_learn_more); + + createPinButton.setOnClickListener(v -> { + PinState.onPinRestoreForgottenOrSkipped(); + ((PinRestoreActivity) requireActivity()).navigateToPinCreation(); + }); + + learnMoreButton.setOnClickListener(v -> { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java new file mode 100644 index 00000000..9ab5d810 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.pin; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.KbsEnclave; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.registration.service.KeyBackupSystemWrongPinException; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.KbsPinData; +import org.whispersystems.signalservice.api.KeyBackupService; +import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException; +import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +public class PinRestoreRepository { + + private static final String TAG = Log.tag(PinRestoreRepository.class); + + private final Executor executor = SignalExecutors.UNBOUNDED; + + void getToken(@NonNull Callback> callback) { + executor.execute(() -> { + try { + callback.onComplete(Optional.fromNullable(getTokenSync(null))); + } catch (IOException e) { + callback.onComplete(Optional.absent()); + } + }); + } + + /** + * @param authorization If this is being called before the user is registered (i.e. as part of + * reglock), you must pass in an authorization token that can be used to + * retrieve a backup. Otherwise, pass in null and we'll fetch one. + */ + public @NonNull TokenData getTokenSync(@Nullable String authorization) throws IOException { + TokenData firstKnownTokenData = null; + + for (KbsEnclave enclave : KbsEnclaves.all()) { + KeyBackupService kbs = ApplicationDependencies.getKeyBackupService(enclave); + + authorization = authorization == null ? kbs.getAuthorization() : authorization; + + TokenResponse token = kbs.getToken(authorization); + TokenData tokenData = new TokenData(enclave, authorization, token); + + if (tokenData.getTriesRemaining() > 0) { + Log.i(TAG, "Found data! " + enclave.getEnclaveName()); + return tokenData; + } else if (firstKnownTokenData == null) { + Log.i(TAG, "No data, but storing as the first response. " + enclave.getEnclaveName()); + firstKnownTokenData = tokenData; + } else { + Log.i(TAG, "No data, and we already have a 'first response'. " + enclave.getEnclaveName()); + } + } + + return Objects.requireNonNull(firstKnownTokenData); + } + + void submitPin(@NonNull String pin, @NonNull TokenData tokenData, @NonNull Callback callback) { + executor.execute(() -> { + try { + Stopwatch stopwatch = new Stopwatch("PinSubmission"); + + KbsPinData kbsData = PinState.restoreMasterKey(pin, tokenData.getEnclave(), tokenData.getBasicAuth(), tokenData.getTokenResponse()); + PinState.onSignalPinRestore(ApplicationDependencies.getApplication(), Objects.requireNonNull(kbsData), pin); + stopwatch.split("MasterKey"); + + ApplicationDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN); + stopwatch.split("AccountRestore"); + + ApplicationDependencies.getJobManager().runSynchronously(new StorageSyncJob(), TimeUnit.SECONDS.toMillis(10)); + stopwatch.split("ContactRestore"); + + stopwatch.stop(TAG); + + callback.onComplete(new PinResultData(PinResult.SUCCESS, tokenData)); + } catch (IOException e) { + callback.onComplete(new PinResultData(PinResult.NETWORK_ERROR, tokenData)); + } catch (KeyBackupSystemNoDataException e) { + callback.onComplete(new PinResultData(PinResult.LOCKED, tokenData)); + } catch (KeyBackupSystemWrongPinException e) { + callback.onComplete(new PinResultData(PinResult.INCORRECT, TokenData.withResponse(tokenData, e.getTokenResponse()))); + } + }); + } + + interface Callback { + void onComplete(@NonNull T value); + } + + public static class TokenData implements Parcelable { + private final KbsEnclave enclave; + private final String basicAuth; + private final TokenResponse tokenResponse; + + TokenData(@NonNull KbsEnclave enclave, @NonNull String basicAuth, @NonNull TokenResponse tokenResponse) { + this.enclave = enclave; + this.basicAuth = basicAuth; + this.tokenResponse = tokenResponse; + } + + private TokenData(Parcel in) { + //noinspection ConstantConditions + this.enclave = new KbsEnclave(in.readString(), in.readString(), in.readString()); + this.basicAuth = in.readString(); + + byte[] backupId = new byte[0]; + byte[] token = new byte[0]; + + in.readByteArray(backupId); + in.readByteArray(token); + + this.tokenResponse = new TokenResponse(backupId, token, in.readInt()); + } + + public static @NonNull TokenData withResponse(@NonNull TokenData data, @NonNull TokenResponse response) { + return new TokenData(data.getEnclave(), data.getBasicAuth(), response); + } + + public int getTriesRemaining() { + return tokenResponse.getTries(); + } + + public @NonNull String getBasicAuth() { + return basicAuth; + } + + public @NonNull TokenResponse getTokenResponse() { + return tokenResponse; + } + + public @NonNull KbsEnclave getEnclave() { + return enclave; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(enclave.getEnclaveName()); + dest.writeString(enclave.getServiceId()); + dest.writeString(enclave.getMrEnclave()); + + dest.writeString(basicAuth); + + dest.writeByteArray(tokenResponse.getBackupId()); + dest.writeByteArray(tokenResponse.getToken()); + dest.writeInt(tokenResponse.getTries()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public TokenData createFromParcel(Parcel in) { + return new TokenData(in); + } + + @Override + public TokenData[] newArray(int size) { + return new TokenData[size]; + } + }; + + } + + static class PinResultData { + private final PinResult result; + private final TokenData tokenData; + + PinResultData(@NonNull PinResult result, @NonNull TokenData tokenData) { + this.result = result; + this.tokenData = tokenData; + } + + public @NonNull PinResult getResult() { + return result; + } + + public @NonNull TokenData getTokenData() { + return tokenData; + } + } + + enum PinResult { + SUCCESS, INCORRECT, LOCKED, NETWORK_ERROR + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.java new file mode 100644 index 00000000..e2e6e1b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.pin; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.KbsConstants; +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public class PinRestoreViewModel extends ViewModel { + + private final PinRestoreRepository repo; + private final DefaultValueLiveData triesRemaining; + private final SingleLiveEvent event; + + private volatile PinRestoreRepository.TokenData tokenData; + + public PinRestoreViewModel() { + this.repo = new PinRestoreRepository(); + this.triesRemaining = new DefaultValueLiveData<>(new TriesRemaining(10, false)); + this.event = new SingleLiveEvent<>(); + + repo.getToken(token -> { + if (token.isPresent()) { + updateTokenData(token.get(), false); + } else { + event.postValue(Event.NETWORK_ERROR); + } + }); + } + + void onPinSubmitted(@NonNull String pin, @NonNull PinKeyboardType pinKeyboardType) { + int trimmedLength = pin.replace(" ", "").length(); + + if (trimmedLength == 0) { + event.postValue(Event.EMPTY_PIN); + return; + } + + if (trimmedLength < KbsConstants.MINIMUM_PIN_LENGTH) { + event.postValue(Event.PIN_TOO_SHORT); + return; + } + + if (tokenData != null) { + repo.submitPin(pin, tokenData, result -> { + + switch (result.getResult()) { + case SUCCESS: + SignalStore.pinValues().setKeyboardType(pinKeyboardType); + SignalStore.storageServiceValues().setNeedsAccountRestore(false); + event.postValue(Event.SUCCESS); + break; + case LOCKED: + event.postValue(Event.PIN_LOCKED); + break; + case INCORRECT: + event.postValue(Event.PIN_INCORRECT); + updateTokenData(result.getTokenData(), true); + break; + case NETWORK_ERROR: + event.postValue(Event.NETWORK_ERROR); + break; + } + }); + } else { + repo.getToken(token -> { + if (token.isPresent()) { + updateTokenData(token.get(), false); + onPinSubmitted(pin, pinKeyboardType); + } else { + event.postValue(Event.NETWORK_ERROR); + } + }); + } + } + + @NonNull DefaultValueLiveData getTriesRemaining() { + return triesRemaining; + } + + @NonNull LiveData getEvent() { + return event; + } + + private void updateTokenData(@NonNull PinRestoreRepository.TokenData tokenData, boolean incorrectGuess) { + this.tokenData = tokenData; + triesRemaining.postValue(new TriesRemaining(tokenData.getTriesRemaining(), incorrectGuess)); + } + + enum Event { + SUCCESS, EMPTY_PIN, PIN_TOO_SHORT, PIN_INCORRECT, PIN_LOCKED, NETWORK_ERROR + } + + static class TriesRemaining { + private final int triesRemaining; + private final boolean hasIncorrectGuess; + + TriesRemaining(int triesRemaining, boolean hasIncorrectGuess) { + this.triesRemaining = triesRemaining; + this.hasIncorrectGuess = hasIncorrectGuess; + } + + public int getCount() { + return triesRemaining; + } + + public boolean hasIncorrectGuess() { + return hasIncorrectGuess; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java new file mode 100644 index 00000000..43231e7e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java @@ -0,0 +1,508 @@ +package org.thoughtcrime.securesms.pin; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.KbsEnclave; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobTracker; +import org.thoughtcrime.securesms.jobs.ClearFallbackKbsEnclaveJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.StorageForcePushJob; +import org.thoughtcrime.securesms.keyvalue.KbsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.PinHashing; +import org.thoughtcrime.securesms.lock.RegistrationLockReminders; +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.registration.service.KeyBackupSystemWrongPinException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.KbsPinData; +import org.whispersystems.signalservice.api.KeyBackupService; +import org.whispersystems.signalservice.api.KeyBackupServicePinException; +import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException; +import org.whispersystems.signalservice.api.kbs.HashedPin; +import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; +import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +public final class PinState { + + private static final String TAG = Log.tag(PinState.class); + + /** + * Invoked during registration to restore the master key based on the server response during + * verification. + * + * Does not affect {@link PinState}. + */ + public static synchronized @Nullable KbsPinData restoreMasterKey(@Nullable String pin, + @NonNull KbsEnclave enclave, + @Nullable String basicStorageCredentials, + @NonNull TokenResponse tokenResponse) + throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException + { + Log.i(TAG, "restoreMasterKey()"); + + if (pin == null) return null; + + if (basicStorageCredentials == null) { + throw new AssertionError("Cannot restore KBS key, no storage credentials supplied"); + } + + Log.i(TAG, "Preparing to restore from " + enclave.getEnclaveName()); + return restoreMasterKeyFromEnclave(enclave, pin, basicStorageCredentials, tokenResponse); + } + + private static @NonNull KbsPinData restoreMasterKeyFromEnclave(@NonNull KbsEnclave enclave, + @NonNull String pin, + @NonNull String basicStorageCredentials, + @NonNull TokenResponse tokenResponse) + throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException + { + KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(enclave); + KeyBackupService.RestoreSession session = keyBackupService.newRegistrationSession(basicStorageCredentials, tokenResponse); + + try { + Log.i(TAG, "Restoring pin from KBS"); + + HashedPin hashedPin = PinHashing.hashPin(pin, session); + KbsPinData kbsData = session.restorePin(hashedPin); + + if (kbsData != null) { + Log.i(TAG, "Found registration lock token on KBS."); + } else { + throw new AssertionError("Null not expected"); + } + + return kbsData; + } catch (UnauthenticatedResponseException | InvalidKeyException e) { + Log.w(TAG, "Failed to restore key", e); + throw new IOException(e); + } catch (KeyBackupServicePinException e) { + Log.w(TAG, "Incorrect pin", e); + throw new KeyBackupSystemWrongPinException(e.getToken()); + } + } + + /** + * Invoked after a user has successfully registered. Ensures all the necessary state is updated. + */ + public static synchronized void onRegistration(@NonNull Context context, + @Nullable KbsPinData kbsData, + @Nullable String pin, + boolean hasPinToRestore) + { + Log.i(TAG, "onRegistration()"); + + TextSecurePreferences.setV1RegistrationLockPin(context, pin); + + if (kbsData == null && pin != null) { + Log.i(TAG, "Registration Lock V1"); + SignalStore.kbsValues().clearRegistrationLockAndPin(); + TextSecurePreferences.setV1RegistrationLockEnabled(context, true); + TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis()); + TextSecurePreferences.setRegistrationLockNextReminderInterval(context, RegistrationLockReminders.INITIAL_INTERVAL); + } else if (kbsData != null && pin != null) { + Log.i(TAG, "Registration Lock V2"); + TextSecurePreferences.setV1RegistrationLockEnabled(context, false); + SignalStore.kbsValues().setV2RegistrationLockEnabled(true); + SignalStore.kbsValues().setKbsMasterKey(kbsData, pin); + SignalStore.pinValues().resetPinReminders(); + resetPinRetryCount(context, pin); + ClearFallbackKbsEnclaveJob.clearAll(); + } else if (hasPinToRestore) { + Log.i(TAG, "Has a PIN to restore."); + SignalStore.kbsValues().clearRegistrationLockAndPin(); + TextSecurePreferences.setV1RegistrationLockEnabled(context, false); + SignalStore.storageServiceValues().setNeedsAccountRestore(true); + } else { + Log.i(TAG, "No registration lock or PIN at all."); + SignalStore.kbsValues().clearRegistrationLockAndPin(); + TextSecurePreferences.setV1RegistrationLockEnabled(context, false); + } + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Invoked when the user is going through the PIN restoration flow (which is separate from reglock). + */ + public static synchronized void onSignalPinRestore(@NonNull Context context, @NonNull KbsPinData kbsData, @NonNull String pin) { + Log.i(TAG, "onSignalPinRestore()"); + + SignalStore.kbsValues().setKbsMasterKey(kbsData, pin); + SignalStore.kbsValues().setV2RegistrationLockEnabled(false); + SignalStore.pinValues().resetPinReminders(); + SignalStore.storageServiceValues().setNeedsAccountRestore(false); + resetPinRetryCount(context, pin); + ClearFallbackKbsEnclaveJob.clearAll(); + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Invoked when the user skips out on PIN restoration or otherwise fails to remember their PIN. + */ + public static synchronized void onPinRestoreForgottenOrSkipped() { + SignalStore.kbsValues().clearRegistrationLockAndPin(); + SignalStore.storageServiceValues().setNeedsAccountRestore(false); + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Invoked whenever the Signal PIN is changed or created. + */ + @WorkerThread + public static synchronized void onPinChangedOrCreated(@NonNull Context context, @NonNull String pin, @NonNull PinKeyboardType keyboard) + throws IOException, UnauthenticatedResponseException, InvalidKeyException + { + Log.i(TAG, "onPinChangedOrCreated()"); + + KbsValues kbsValues = SignalStore.kbsValues(); + boolean isFirstPin = !kbsValues.hasPin() || kbsValues.hasOptedOut(); + MasterKey masterKey = kbsValues.getOrCreateMasterKey(); + KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()); + KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession(); + HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession); + KbsPinData kbsData = pinChangeSession.setPin(hashedPin, masterKey); + + kbsValues.setKbsMasterKey(kbsData, pin); + TextSecurePreferences.clearRegistrationLockV1(context); + SignalStore.pinValues().setKeyboardType(keyboard); + SignalStore.pinValues().resetPinReminders(); + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.PINS_FOR_ALL); + + if (isFirstPin) { + Log.i(TAG, "First time setting a PIN. Refreshing attributes to set the 'storage' capability."); + bestEffortRefreshAttributes(); + } else { + Log.i(TAG, "Not the first time setting a PIN."); + } + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Invoked when PIN creation fails. + */ + public static synchronized void onPinCreateFailure() { + Log.i(TAG, "onPinCreateFailure()"); + if (getState() == State.NO_REGISTRATION_LOCK) { + SignalStore.kbsValues().onPinCreateFailure(); + } + } + + /** + * Invoked when the user has enabled the "PIN opt out" setting. + */ + @WorkerThread + public static synchronized void onPinOptOut() { + Log.i(TAG, "onPinOptOutEnabled()"); + assertState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED, State.NO_REGISTRATION_LOCK); + + optOutOfPin(); + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Invoked whenever a Signal PIN user enables registration lock. + */ + @WorkerThread + public static synchronized void onEnableRegistrationLockForUserWithPin() throws IOException { + Log.i(TAG, "onEnableRegistrationLockForUserWithPin()"); + + if (getState() == State.PIN_WITH_REGISTRATION_LOCK_ENABLED) { + Log.i(TAG, "Registration lock already enabled. Skipping."); + return; + } + + assertState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED); + + SignalStore.kbsValues().setV2RegistrationLockEnabled(false); + ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()) + .newPinChangeSession(SignalStore.kbsValues().getRegistrationLockTokenResponse()) + .enableRegistrationLock(SignalStore.kbsValues().getOrCreateMasterKey()); + SignalStore.kbsValues().setV2RegistrationLockEnabled(true); + + updateState(State.PIN_WITH_REGISTRATION_LOCK_ENABLED); + } + + /** + * Invoked whenever a Signal PIN user disables registration lock. + */ + @WorkerThread + public static synchronized void onDisableRegistrationLockForUserWithPin() throws IOException { + Log.i(TAG, "onDisableRegistrationLockForUserWithPin()"); + + if (getState() == State.PIN_WITH_REGISTRATION_LOCK_DISABLED) { + Log.i(TAG, "Registration lock already disabled. Skipping."); + return; + } + + assertState(State.PIN_WITH_REGISTRATION_LOCK_ENABLED); + + SignalStore.kbsValues().setV2RegistrationLockEnabled(true); + ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()) + .newPinChangeSession(SignalStore.kbsValues().getRegistrationLockTokenResponse()) + .disableRegistrationLock(); + SignalStore.kbsValues().setV2RegistrationLockEnabled(false); + + updateState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED); + } + + /** + * Should only be called by {@link org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob}. + */ + @WorkerThread + public static synchronized void onMigrateToRegistrationLockV2(@NonNull Context context, @NonNull String pin) + throws IOException, UnauthenticatedResponseException, InvalidKeyException + { + Log.i(TAG, "onMigrateToRegistrationLockV2()"); + + KbsValues kbsValues = SignalStore.kbsValues(); + MasterKey masterKey = kbsValues.getOrCreateMasterKey(); + KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()); + KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession(); + HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession); + KbsPinData kbsData = pinChangeSession.setPin(hashedPin, masterKey); + + pinChangeSession.enableRegistrationLock(masterKey); + + kbsValues.setKbsMasterKey(kbsData, pin); + TextSecurePreferences.clearRegistrationLockV1(context); + + updateState(buildInferredStateFromOtherFields()); + } + + /** + * Should only be called by {@link org.thoughtcrime.securesms.jobs.KbsEnclaveMigrationWorkerJob}. + */ + @WorkerThread + public static synchronized void onMigrateToNewEnclave(@NonNull String pin) + throws IOException, UnauthenticatedResponseException + { + Log.i(TAG, "onMigrateToNewEnclave()"); + assertState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED, State.PIN_WITH_REGISTRATION_LOCK_ENABLED); + + Log.i(TAG, "Migrating to enclave " + KbsEnclaves.current().getEnclaveName()); + setPinOnEnclave(KbsEnclaves.current(), pin, SignalStore.kbsValues().getOrCreateMasterKey()); + + ClearFallbackKbsEnclaveJob.clearAll(); + } + + @WorkerThread + private static void bestEffortRefreshAttributes() { + Optional result = ApplicationDependencies.getJobManager().runSynchronously(new RefreshAttributesJob(), TimeUnit.SECONDS.toMillis(10)); + + if (result.isPresent() && result.get() == JobTracker.JobState.SUCCESS) { + Log.i(TAG, "Attributes were refreshed successfully."); + } else if (result.isPresent()) { + Log.w(TAG, "Attribute refresh finished, but was not successful. Enqueuing one for later. (Result: " + result.get() + ")"); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + } else { + Log.w(TAG, "Job did not finish in the allotted time. It'll finish later."); + } + } + + @WorkerThread + private static void bestEffortForcePushStorage() { + Optional result = ApplicationDependencies.getJobManager().runSynchronously(new StorageForcePushJob(), TimeUnit.SECONDS.toMillis(10)); + + if (result.isPresent() && result.get() == JobTracker.JobState.SUCCESS) { + Log.i(TAG, "Storage was force-pushed successfully."); + } else if (result.isPresent()) { + Log.w(TAG, "Storage force-pushed finished, but was not successful. Enqueuing one for later. (Result: " + result.get() + ")"); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + } else { + Log.w(TAG, "Storage fore push did not finish in the allotted time. It'll finish later."); + } + } + + @WorkerThread + private static void resetPinRetryCount(@NonNull Context context, @Nullable String pin) { + if (pin == null) { + return; + } + + try { + setPinOnEnclave(KbsEnclaves.current(), pin, SignalStore.kbsValues().getOrCreateMasterKey()); + TextSecurePreferences.clearRegistrationLockV1(context); + Log.i(TAG, "Pin set/attempts reset on KBS"); + } catch (IOException e) { + Log.w(TAG, "May have failed to reset pin attempts!", e); + } catch (UnauthenticatedResponseException e) { + Log.w(TAG, "Failed to reset pin attempts", e); + } + } + + @WorkerThread + private static @NonNull KbsPinData setPinOnEnclave(@NonNull KbsEnclave enclave, @NonNull String pin, @NonNull MasterKey masterKey) + throws IOException, UnauthenticatedResponseException + { + KeyBackupService kbs = ApplicationDependencies.getKeyBackupService(enclave); + KeyBackupService.PinChangeSession pinChangeSession = kbs.newPinChangeSession(); + HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession); + KbsPinData newData = pinChangeSession.setPin(hashedPin, masterKey); + + SignalStore.kbsValues().setKbsMasterKey(newData, pin); + + return newData; + } + + @WorkerThread + private static void optOutOfPin() { + SignalStore.kbsValues().optOut(); + + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.PINS_FOR_ALL); + + bestEffortRefreshAttributes(); + bestEffortForcePushStorage(); + } + + private static @NonNull State assertState(State... allowed) { + State currentState = getState(); + + for (State state : allowed) { + if (currentState == state) { + return currentState; + } + } + + switch (currentState) { + case NO_REGISTRATION_LOCK: throw new InvalidState_NoRegistrationLock(); + case REGISTRATION_LOCK_V1: throw new InvalidState_RegistrationLockV1(); + case PIN_WITH_REGISTRATION_LOCK_ENABLED: throw new InvalidState_PinWithRegistrationLockEnabled(); + case PIN_WITH_REGISTRATION_LOCK_DISABLED: throw new InvalidState_PinWithRegistrationLockDisabled(); + case PIN_OPT_OUT: throw new InvalidState_PinOptOut(); + default: throw new IllegalStateException("Expected: " + Arrays.toString(allowed) + ", Actual: " + currentState); + } + } + + private static @NonNull State getState() { + String serialized = SignalStore.pinValues().getPinState(); + + if (serialized != null) { + return State.deserialize(serialized); + } else { + State state = buildInferredStateFromOtherFields(); + SignalStore.pinValues().setPinState(state.serialize()); + return state; + } + } + + private static void updateState(@NonNull State state) { + Log.i(TAG, "Updating state to: " + state); + SignalStore.pinValues().setPinState(state.serialize()); + } + + private static @NonNull State buildInferredStateFromOtherFields() { + Context context = ApplicationDependencies.getApplication(); + KbsValues kbsValues = SignalStore.kbsValues(); + + boolean v1Enabled = TextSecurePreferences.isV1RegistrationLockEnabled(context); + boolean v2Enabled = kbsValues.isV2RegistrationLockEnabled(); + boolean hasPin = kbsValues.hasPin(); + boolean optedOut = kbsValues.hasOptedOut(); + + if (optedOut && !v2Enabled && !v1Enabled) { + return State.PIN_OPT_OUT; + } + + if (!v1Enabled && !v2Enabled && !hasPin) { + return State.NO_REGISTRATION_LOCK; + } + + if (v1Enabled && !v2Enabled && !hasPin) { + return State.REGISTRATION_LOCK_V1; + } + + if (v2Enabled && hasPin) { + TextSecurePreferences.setV1RegistrationLockEnabled(context, false); + return State.PIN_WITH_REGISTRATION_LOCK_ENABLED; + } + + if (!v2Enabled && hasPin) { + TextSecurePreferences.setV1RegistrationLockEnabled(context, false); + return State.PIN_WITH_REGISTRATION_LOCK_DISABLED; + } + + throw new InvalidInferredStateError(String.format(Locale.ENGLISH, "Invalid state! v1: %b, v2: %b, pin: %b", v1Enabled, v2Enabled, hasPin)); + } + + private enum State { + /** + * User has nothing -- either in the process of registration, or pre-PIN-migration + */ + NO_REGISTRATION_LOCK("no_registration_lock"), + + /** + * User has a V1 registration lock set + */ + REGISTRATION_LOCK_V1("registration_lock_v1"), + + /** + * User has a PIN, and registration lock is enabled. + */ + PIN_WITH_REGISTRATION_LOCK_ENABLED("pin_with_registration_lock_enabled"), + + /** + * User has a PIN, but registration lock is disabled. + */ + PIN_WITH_REGISTRATION_LOCK_DISABLED("pin_with_registration_lock_disabled"), + + /** + * The user has opted out of creating a PIN. In this case, we will generate a high-entropy PIN + * on their behalf. + */ + PIN_OPT_OUT("pin_opt_out"); + + /** + * Using a string key so that people can rename/reorder values in the future without breaking + * serialization. + */ + private final String key; + + State(String key) { + this.key = key; + } + + public @NonNull String serialize() { + return key; + } + + public static @NonNull State deserialize(@NonNull String serialized) { + for (State state : values()) { + if (state.key.equals(serialized)) { + return state; + } + } + throw new IllegalArgumentException("No state for value: " + serialized); + } + } + + private static class InvalidInferredStateError extends Error { + InvalidInferredStateError(String message) { + super(message); + } + } + + private static class InvalidState_NoRegistrationLock extends IllegalStateException {} + private static class InvalidState_RegistrationLockV1 extends IllegalStateException {} + private static class InvalidState_PinWithRegistrationLockEnabled extends IllegalStateException {} + private static class InvalidState_PinWithRegistrationLockDisabled extends IllegalStateException {} + private static class InvalidState_PinOptOut extends IllegalStateException {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/RegistrationLockV2Dialog.java b/app/src/main/java/org/thoughtcrime/securesms/pin/RegistrationLockV2Dialog.java new file mode 100644 index 00000000..ef94feec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/RegistrationLockV2Dialog.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.pin; + +import android.content.Context; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.util.Objects; + +public final class RegistrationLockV2Dialog { + + private static final String TAG = Log.tag(RegistrationLockV2Dialog.class); + + private RegistrationLockV2Dialog() {} + + public static void showEnableDialog(@NonNull Context context, @NonNull Runnable onSuccess) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(R.string.RegistrationLockV2Dialog_turn_on_registration_lock) + .setView(R.layout.registration_lock_v2_dialog) + .setMessage(R.string.RegistrationLockV2Dialog_if_you_forget_your_signal_pin_when_registering_again) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.RegistrationLockV2Dialog_turn_on, null) + .create(); + dialog.setOnShowListener(d -> { + ProgressBar progress = Objects.requireNonNull(dialog.findViewById(R.id.reglockv2_dialog_progress)); + View positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + + positiveButton.setOnClickListener(v -> { + progress.setIndeterminate(true); + progress.setVisibility(View.VISIBLE); + + SimpleTask.run(SignalExecutors.UNBOUNDED, () -> { + try { + PinState.onEnableRegistrationLockForUserWithPin(); + Log.i(TAG, "Successfully enabled registration lock."); + return true; + } catch (IOException e) { + Log.w(TAG, "Failed to enable registration lock setting.", e); + return false; + } + }, (success) -> { + progress.setVisibility(View.GONE); + + if (!success) { + Toast.makeText(context, R.string.preferences_app_protection__failed_to_enable_registration_lock, Toast.LENGTH_LONG).show(); + } else { + onSuccess.run(); + } + + dialog.dismiss(); + }); + }); + }); + + dialog.show(); + } + + public static void showDisableDialog(@NonNull Context context, @NonNull Runnable onSuccess) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(R.string.RegistrationLockV2Dialog_turn_off_registration_lock) + .setView(R.layout.registration_lock_v2_dialog) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.RegistrationLockV2Dialog_turn_off, null) + .create(); + dialog.setOnShowListener(d -> { + ProgressBar progress = Objects.requireNonNull(dialog.findViewById(R.id.reglockv2_dialog_progress)); + View positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + + positiveButton.setOnClickListener(v -> { + progress.setIndeterminate(true); + progress.setVisibility(View.VISIBLE); + + SimpleTask.run(SignalExecutors.UNBOUNDED, () -> { + try { + PinState.onDisableRegistrationLockForUserWithPin(); + Log.i(TAG, "Successfully disabled registration lock."); + return true; + } catch (IOException e) { + Log.w(TAG, "Failed to disable registration lock.", e); + return false; + } + }, (success) -> { + progress.setVisibility(View.GONE); + + if (!success) { + Toast.makeText(context, R.string.preferences_app_protection__failed_to_disable_registration_lock, Toast.LENGTH_LONG).show(); + } else { + onSuccess.run(); + } + + dialog.dismiss(); + }); + }); + }); + + dialog.show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java new file mode 100644 index 00000000..ad53ba72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; + +import com.google.android.material.snackbar.Snackbar; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.pin.PinOptOutDialog; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class AdvancedPinPreferenceFragment extends ListSummaryPreferenceFragment { + + private static final String PREF_ENABLE = "pref_pin_enable"; + private static final String PREF_DISABLE = "pref_pin_disable"; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_advanced_pin); + } + + @Override + public void onResume() { + super.onResume(); + updatePreferenceState(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) { + Snackbar.make(requireView(), R.string.ApplicationPreferencesActivity_pin_created, Snackbar.LENGTH_LONG).setTextColor(Color.WHITE).show(); + } + } + + private void updatePreferenceState() { + Preference enable = this.findPreference(PREF_ENABLE); + Preference disable = this.findPreference(PREF_DISABLE); + + if (SignalStore.kbsValues().hasOptedOut()) { + enable.setVisible(true); + disable.setVisible(false); + + enable.setOnPreferenceClickListener(preference -> { + onPreferenceChanged(true); + return true; + }); + } else { + enable.setVisible(false); + disable.setVisible(true); + + disable.setOnPreferenceClickListener(preference -> { + onPreferenceChanged(false); + return true; + }); + } + + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__advanced_pin_settings); + } + + private void onPreferenceChanged(boolean enabled) { + boolean hasRegistrationLock = TextSecurePreferences.isV1RegistrationLockEnabled(requireContext()) || + SignalStore.kbsValues().isV2RegistrationLockEnabled(); + + if (!enabled && hasRegistrationLock) { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.ApplicationPreferencesActivity_pins_are_required_for_registration_lock) + .setCancelable(true) + .setPositiveButton(android.R.string.ok, (d, which) -> d.dismiss()) + .show(); + } else if (!enabled) { + PinOptOutDialog.show(requireContext(), + () -> { + updatePreferenceState(); + Snackbar.make(requireView(), R.string.ApplicationPreferencesActivity_pin_disabled, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show(); + }); + } else { + startActivityForResult(CreateKbsPinActivity.getIntentForPinCreate(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java new file mode 100644 index 00000000..f743e8c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java @@ -0,0 +1,279 @@ +package org.thoughtcrime.securesms.preferences; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; + +import com.google.firebase.iid.FirebaseInstanceId; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.thoughtcrime.securesms.contacts.ContactIdentityManager; +import org.thoughtcrime.securesms.delete.DeleteAccountFragment; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; + +import java.io.IOException; + +public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { + private static final String TAG = AdvancedPreferenceFragment.class.getSimpleName(); + + private static final String PUSH_MESSAGING_PREF = "pref_toggle_push_messaging"; + private static final String SUBMIT_DEBUG_LOG_PREF = "pref_submit_debug_logs"; + private static final String INTERNAL_PREF = "pref_internal"; + private static final String ADVANCED_PIN_PREF = "pref_advanced_pin_settings"; + private static final String DELETE_ACCOUNT = "pref_delete_account"; + + private static final int PICK_IDENTITY_CONTACT = 1; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + initializeIdentitySelection(); + + Preference submitDebugLog = this.findPreference(SUBMIT_DEBUG_LOG_PREF); + submitDebugLog.setOnPreferenceClickListener(new SubmitDebugLogListener()); + submitDebugLog.setSummary(getVersion(getActivity())); + + Preference pinSettings = this.findPreference(ADVANCED_PIN_PREF); + pinSettings.setOnPreferenceClickListener(preference -> { + getApplicationPreferencesActivity().pushFragment(new AdvancedPinPreferenceFragment()); + return false; + }); + + Preference internalPreference = this.findPreference(INTERNAL_PREF); + internalPreference.setVisible(FeatureFlags.internalUser()); + internalPreference.setOnPreferenceClickListener(preference -> { + if (FeatureFlags.internalUser()) { + getApplicationPreferencesActivity().pushFragment(new InternalOptionsPreferenceFragment()); + return true; + } else { + return false; + } + }); + + Preference deleteAccount = this.findPreference(DELETE_ACCOUNT); + deleteAccount.setOnPreferenceClickListener(preference -> { + getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); + return false; + }); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_tertiary)); + + View list = view.findViewById(R.id.recycler_view); + ViewGroup.LayoutParams params = list.getLayoutParams(); + + params.height = ActionBar.LayoutParams.WRAP_CONTENT; + list.setLayoutParams(params); + list.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_advanced); + } + + @Override + public void onResume() { + super.onResume(); + getApplicationPreferencesActivity().getSupportActionBar().setTitle(R.string.preferences__advanced); + + initializePushMessagingToggle(); + } + + @Override + public void onActivityResult(int reqCode, int resultCode, Intent data) { + super.onActivityResult(reqCode, resultCode, data); + + Log.i(TAG, "Got result: " + resultCode + " for req: " + reqCode); + if (resultCode == Activity.RESULT_OK && reqCode == PICK_IDENTITY_CONTACT) { + handleIdentitySelection(data); + } + } + + private @NonNull ApplicationPreferencesActivity getApplicationPreferencesActivity() { + return (ApplicationPreferencesActivity) requireActivity(); + } + + private void initializePushMessagingToggle() { + CheckBoxPreference preference = (CheckBoxPreference)this.findPreference(PUSH_MESSAGING_PREF); + + if (TextSecurePreferences.isPushRegistered(getActivity())) { + preference.setChecked(true); + preference.setSummary(PhoneNumberFormatter.prettyPrint(TextSecurePreferences.getLocalNumber(getActivity()))); + } else { + preference.setChecked(false); + preference.setSummary(R.string.preferences__free_private_messages_and_calls); + } + + preference.setOnPreferenceChangeListener(new PushMessagingClickListener()); + } + + private void initializeIdentitySelection() { + ContactIdentityManager identity = ContactIdentityManager.getInstance(getActivity()); + + Preference preference = this.findPreference(TextSecurePreferences.IDENTITY_PREF); + + if (identity.isSelfIdentityAutoDetected()) { + this.getPreferenceScreen().removePreference(preference); + } else { + Uri contactUri = identity.getSelfIdentityUri(); + + if (contactUri != null) { + String contactName = ContactAccessor.getInstance().getNameFromContact(getActivity(), contactUri); + preference.setSummary(String.format(getString(R.string.ApplicationPreferencesActivity_currently_s), + contactName)); + } + + preference.setOnPreferenceClickListener(new IdentityPreferenceClickListener()); + } + } + + private @NonNull String getVersion(@Nullable Context context) { + if (context == null) return ""; + + String app = context.getString(R.string.app_name); + String version = BuildConfig.VERSION_NAME; + + return String.format("%s %s", app, version); + } + + private class IdentityPreferenceClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setType(ContactsContract.Contacts.CONTENT_TYPE); + startActivityForResult(intent, PICK_IDENTITY_CONTACT); + return true; + } + } + + private void handleIdentitySelection(Intent data) { + Uri contactUri = data.getData(); + + if (contactUri != null) { + TextSecurePreferences.setIdentityContactUri(getActivity(), contactUri.toString()); + initializeIdentitySelection(); + } + } + + private class SubmitDebugLogListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + final Intent intent = new Intent(getActivity(), SubmitDebugLogActivity.class); + startActivity(intent); + return true; + } + } + + private class PushMessagingClickListener implements Preference.OnPreferenceChangeListener { + private static final int SUCCESS = 0; + private static final int NETWORK_ERROR = 1; + + private class DisablePushMessagesTask extends ProgressDialogAsyncTask { + private final CheckBoxPreference checkBoxPreference; + + public DisablePushMessagesTask(final CheckBoxPreference checkBoxPreference) { + super(getActivity(), R.string.ApplicationPreferencesActivity_unregistering, R.string.ApplicationPreferencesActivity_unregistering_from_signal_messages_and_calls); + this.checkBoxPreference = checkBoxPreference; + } + + @Override + protected void onPostExecute(Integer result) { + super.onPostExecute(result); + switch (result) { + case NETWORK_ERROR: + Toast.makeText(getActivity(), + R.string.ApplicationPreferencesActivity_error_connecting_to_server, + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + TextSecurePreferences.setPushRegistered(getActivity(), false); + SignalStore.registrationValues().clearRegistrationComplete(); + initializePushMessagingToggle(); + break; + } + } + + @Override + protected Integer doInBackground(Void... params) { + try { + Context context = getActivity(); + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + + try { + accountManager.setGcmId(Optional.absent()); + } catch (AuthorizationFailedException e) { + Log.w(TAG, e); + } + + if (!TextSecurePreferences.isFcmDisabled(context)) { + FirebaseInstanceId.getInstance().deleteInstanceId(); + } + + return SUCCESS; + } catch (IOException ioe) { + Log.w(TAG, ioe); + return NETWORK_ERROR; + } + } + } + + @Override + public boolean onPreferenceChange(final Preference preference, Object newValue) { + if (((CheckBoxPreference)preference).isChecked()) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(R.drawable.ic_info_outline); + builder.setTitle(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls); + builder.setMessage(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls_by_unregistering); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new DisablePushMessagesTask((CheckBoxPreference)preference).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + builder.show(); + } else { + startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext())); + } + + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java new file mode 100644 index 00000000..e16981ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -0,0 +1,659 @@ +package org.thoughtcrime.securesms.preferences; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.InputType; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.TextAppearanceSpan; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.autofill.HintConstants; +import androidx.core.app.DialogCompat; +import androidx.core.view.ViewCompat; +import androidx.preference.CheckBoxPreference; +import androidx.preference.Preference; + +import com.google.android.material.snackbar.Snackbar; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.PassphraseChangeActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.blocked.BlockedUsersActivity; +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.keyvalue.KbsValues; +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; +import org.thoughtcrime.securesms.keyvalue.PinValues; +import org.thoughtcrime.securesms.keyvalue.SettingsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.PinHashing; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.lock.v2.KbsConstants; +import org.thoughtcrime.securesms.lock.v2.RegistrationLockUtil; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import mobi.upod.timedurationpicker.TimeDurationPickerDialog; + +public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment { + + private static final String TAG = Log.tag(AppProtectionPreferenceFragment.class); + + private static final String PREFERENCE_CATEGORY_BLOCKED = "preference_category_blocked"; + private static final String PREFERENCE_UNIDENTIFIED_LEARN_MORE = "pref_unidentified_learn_more"; + private static final String PREFERENCE_INCOGNITO_LEARN_MORE = "pref_incognito_learn_more"; + private static final String PREFERENCE_WHO_CAN_SEE_PHONE_NUMBER = "pref_who_can_see_phone_number"; + private static final String PREFERENCE_WHO_CAN_FIND_BY_PHONE_NUMBER = "pref_who_can_find_by_phone_number"; + + private CheckBoxPreference disablePassphrase; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + disablePassphrase = (CheckBoxPreference) this.findPreference("pref_enable_passphrase_temporary"); + + this.findPreference(KbsValues.V2_LOCK_ENABLED).setPreferenceDataStore(SignalStore.getPreferenceDataStore()); + ((SwitchPreferenceCompat) this.findPreference(KbsValues.V2_LOCK_ENABLED)).setChecked(SignalStore.kbsValues().isV2RegistrationLockEnabled()); + this.findPreference(KbsValues.V2_LOCK_ENABLED).setOnPreferenceChangeListener(new RegistrationLockV2ChangedListener()); + + this.findPreference(PinValues.PIN_REMINDERS_ENABLED).setPreferenceDataStore(SignalStore.getPreferenceDataStore()); + ((SwitchPreferenceCompat) this.findPreference(PinValues.PIN_REMINDERS_ENABLED)).setChecked(SignalStore.pinValues().arePinRemindersEnabled()); + this.findPreference(PinValues.PIN_REMINDERS_ENABLED).setOnPreferenceChangeListener(new PinRemindersChangedListener()); + + this.findPreference(TextSecurePreferences.SCREEN_LOCK).setOnPreferenceChangeListener(new ScreenLockListener()); + this.findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setOnPreferenceClickListener(new ScreenLockTimeoutListener()); + + this.findPreference(TextSecurePreferences.CHANGE_PASSPHRASE_PREF).setOnPreferenceClickListener(new ChangePassphraseClickListener()); + this.findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF).setOnPreferenceClickListener(new PassphraseIntervalClickListener()); + this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); + this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); + this.findPreference(PREFERENCE_CATEGORY_BLOCKED).setOnPreferenceClickListener(new BlockedContactsClickListener()); + this.findPreference(TextSecurePreferences.SHOW_UNIDENTIFIED_DELIVERY_INDICATORS).setOnPreferenceChangeListener(new ShowUnidentifiedDeliveryIndicatorsChangedListener()); + this.findPreference(TextSecurePreferences.UNIVERSAL_UNIDENTIFIED_ACCESS).setOnPreferenceChangeListener(new UniversalUnidentifiedAccessChangedListener()); + this.findPreference(PREFERENCE_UNIDENTIFIED_LEARN_MORE).setOnPreferenceClickListener(new UnidentifiedLearnMoreClickListener()); + this.findPreference(PREFERENCE_INCOGNITO_LEARN_MORE).setOnPreferenceClickListener(new IncognitoLearnMoreClickListener()); + disablePassphrase.setOnPreferenceChangeListener(new DisablePassphraseClickListener()); + + if (FeatureFlags.phoneNumberPrivacy()) { + Preference whoCanSeePhoneNumber = this.findPreference(PREFERENCE_WHO_CAN_SEE_PHONE_NUMBER); + Preference whoCanFindByPhoneNumber = this.findPreference(PREFERENCE_WHO_CAN_FIND_BY_PHONE_NUMBER); + + whoCanSeePhoneNumber.setPreferenceDataStore(null); + whoCanSeePhoneNumber.setOnPreferenceClickListener(new PhoneNumberPrivacyWhoCanSeeClickListener()); + + whoCanFindByPhoneNumber.setPreferenceDataStore(null); + whoCanFindByPhoneNumber.setOnPreferenceClickListener(new PhoneNumberPrivacyWhoCanFindClickListener()); + } else { + this.findPreference("category_phone_number_privacy").setVisible(false); + } + + SwitchPreferenceCompat linkPreviewPref = (SwitchPreferenceCompat) this.findPreference(SettingsValues.LINK_PREVIEWS); + linkPreviewPref.setChecked(SignalStore.settings().isLinkPreviewsEnabled()); + linkPreviewPref.setPreferenceDataStore(SignalStore.getPreferenceDataStore()); + linkPreviewPref.setOnPreferenceChangeListener(new LinkPreviewToggleListener()); + + initializeVisibility(); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_app_protection); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__privacy); + + if (!TextSecurePreferences.isPasswordDisabled(getContext())) initializePassphraseTimeoutSummary(); + else initializeScreenLockTimeoutSummary(); + + disablePassphrase.setChecked(!TextSecurePreferences.isPasswordDisabled(getActivity())); + + Preference signalPinCreateChange = this.findPreference(TextSecurePreferences.SIGNAL_PIN_CHANGE); + SwitchPreferenceCompat signalPinReminders = (SwitchPreferenceCompat) this.findPreference(PinValues.PIN_REMINDERS_ENABLED); + SwitchPreferenceCompat registrationLockV2 = (SwitchPreferenceCompat) this.findPreference(KbsValues.V2_LOCK_ENABLED); + + if (SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut()) { + signalPinCreateChange.setOnPreferenceClickListener(new KbsPinUpdateListener()); + signalPinCreateChange.setTitle(R.string.preferences_app_protection__change_your_pin); + signalPinReminders.setEnabled(true); + registrationLockV2.setEnabled(true); + } else { + signalPinCreateChange.setOnPreferenceClickListener(new KbsPinCreateListener()); + signalPinCreateChange.setTitle(R.string.preferences_app_protection__create_a_pin); + signalPinReminders.setEnabled(false); + registrationLockV2.setEnabled(false); + } + + initializePhoneNumberPrivacyWhoCanSeeSummary(); + initializePhoneNumberPrivacyWhoCanFindSummary(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) { + Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).setTextColor(Color.WHITE).show(); + } + } + + private void initializePassphraseTimeoutSummary() { + int timeoutMinutes = TextSecurePreferences.getPassphraseTimeoutInterval(getActivity()); + this.findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF) + .setSummary(getResources().getQuantityString(R.plurals.AppProtectionPreferenceFragment_minutes, timeoutMinutes, timeoutMinutes)); + } + + private void initializeScreenLockTimeoutSummary() { + long timeoutSeconds = TextSecurePreferences.getScreenLockTimeout(getContext()); + long hours = TimeUnit.SECONDS.toHours(timeoutSeconds); + long minutes = TimeUnit.SECONDS.toMinutes(timeoutSeconds) - (TimeUnit.SECONDS.toHours(timeoutSeconds) * 60 ); + long seconds = TimeUnit.SECONDS.toSeconds(timeoutSeconds) - (TimeUnit.SECONDS.toMinutes(timeoutSeconds) * 60); + + findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT) + .setSummary(timeoutSeconds <= 0 ? getString(R.string.AppProtectionPreferenceFragment_none) : + String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)); + } + + private void initializePhoneNumberPrivacyWhoCanSeeSummary() { + Preference preference = findPreference(PREFERENCE_WHO_CAN_SEE_PHONE_NUMBER); + + switch (SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode()) { + case EVERYONE: preference.setSummary(R.string.PhoneNumberPrivacy_everyone); break; + case CONTACTS: preference.setSummary(R.string.PhoneNumberPrivacy_my_contacts); break; + case NOBODY : preference.setSummary(R.string.PhoneNumberPrivacy_nobody); break; + default : throw new AssertionError(); + } + } + + private void initializePhoneNumberPrivacyWhoCanFindSummary() { + Preference preference = findPreference(PREFERENCE_WHO_CAN_FIND_BY_PHONE_NUMBER); + + switch (SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode()) { + case LISTED : preference.setSummary(R.string.PhoneNumberPrivacy_everyone); break; + case UNLISTED: preference.setSummary(R.string.PhoneNumberPrivacy_nobody); break; + default : throw new AssertionError(); + } + } + + private void initializeVisibility() { + if (TextSecurePreferences.isPasswordDisabled(getContext())) { + findPreference("pref_enable_passphrase_temporary").setVisible(false); + findPreference(TextSecurePreferences.CHANGE_PASSPHRASE_PREF).setVisible(false); + findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF).setVisible(false); + findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_PREF).setVisible(false); + + KeyguardManager keyguardManager = (KeyguardManager)getContext().getSystemService(Context.KEYGUARD_SERVICE); + if (!keyguardManager.isKeyguardSecure()) { + ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.SCREEN_LOCK)).setChecked(false); + findPreference(TextSecurePreferences.SCREEN_LOCK).setEnabled(false); + } + } else { + findPreference(TextSecurePreferences.SCREEN_LOCK).setVisible(false); + findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setVisible(false); + } + } + + private class ScreenLockListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + Log.w(TAG, "Screen lock preference changed: " + newValue); + + boolean enabled = (Boolean)newValue; + TextSecurePreferences.setScreenLockEnabled(getContext(), enabled); + + Intent intent = new Intent(getContext(), KeyCachingService.class); + intent.setAction(KeyCachingService.LOCK_TOGGLED_EVENT); + getContext().startService(intent); + + ConversationUtil.refreshRecipientShortcuts(); + + return true; + } + } + + private class ScreenLockTimeoutListener implements Preference.OnPreferenceClickListener { + + @Override + public boolean onPreferenceClick(Preference preference) { + new TimeDurationPickerDialog(getContext(), (view, duration) -> { + if (duration == 0) { + TextSecurePreferences.setScreenLockTimeout(getContext(), 0); + } else { + long timeoutSeconds = Math.max(TimeUnit.MILLISECONDS.toSeconds(duration), 60); + TextSecurePreferences.setScreenLockTimeout(getContext(), timeoutSeconds); + } + + initializeScreenLockTimeoutSummary(); + }, 0).show(); + + return true; + } + } + + private class KbsPinUpdateListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN); + return true; + } + } + + private class KbsPinCreateListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + startActivityForResult(CreateKbsPinActivity.getIntentForPinCreate(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN); + return true; + } + } + + private class BlockedContactsClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(getActivity(), BlockedUsersActivity.class); + startActivity(intent); + return true; + } + } + + private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(enabled, + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), + SignalStore.settings().isLinkPreviewsEnabled())); + + }); + return true; + } + } + + private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + enabled, + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), + SignalStore.settings().isLinkPreviewsEnabled())); + + if (!enabled) { + ApplicationDependencies.getTypingStatusRepository().clear(); + } + }); + return true; + } + } + + private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()), + enabled)); + if (enabled) { + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.LINK_PREVIEWS); + } + }); + return true; + } + } + + public static CharSequence getSummary(Context context) { + final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary;; + final String onRes = context.getString(R.string.ApplicationPreferencesActivity_on); + final String offRes = context.getString(R.string.ApplicationPreferencesActivity_off); + boolean registrationLockEnabled = RegistrationLockUtil.userHasRegistrationLock(context); + + if (TextSecurePreferences.isPasswordDisabled(context) && !TextSecurePreferences.isScreenLockEnabled(context)) { + if (registrationLockEnabled) { + return context.getString(privacySummaryResId, offRes, onRes); + } else { + return context.getString(privacySummaryResId, offRes, offRes); + } + } else { + if (registrationLockEnabled) { + return context.getString(privacySummaryResId, onRes, onRes); + } else { + return context.getString(privacySummaryResId, onRes, offRes); + } + } + } + + // Derecated + + private class ChangePassphraseClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + if (MasterSecretUtil.isPassphraseInitialized(getActivity())) { + startActivity(new Intent(getActivity(), PassphraseChangeActivity.class)); + } else { + Toast.makeText(getActivity(), + R.string.ApplicationPreferenceActivity_you_havent_set_a_passphrase_yet, + Toast.LENGTH_LONG).show(); + } + + return true; + } + } + + private class PassphraseIntervalClickListener implements Preference.OnPreferenceClickListener { + + @Override + public boolean onPreferenceClick(Preference preference) { + new TimeDurationPickerDialog(getContext(), (view, duration) -> { + int timeoutMinutes = Math.max((int)TimeUnit.MILLISECONDS.toMinutes(duration), 1); + + TextSecurePreferences.setPassphraseTimeoutInterval(getActivity(), timeoutMinutes); + + initializePassphraseTimeoutSummary(); + + }, 0).show(); + + return true; + } + } + + private class DisablePassphraseClickListener implements Preference.OnPreferenceChangeListener { + + @Override + public boolean onPreferenceChange(final Preference preference, Object newValue) { + if (((CheckBoxPreference)preference).isChecked()) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.ApplicationPreferencesActivity_disable_passphrase); + builder.setMessage(R.string.ApplicationPreferencesActivity_this_will_permanently_unlock_signal_and_message_notifications); + builder.setIcon(R.drawable.ic_warning); + builder.setPositiveButton(R.string.ApplicationPreferencesActivity_disable, (dialog, which) -> { + MasterSecretUtil.changeMasterSecretPassphrase(getActivity(), + KeyCachingService.getMasterSecret(getContext()), + MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + + TextSecurePreferences.setPasswordDisabled(getActivity(), true); + ((CheckBoxPreference)preference).setChecked(false); + + Intent intent = new Intent(getActivity(), KeyCachingService.class); + intent.setAction(KeyCachingService.DISABLE_ACTION); + getActivity().startService(intent); + + initializeVisibility(); + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } else { + Intent intent = new Intent(getActivity(), PassphraseChangeActivity.class); + startActivity(intent); + } + + return false; + } + } + + private class ShowUnidentifiedDeliveryIndicatorsChangedListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean enabled = (boolean) newValue; + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(getContext()), + TextSecurePreferences.isTypingIndicatorsEnabled(getContext()), + enabled, + SignalStore.settings().isLinkPreviewsEnabled())); + }); + + return true; + } + } + + private class UniversalUnidentifiedAccessChangedListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object o) { + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + return true; + } + } + + private class UnidentifiedLearnMoreClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + CommunicationActions.openBrowserLink(preference.getContext(), "https://signal.org/blog/sealed-sender/"); + return true; + } + } + + private class IncognitoLearnMoreClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + CommunicationActions.openBrowserLink(preference.getContext(), "https://support.signal.org/hc/en-us/articles/360055276112"); + return true; + } + } + + private class RegistrationLockV2ChangedListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean value = (boolean) newValue; + + Log.i(TAG, "Getting ready to change registration lock setting to: " + value); + + if (value) { + RegistrationLockV2Dialog.showEnableDialog(requireContext(), () -> ((CheckBoxPreference) preference).setChecked(true)); + } else { + RegistrationLockV2Dialog.showDisableDialog(requireContext(), () -> ((CheckBoxPreference) preference).setChecked(false)); + } + + return false; + } + } + + private class PinRemindersChangedListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean value = (boolean) newValue; + + if (!value) { + Context context = preference.getContext(); + DisplayMetrics metrics = preference.getContext().getResources().getDisplayMetrics(); + AlertDialog dialog = new AlertDialog.Builder(context, ThemeUtil.isDarkTheme(context) ? R.style.Theme_Signal_AlertDialog_Dark_Cornered_ColoredAccent : R.style.Theme_Signal_AlertDialog_Light_Cornered_ColoredAccent) + .setView(R.layout.pin_disable_reminders_dialog) + .create(); + + + dialog.show(); + dialog.getWindow().setLayout((int)(metrics.widthPixels * .80), ViewGroup.LayoutParams.WRAP_CONTENT); + + EditText pinEditText = (EditText) DialogCompat.requireViewById(dialog, R.id.reminder_disable_pin); + TextView statusText = (TextView) DialogCompat.requireViewById(dialog, R.id.reminder_disable_status); + View cancelButton = DialogCompat.requireViewById(dialog, R.id.reminder_disable_cancel); + View turnOffButton = DialogCompat.requireViewById(dialog, R.id.reminder_disable_turn_off); + + pinEditText.post(() -> { + if (pinEditText.requestFocus()) { + ServiceUtil.getInputMethodManager(pinEditText.getContext()).showSoftInput(pinEditText, 0); + } + }); + + ViewCompat.setAutofillHints(pinEditText, HintConstants.AUTOFILL_HINT_PASSWORD); + + switch (SignalStore.pinValues().getKeyboardType()) { + case NUMERIC: + pinEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + break; + case ALPHA_NUMERIC: + pinEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + break; + default: + throw new AssertionError("Unexpected type!"); + } + + pinEditText.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + turnOffButton.setEnabled(text.length() >= KbsConstants.MINIMUM_PIN_LENGTH); + } + }); + + pinEditText.setTypeface(Typeface.DEFAULT); + + turnOffButton.setOnClickListener(v -> { + String pin = pinEditText.getText().toString(); + boolean correct = PinHashing.verifyLocalPinHash(Objects.requireNonNull(SignalStore.kbsValues().getLocalPinHash()), pin); + + if (correct) { + SignalStore.pinValues().setPinRemindersEnabled(false); + ((SwitchPreferenceCompat) findPreference(PinValues.PIN_REMINDERS_ENABLED)).setChecked(false); + dialog.dismiss(); + } else { + statusText.setText(R.string.preferences_app_protection__incorrect_pin_try_again); + } + }); + + cancelButton.setOnClickListener(v -> dialog.dismiss()); + + return false; + } else { + return true; + } + } + } + + private final class PhoneNumberPrivacyWhoCanSeeClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + PhoneNumberPrivacyValues phoneNumberPrivacyValues = SignalStore.phoneNumberPrivacy(); + + final PhoneNumberPrivacyValues.PhoneNumberSharingMode[] value = { phoneNumberPrivacyValues.getPhoneNumberSharingMode() }; + + Map items = items(requireContext()); + List modes = new ArrayList<>(items.keySet()); + CharSequence[] modeStrings = items.values().toArray(new CharSequence[0]); + int selectedMode = modes.indexOf(value[0]); + + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.preferences_app_protection__see_my_phone_number) + .setCancelable(true) + .setSingleChoiceItems(modeStrings, selectedMode, (dialog, which) -> value[0] = modes.get(which)) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + PhoneNumberPrivacyValues.PhoneNumberSharingMode phoneNumberSharingMode = value[0]; + phoneNumberPrivacyValues.setPhoneNumberSharingMode(phoneNumberSharingMode); + Log.i(TAG, String.format("PhoneNumberSharingMode changed to %s. Scheduling storage value sync", phoneNumberSharingMode)); + StorageSyncHelper.scheduleSyncForDataChange(); + initializePhoneNumberPrivacyWhoCanSeeSummary(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return true; + } + + private Map items(Context context) { + Map map = new LinkedHashMap<>(); + + map.put(PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYONE, titleAndDescription(context, context.getString(R.string.PhoneNumberPrivacy_everyone), context.getString(R.string.PhoneNumberPrivacy_everyone_see_description))); + map.put(PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY, context.getString(R.string.PhoneNumberPrivacy_nobody)); + + return map; + } + } + + private final class PhoneNumberPrivacyWhoCanFindClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + PhoneNumberPrivacyValues phoneNumberPrivacyValues = SignalStore.phoneNumberPrivacy(); + + final PhoneNumberPrivacyValues.PhoneNumberListingMode[] value = { phoneNumberPrivacyValues.getPhoneNumberListingMode() }; + + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.preferences_app_protection__find_me_by_phone_number) + .setCancelable(true) + .setSingleChoiceItems(items(requireContext()), + value[0].ordinal(), + (dialog, which) -> value[0] = PhoneNumberPrivacyValues.PhoneNumberListingMode.values()[which]) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + PhoneNumberPrivacyValues.PhoneNumberListingMode phoneNumberListingMode = value[0]; + phoneNumberPrivacyValues.setPhoneNumberListingMode(phoneNumberListingMode); + Log.i(TAG, String.format("PhoneNumberListingMode changed to %s. Scheduling storage value sync", phoneNumberListingMode)); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + initializePhoneNumberPrivacyWhoCanFindSummary(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return true; + } + + private CharSequence[] items(Context context) { + return new CharSequence[]{ + titleAndDescription(context, context.getString(R.string.PhoneNumberPrivacy_everyone), context.getString(R.string.PhoneNumberPrivacy_everyone_find_description)), + context.getString(R.string.PhoneNumberPrivacy_nobody) }; + } + } + + /** Adds a detail row for radio group descriptions. */ + private static CharSequence titleAndDescription(@NonNull Context context, @NonNull String header, @NonNull String description) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + + builder.append("\n"); + builder.append(header); + builder.append("\n"); + + builder.setSpan(new TextAppearanceSpan(context, android.R.style.TextAppearance_Small), builder.length(), builder.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + builder.append(description); + builder.append("\n"); + + return builder; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java new file mode 100644 index 00000000..5200b14e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppearancePreferenceFragment.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ActivityTransitionUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; + +import java.util.Arrays; + +public class AppearancePreferenceFragment extends ListSummaryPreferenceFragment { + + private static final String WALLPAPER_PREF = "pref_wallpaper"; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + this.findPreference(TextSecurePreferences.THEME_PREF).setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(TextSecurePreferences.LANGUAGE_PREF).setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(WALLPAPER_PREF).setOnPreferenceClickListener(preference -> { + startActivity(ChatWallpaperActivity.createIntent(requireContext())); + ActivityTransitionUtil.setSlideInTransition(requireActivity()); + return true; + }); + initializeListSummary((ListPreference)findPreference(TextSecurePreferences.THEME_PREF)); + initializeListSummary((ListPreference)findPreference(TextSecurePreferences.LANGUAGE_PREF)); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_appearance); + } + + @Override + public void onStart() { + super.onStart(); + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener((ApplicationPreferencesActivity)getActivity()); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__appearance); + } + + @Override + public void onStop() { + super.onStop(); + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener((ApplicationPreferencesActivity) getActivity()); + } + + public static CharSequence getSummary(Context context) { + String[] languageEntries = context.getResources().getStringArray(R.array.language_entries); + String[] languageEntryValues = context.getResources().getStringArray(R.array.language_values); + String[] themeEntries = context.getResources().getStringArray(R.array.pref_theme_entries); + String[] themeEntryValues = context.getResources().getStringArray(R.array.pref_theme_values); + + int langIndex = Arrays.asList(languageEntryValues).indexOf(TextSecurePreferences.getLanguage(context)); + int themeIndex = Arrays.asList(themeEntryValues).indexOf(TextSecurePreferences.getTheme(context)); + + if (langIndex == -1) langIndex = 0; + if (themeIndex == -1) themeIndex = 0; + + return context.getString(R.string.ApplicationPreferencesActivity_appearance_summary, + themeEntries[themeIndex], + languageEntries[langIndex]); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java new file mode 100644 index 00000000..97ce8140 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProviders; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.preferences.widgets.StorageGraphView; + +import java.util.Arrays; + +public class ApplicationPreferencesViewModel extends ViewModel { + + private final MutableLiveData storageBreakdown = new MutableLiveData<>(); + + LiveData getStorageBreakdown() { + return storageBreakdown; + } + + static ApplicationPreferencesViewModel getApplicationPreferencesViewModel(@NonNull FragmentActivity activity) { + return ViewModelProviders.of(activity).get(ApplicationPreferencesViewModel.class); + } + + void refreshStorageBreakdown(@NonNull Context context) { + SignalExecutors.BOUNDED.execute(() -> { + MediaDatabase.StorageBreakdown breakdown = DatabaseFactory.getMediaDatabase(context) + .getStorageBreakdown(); + + StorageGraphView.StorageBreakdown latestStorageBreakdown = new StorageGraphView.StorageBreakdown(Arrays.asList( + new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_photos), breakdown.getPhotoSize()), + new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_videos), breakdown.getVideoSize()), + new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_files), breakdown.getDocumentSize()), + new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_audio), breakdown.getAudioSize()) + )); + + storageBreakdown.postValue(latestStorageBreakdown); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java new file mode 100644 index 00000000..d9feef51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.preferences; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.text.HtmlCompat; +import androidx.fragment.app.Fragment; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupDialog; +import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; +import java.util.Objects; + +public class BackupsPreferenceFragment extends Fragment { + + private static final String TAG = Log.tag(BackupsPreferenceFragment.class); + + private static final short CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 26212; + + private View create; + private View folder; + private View verify; + private TextView toggle; + private TextView info; + private TextView summary; + private TextView folderName; + private ProgressBar progress; + private TextView progressSummary; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_backups, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + create = view.findViewById(R.id.fragment_backup_create); + folder = view.findViewById(R.id.fragment_backup_folder); + verify = view.findViewById(R.id.fragment_backup_verify); + toggle = view.findViewById(R.id.fragment_backup_toggle); + info = view.findViewById(R.id.fragment_backup_info); + summary = view.findViewById(R.id.fragment_backup_create_summary); + folderName = view.findViewById(R.id.fragment_backup_folder_name); + progress = view.findViewById(R.id.fragment_backup_progress); + progressSummary = view.findViewById(R.id.fragment_backup_progress_summary); + + toggle.setOnClickListener(unused -> onToggleClicked()); + create.setOnClickListener(unused -> onCreateClicked()); + verify.setOnClickListener(unused -> BackupDialog.showVerifyBackupPassphraseDialog(requireContext())); + + EventBus.getDefault().register(this); + } + + @SuppressWarnings("ConstantConditions") + @Override + public void onResume() { + super.onResume(); + ((AppCompatActivity) getActivity()).getSupportActionBar().setTitle(R.string.BackupsPreferenceFragment__chat_backups); + + setBackupStatus(); + setBackupSummary(); + setInfo(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (Build.VERSION.SDK_INT >= 29 && + requestCode == CHOOSE_BACKUPS_LOCATION_REQUEST_CODE && + resultCode == Activity.RESULT_OK && + data != null && + data.getData() != null) + { + BackupDialog.showEnableBackupDialog(requireContext(), + data, + StorageUtil.getDisplayPath(requireContext(), data.getData()), + this::setBackupsEnabled); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(FullBackupBase.BackupEvent event) { + if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + create.setEnabled(false); + summary.setText(getString(R.string.BackupsPreferenceFragment__in_progress)); + progress.setVisibility(View.VISIBLE); + progressSummary.setVisibility(View.VISIBLE); + progressSummary.setText(getString(R.string.BackupsPreferenceFragment__d_so_far, event.getCount())); + } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + create.setEnabled(true); + progress.setVisibility(View.GONE); + progressSummary.setVisibility(View.GONE); + setBackupSummary(); + } + } + + private void setBackupStatus() { + if (TextSecurePreferences.isBackupEnabled(requireContext())) { + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + setBackupsEnabled(); + } else { + Log.w(TAG, "Cannot access backup directory. Disabling backups."); + + BackupUtil.disableBackups(requireContext()); + setBackupsDisabled(); + } + } else { + setBackupsDisabled(); + } + } + + private void setBackupSummary() { + summary.setText(getString(R.string.BackupsPreferenceFragment__last_backup, BackupUtil.getLastBackupTime(requireContext(), Locale.getDefault()))); + } + + private void setBackupFolderName() { + folder.setVisibility(View.GONE); + + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + if (BackupUtil.isUserSelectionRequired(requireContext()) && + BackupUtil.canUserAccessBackupDirectory(requireContext())) + { + Uri backupUri = Objects.requireNonNull(SignalStore.settings().getSignalBackupDirectory()); + + folder.setVisibility(View.VISIBLE); + folderName.setText(StorageUtil.getDisplayPath(requireContext(), backupUri)); + } else if (StorageUtil.canWriteInSignalStorageDir()) { + try { + folder.setVisibility(View.VISIBLE); + folderName.setText(StorageUtil.getOrCreateBackupDirectory().getPath()); + } catch (NoExternalStorageException e) { + Log.w(TAG, "Could not display folder name.", e); + } + } + } + } + + private void setInfo() { + String link = String.format("%s", getString(R.string.backup_support_url), getString(R.string.BackupsPreferenceFragment__learn_more)); + String infoText = getString(R.string.BackupsPreferenceFragment__to_restore_a_backup, link); + + info.setText(HtmlCompat.fromHtml(infoText, 0)); + info.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void onToggleClicked() { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + onToggleClickedApi29(); + } else { + onToggleClickedLegacy(); + } + } + + @RequiresApi(29) + private void onToggleClickedApi29() { + if (!TextSecurePreferences.isBackupEnabled(requireContext())) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_BACKUPS_LOCATION_REQUEST_CODE); + } else { + BackupDialog.showDisableBackupDialog(requireContext(), this::setBackupsDisabled); + } + } + + private void onToggleClickedLegacy() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + if (!TextSecurePreferences.isBackupEnabled(requireContext())) { + BackupDialog.showEnableBackupDialog(requireContext(), null, null, this::setBackupsEnabled); + } else { + BackupDialog.showDisableBackupDialog(requireContext(), this::setBackupsDisabled); + } + }) + .withPermanentDenialDialog(getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) + .execute(); + } + + private void onCreateClicked() { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + onCreateClickedApi29(); + } else { + onCreateClickedLegacy(); + } + } + + @RequiresApi(29) + private void onCreateClickedApi29() { + Log.i(TAG, "Queing backup..."); + LocalBackupJob.enqueue(true); + } + + private void onCreateClickedLegacy() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + Log.i(TAG, "Queuing backup..."); + LocalBackupJob.enqueue(true); + }) + .withPermanentDenialDialog(getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) + .execute(); + } + + private void setBackupsEnabled() { + toggle.setText(R.string.BackupsPreferenceFragment__turn_off); + create.setVisibility(View.VISIBLE); + verify.setVisibility(View.VISIBLE); + setBackupFolderName(); + } + + private void setBackupsDisabled() { + toggle.setText(R.string.BackupsPreferenceFragment__turn_on); + create.setVisibility(View.GONE); + folder.setVisibility(View.GONE); + verify.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactListItem.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactListItem.java new file mode 100644 index 00000000..eeefe1da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactListItem.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; + +public class BlockedContactListItem extends RelativeLayout implements RecipientForeverObserver { + + private AvatarImageView contactPhotoImage; + private TextView nameView; + private GlideRequests glideRequests; + private LiveRecipient recipient; + + public BlockedContactListItem(Context context) { + super(context); + } + + public BlockedContactListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BlockedContactListItem(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.contactPhotoImage = findViewById(R.id.contact_photo_image); + this.nameView = findViewById(R.id.name); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (this.recipient != null) { + recipient.removeForeverObserver(this); + } + } + + public void set(@NonNull GlideRequests glideRequests, @NonNull LiveRecipient recipient) { + this.glideRequests = glideRequests; + this.recipient = recipient; + + onRecipientChanged(recipient.get()); + + this.recipient.observeForever(this); + } + + @Override + public void onRecipientChanged(@NonNull Recipient recipient) { + final AvatarImageView contactPhotoImage = this.contactPhotoImage; + final TextView nameView = this.nameView; + + contactPhotoImage.setAvatar(glideRequests, recipient, false); + nameView.setText(recipient.getDisplayName(getContext())); + } + + public Recipient getRecipient() { + return recipient.get(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java new file mode 100644 index 00000000..a51a662b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; + +import org.greenrobot.eventbus.EventBus; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThrottledDebouncer; + +public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { + private static final String PREFER_SYSTEM_CONTACT_PHOTOS = "pref_system_contact_photos"; + + private final ThrottledDebouncer refreshDebouncer = new ThrottledDebouncer(500); + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + + findPreference(TextSecurePreferences.BACKUP).setOnPreferenceClickListener(unused -> { + goToBackupsPreferenceFragment(); + return true; + }); + + findPreference(PREFER_SYSTEM_CONTACT_PHOTOS) + .setOnPreferenceChangeListener((preference, newValue) -> { + SignalStore.settings().setPreferSystemContactPhotos(newValue == Boolean.TRUE); + refreshDebouncer.publish(ConversationUtil::refreshRecipientShortcuts); + StorageSyncHelper.scheduleSyncForDataChange(); + return true; + }); + + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF)); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_chats); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity)getActivity()).getSupportActionBar().setTitle(R.string.preferences_chats__chats); + } + + @Override + public void onDestroy() { + super.onDestroy(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void goToBackupsPreferenceFragment() { + ((ApplicationPreferencesActivity) requireActivity()).pushFragment(new BackupsPreferenceFragment()); + } + + public static CharSequence getSummary(Context context) { + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java new file mode 100644 index 00000000..e7d3a21e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.preferences; + + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; + +import androidx.core.view.ViewCompat; +import androidx.fragment.app.DialogFragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceGroupAdapter; +import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CustomDefaultPreference; +import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference; +import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat; + +public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + View lv = getView().findViewById(android.R.id.list); + if (lv != null) lv.setPadding(0, 0, 0, 0); + } + + @Override + public void onDisplayPreferenceDialog(Preference preference) { + DialogFragment dialogFragment = null; + + if (preference instanceof ColorPickerPreference) { + dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey()); + } else if (preference instanceof CustomDefaultPreference) { + dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); + } + + if (dialogFragment != null) { + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getFragmentManager(), "android.support.v7.preference.PreferenceFragment.DIALOG"); + } else { + super.onDisplayPreferenceDialog(preference); + } + } + + @Override + @SuppressLint("RestrictedApi") + protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) { + return new PreferenceGroupAdapter(preferenceScreen) { + @Override + public void onBindViewHolder(PreferenceViewHolder holder, int position) { + super.onBindViewHolder(holder, position); + Preference preference = getItem(position); + if (preference instanceof PreferenceCategory) { + setZeroPaddingToLayoutChildren(holder.itemView); + } else { + View iconFrame = holder.itemView.findViewById(R.id.icon_frame); + if (iconFrame != null) { + iconFrame.setVisibility(preference.getIcon() == null ? View.GONE : View.VISIBLE); + } + } + } + }; + } + + private void setZeroPaddingToLayoutChildren(View view) { + if (!(view instanceof ViewGroup)) return; + + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + setZeroPaddingToLayoutChildren(viewGroup.getChildAt(i)); + ViewCompat.setPaddingRelative(viewGroup, 0, viewGroup.getPaddingTop(), ViewCompat.getPaddingEnd(viewGroup), viewGroup.getPaddingBottom()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/DataAndStoragePreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/DataAndStoragePreferenceFragment.java new file mode 100644 index 00000000..f7b0b045 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/DataAndStoragePreferenceFragment.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.preferences; + +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.webrtc.CallBandwidthMode; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +public class DataAndStoragePreferenceFragment extends ListSummaryPreferenceFragment { + + private static final String TAG = Log.tag(DataAndStoragePreferenceFragment.class); + private static final String MANAGE_STORAGE_KEY = "pref_data_manage"; + private static final String USE_PROXY_KEY = "pref_use_proxy"; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF) + .setOnPreferenceChangeListener(new MediaDownloadChangeListener()); + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF) + .setOnPreferenceChangeListener(new MediaDownloadChangeListener()); + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF) + .setOnPreferenceChangeListener(new MediaDownloadChangeListener()); + + findPreference(TextSecurePreferences.CALL_BANDWIDTH_PREF) + .setOnPreferenceChangeListener(new CallBandwidthChangeListener()); + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.CALL_BANDWIDTH_PREF)); + + Preference manageStorage = findPreference(MANAGE_STORAGE_KEY); + manageStorage.setOnPreferenceClickListener(unused -> { + requireApplicationPreferencesActivity().pushFragment(new StoragePreferenceFragment()); + return false; + }); + + ApplicationPreferencesViewModel viewModel = ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(requireActivity()); + + viewModel.getStorageBreakdown() + .observe(requireActivity(), + breakdown -> manageStorage.setSummary(Util.getPrettyFileSize(breakdown.getTotalSize()))); + + + findPreference(USE_PROXY_KEY).setOnPreferenceClickListener(unused -> { + requireApplicationPreferencesActivity().pushFragment(EditProxyFragment.newInstance()); + return false; + }); + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_data_and_storage); + } + + @Override + public void onResume() { + super.onResume(); + requireApplicationPreferencesActivity().getSupportActionBar().setTitle(R.string.preferences__data_and_storage); + setMediaDownloadSummaries(); + ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(requireActivity()).refreshStorageBreakdown(requireContext()); + findPreference(USE_PROXY_KEY).setSummary(SignalStore.proxy().isProxyEnabled() ? R.string.preferences_on : R.string.preferences_off); + } + + private @NonNull ApplicationPreferencesActivity requireApplicationPreferencesActivity() { + return (ApplicationPreferencesActivity) requireActivity(); + } + + private void setMediaDownloadSummaries() { + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF) + .setSummary(getSummaryForMediaPreference(TextSecurePreferences.getMobileMediaDownloadAllowed(getActivity()))); + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF) + .setSummary(getSummaryForMediaPreference(TextSecurePreferences.getWifiMediaDownloadAllowed(getActivity()))); + findPreference(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF) + .setSummary(getSummaryForMediaPreference(TextSecurePreferences.getRoamingMediaDownloadAllowed(getActivity()))); + } + + private CharSequence getSummaryForMediaPreference(Set allowedNetworks) { + String[] keys = getResources().getStringArray(R.array.pref_media_download_entries); + String[] values = getResources().getStringArray(R.array.pref_media_download_values); + List outValues = new ArrayList<>(allowedNetworks.size()); + + for (int i=0; i < keys.length; i++) { + if (allowedNetworks.contains(keys[i])) outValues.add(values[i]); + } + + return outValues.isEmpty() ? getResources().getString(R.string.preferences__none) + : TextUtils.join(", ", outValues); + } + + private class MediaDownloadChangeListener implements Preference.OnPreferenceChangeListener { + @SuppressWarnings("unchecked") + @Override public boolean onPreferenceChange(Preference preference, Object newValue) { + Log.i(TAG, "onPreferenceChange"); + preference.setSummary(getSummaryForMediaPreference((Set)newValue)); + return true; + } + } + + private class CallBandwidthChangeListener extends ListSummaryListener { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + ListPreference listPref = (ListPreference) preference; + int entryIndex = Arrays.asList(listPref.getEntryValues()).indexOf(value); + + switch (entryIndex) { + case 0: + SignalStore.settings().setCallBandwidthMode(CallBandwidthMode.HIGH_ALWAYS); + break; + case 1: + SignalStore.settings().setCallBandwidthMode(CallBandwidthMode.HIGH_ON_WIFI); + break; + case 2: + SignalStore.settings().setCallBandwidthMode(CallBandwidthMode.LOW_ALWAYS); + break; + default: + throw new AssertionError(); + } + + WebRtcCallService.notifyBandwidthModeUpdated(requireContext()); + + return super.onPreferenceChange(preference, value); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyFragment.java new file mode 100644 index 00000000..73b47ce9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyFragment.java @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.preferences; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SwitchCompat; +import androidx.core.app.ShareCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SignalProxyUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.configuration.SignalProxy; + +public class EditProxyFragment extends Fragment { + + private SwitchCompat proxySwitch; + private EditText proxyText; + private TextView proxyTitle; + private TextView proxyStatus; + private View shareButton; + private CircularProgressButton saveButton; + private EditProxyViewModel viewModel; + + public static EditProxyFragment newInstance() { + return new EditProxyFragment(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.edit_proxy_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.proxySwitch = view.findViewById(R.id.edit_proxy_switch); + this.proxyTitle = view.findViewById(R.id.edit_proxy_address_title); + this.proxyText = view.findViewById(R.id.edit_proxy_host); + this.proxyStatus = view.findViewById(R.id.edit_proxy_status); + this.saveButton = view.findViewById(R.id.edit_proxy_save); + this.shareButton = view.findViewById(R.id.edit_proxy_share); + + proxyText.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + onProxyTextChanged(text); + } + }); + + this.proxyText.setText(Optional.fromNullable(SignalStore.proxy().getProxy()).transform(SignalProxy::getHost).or("")); + this.proxySwitch.setChecked(SignalStore.proxy().isProxyEnabled()); + + initViewModel(); + + saveButton.setOnClickListener(v -> onSaveClicked()); + shareButton.setOnClickListener(v -> onShareClicked()); + proxySwitch.setOnCheckedChangeListener((buttonView, isChecked) -> viewModel.onToggleProxy(isChecked)); + + LearnMoreTextView description = view.findViewById(R.id.edit_proxy_switch_title_description); + description.setLearnMoreVisible(true); + description.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360056052052")); + + requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + } + + @Override + public void onResume() { + super.onResume(); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(R.string.preferences_use_proxy); + SignalProxyUtil.startListeningToWebsocket(); + } + + private void initViewModel() { + viewModel = ViewModelProviders.of(this).get(EditProxyViewModel.class); + + viewModel.getUiState().observe(getViewLifecycleOwner(), this::presentUiState); + viewModel.getProxyState().observe(getViewLifecycleOwner(), this::presentProxyState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + } + + private void presentUiState(@NonNull EditProxyViewModel.UiState uiState) { + switch (uiState) { + case ALL_ENABLED: + proxyText.setEnabled(true); + proxyText.setAlpha(1); + proxyTitle.setAlpha(1); + onProxyTextChanged(proxyText.getText().toString()); + break; + case ALL_DISABLED: + proxyText.setEnabled(false); + proxyText.setAlpha(0.5f); + saveButton.setEnabled(false); + saveButton.setAlpha(0.5f); + shareButton.setEnabled(false); + shareButton.setAlpha(0.5f); + proxyTitle.setAlpha(0.5f); + proxyStatus.setVisibility(View.INVISIBLE); + break; + } + } + + private void presentProxyState(@NonNull PipeConnectivityListener.State proxyState) { + if (SignalStore.proxy().getProxy() != null) { + switch (proxyState) { + case DISCONNECTED: + case CONNECTING: + proxyStatus.setText(R.string.preferences_connecting_to_proxy); + proxyStatus.setTextColor(getResources().getColor(R.color.signal_text_secondary)); + break; + case CONNECTED: + proxyStatus.setText(R.string.preferences_connected_to_proxy); + proxyStatus.setTextColor(getResources().getColor(R.color.signal_accent_green)); + break; + case FAILURE: + proxyStatus.setText(R.string.preferences_connection_failed); + proxyStatus.setTextColor(getResources().getColor(R.color.signal_alert_primary)); + break; + } + } else { + proxyStatus.setText(""); + } + } + + private void presentEvent(@NonNull EditProxyViewModel.Event event) { + switch (event) { + case PROXY_SUCCESS: + proxyStatus.setVisibility(View.VISIBLE); + proxyText.setText(Optional.fromNullable(SignalStore.proxy().getProxy()).transform(SignalProxy::getHost).or("")); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.preferences_success) + .setMessage(R.string.preferences_you_are_connected_to_the_proxy) + .setPositiveButton(android.R.string.ok, (d, i) -> { + requireActivity().onBackPressed(); + d.dismiss(); + }) + .show(); + break; + case PROXY_FAILURE: + proxyStatus.setVisibility(View.INVISIBLE); + proxyText.setText(Optional.fromNullable(SignalStore.proxy().getProxy()).transform(SignalProxy::getHost).or("")); + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(proxyText); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.preferences_failed_to_connect) + .setMessage(R.string.preferences_couldnt_connect_to_the_proxy) + .setPositiveButton(android.R.string.ok, (d, i) -> d.dismiss()) + .show(); + break; + } + } + + private void presentSaveState(@NonNull EditProxyViewModel.SaveState state) { + switch (state) { + case IDLE: + saveButton.setClickable(true); + saveButton.setIndeterminateProgressMode(false); + saveButton.setProgress(0); + break; + case IN_PROGRESS: + saveButton.setClickable(false); + saveButton.setIndeterminateProgressMode(true); + saveButton.setProgress(50); + break; + } + } + + private void onSaveClicked() { + viewModel.onSaveClicked(proxyText.getText().toString()); + } + + private void onShareClicked() { + String link = SignalProxyUtil.generateProxyUrl(proxyText.getText().toString()); + ShareCompat.IntentBuilder.from(requireActivity()) + .setText(link) + .setType("text/plain") + .startChooser(); + } + + private void onProxyTextChanged(@NonNull String text) { + if (Util.isEmpty(text)) { + saveButton.setEnabled(false); + saveButton.setAlpha(0.5f); + shareButton.setEnabled(false); + shareButton.setAlpha(0.5f); + proxyStatus.setVisibility(View.INVISIBLE); + } else { + saveButton.setEnabled(true); + saveButton.setAlpha(1); + shareButton.setEnabled(true); + shareButton.setAlpha(1); + + String trueHost = SignalProxyUtil.convertUserEnteredAddressToHost(proxyText.getText().toString()); + if (SignalStore.proxy().isProxyEnabled() && trueHost.equals(SignalStore.proxy().getProxyHost())) { + proxyStatus.setVisibility(View.VISIBLE); + } else { + proxyStatus.setVisibility(View.INVISIBLE); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java new file mode 100644 index 00000000..98f5c1a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/EditProxyViewModel.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.preferences; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.util.SignalProxyUtil; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.internal.configuration.SignalProxy; + +import java.util.concurrent.TimeUnit; + +public class EditProxyViewModel extends ViewModel { + + private final SingleLiveEvent events; + private final MutableLiveData uiState; + private final MutableLiveData saveState; + private final LiveData pipeState; + + public EditProxyViewModel() { + this.events = new SingleLiveEvent<>(); + this.uiState = new MutableLiveData<>(); + this.saveState = new MutableLiveData<>(SaveState.IDLE); + this.pipeState = TextSecurePreferences.getLocalNumber(ApplicationDependencies.getApplication()) == null ? new MutableLiveData<>() + : ApplicationDependencies.getPipeListener().getState(); + + if (SignalStore.proxy().isProxyEnabled()) { + uiState.setValue(UiState.ALL_ENABLED); + } else { + uiState.setValue(UiState.ALL_DISABLED); + } + } + + void onToggleProxy(boolean enabled) { + if (enabled) { + SignalProxy currentProxy = SignalStore.proxy().getProxy(); + + if (currentProxy != null && !Util.isEmpty(currentProxy.getHost())) { + SignalProxyUtil.enableProxy(currentProxy); + } + uiState.postValue(UiState.ALL_ENABLED); + } else { + SignalProxyUtil.disableProxy(); + uiState.postValue(UiState.ALL_DISABLED); + } + } + + public void onSaveClicked(@NonNull String host) { + String trueHost = SignalProxyUtil.convertUserEnteredAddressToHost(host); + + saveState.postValue(SaveState.IN_PROGRESS); + + SignalExecutors.BOUNDED.execute(() -> { + SignalProxyUtil.enableProxy(new SignalProxy(trueHost, 443)); + + boolean success = SignalProxyUtil.testWebsocketConnection(TimeUnit.SECONDS.toMillis(10)); + + if (success) { + events.postValue(Event.PROXY_SUCCESS); + } else { + SignalProxyUtil.disableProxy(); + events.postValue(Event.PROXY_FAILURE); + } + + saveState.postValue(SaveState.IDLE); + }); + } + + @NonNull LiveData getUiState() { + return uiState; + } + + public @NonNull LiveData getEvents() { + return events; + } + + @NonNull LiveData getProxyState() { + return pipeState; + } + + public @NonNull LiveData getSaveState() { + return saveState; + } + + enum UiState { + ALL_DISABLED, ALL_ENABLED + } + + public enum Event { + PROXY_SUCCESS, PROXY_FAILURE + } + + public enum SaveState { + IDLE, IN_PROGRESS + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java new file mode 100644 index 00000000..2356c46e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.preferences; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceDataStore; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; +import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; +import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; +import org.thoughtcrime.securesms.jobs.StorageForcePushJob; +import org.thoughtcrime.securesms.keyvalue.InternalValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.ConversationUtil; + +public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragment { + private static final String TAG = Log.tag(InternalOptionsPreferenceFragment.class); + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_internal); + + PreferenceDataStore preferenceDataStore = SignalStore.getPreferenceDataStore(); + + initializeSwitchPreference(preferenceDataStore, InternalValues.RECIPIENT_DETAILS, SignalStore.internalValues().recipientDetails()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DO_NOT_CREATE_GV2, SignalStore.internalValues().gv2DoNotCreateGv2Groups()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_FORCE_INVITES, SignalStore.internalValues().gv2ForceInvites()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_SERVER_CHANGES, SignalStore.internalValues().gv2IgnoreServerChanges()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_P2P_CHANGES, SignalStore.internalValues().gv2IgnoreP2PChanges()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DISABLE_AUTOMIGRATE_INITIATION, SignalStore.internalValues().disableGv1AutoMigrateInitiation()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DISABLE_AUTOMIGRATE_NOTIFICATION, SignalStore.internalValues().disableGv1AutoMigrateNotification()); + initializeSwitchPreference(preferenceDataStore, InternalValues.FORCE_CENSORSHIP, SignalStore.internalValues().forcedCensorship()); + + findPreference("pref_refresh_attributes").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager() + .startChain(new RefreshAttributesJob()) + .then(new RefreshOwnProfileJob()) + .enqueue(); + Toast.makeText(getContext(), "Scheduled attribute refresh", Toast.LENGTH_SHORT).show(); + return true; + }); + + findPreference("pref_rotate_profile_key").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager().add(new RotateProfileKeyJob()); + Toast.makeText(getContext(), "Scheduled profile key rotation", Toast.LENGTH_SHORT).show(); + return true; + }); + + findPreference("pref_refresh_remote_values").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); + Toast.makeText(getContext(), "Scheduled remote config refresh", Toast.LENGTH_SHORT).show(); + return true; + }); + + findPreference("pref_force_send").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager().add(new StorageForcePushJob()); + Toast.makeText(getContext(), "Scheduled storage force push", Toast.LENGTH_SHORT).show(); + return true; + }); + + findPreference("pref_delete_dynamic_shortcuts").setOnPreferenceClickListener(preference -> { + ConversationUtil.clearAllShortcuts(requireContext()); + Toast.makeText(getContext(), "Deleted all dynamic shortcuts.", Toast.LENGTH_SHORT).show(); + return true; + }); + } + + private void initializeSwitchPreference(@NonNull PreferenceDataStore preferenceDataStore, + @NonNull String key, + boolean checked) + { + SwitchPreferenceCompat forceGv2Preference = (SwitchPreferenceCompat) findPreference(key); + forceGv2Preference.setPreferenceDataStore(preferenceDataStore); + forceGv2Preference.setChecked(checked); + } + + @Override + public void onResume() { + super.onResume(); + //noinspection ConstantConditions + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__internal_preferences); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java new file mode 100644 index 00000000..acf36570 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.preferences; + + +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.R; + +import java.util.Arrays; + +public abstract class ListSummaryPreferenceFragment extends CorrectedPreferenceFragment { + + protected class ListSummaryListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + ListPreference listPref = (ListPreference) preference; + int entryIndex = Arrays.asList(listPref.getEntryValues()).indexOf(value); + + listPref.setSummary(entryIndex >= 0 && entryIndex < listPref.getEntries().length + ? listPref.getEntries()[entryIndex] + : getString(R.string.preferences__led_color_unknown)); + return true; + } + } + + protected void initializeListSummary(ListPreference pref) { + pref.setSummary(pref.getEntry()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesActivity.java new file mode 100644 index 00000000..5c0534f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesActivity.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.preferences; + +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class MmsPreferencesActivity extends PassphraseRequiredActivity { + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + assert getSupportActionBar() != null; + this.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + Fragment fragment = new MmsPreferencesFragment(); + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.replace(android.R.id.content, fragment); + fragmentTransaction.commit(); + + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + + return false; + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesFragment.java new file mode 100644 index 00000000..c1b83673 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/MmsPreferencesFragment.java @@ -0,0 +1,104 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.CustomDefaultPreference; +import org.thoughtcrime.securesms.database.ApnDatabase; +import org.thoughtcrime.securesms.mms.LegacyMmsConnection; +import org.thoughtcrime.securesms.util.TelephonyUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; + + +public class MmsPreferencesFragment extends CorrectedPreferenceFragment { + + private static final String TAG = MmsPreferencesFragment.class.getSimpleName(); + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + ((PassphraseRequiredActivity) getActivity()).getSupportActionBar() + .setTitle(R.string.preferences__advanced_mms_access_point_names); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_manual_mms); + } + + @Override + public void onResume() { + super.onResume(); + new LoadApnDefaultsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private class LoadApnDefaultsTask extends AsyncTask { + + @Override + protected LegacyMmsConnection.Apn doInBackground(Void... params) { + try { + Context context = getActivity(); + + if (context != null) { + return ApnDatabase.getInstance(context) + .getDefaultApnParameters(TelephonyUtil.getMccMnc(context), + TelephonyUtil.getApn(context)); + } + } catch (IOException e) { + Log.w(TAG, e); + } + + return null; + } + + @Override + protected void onPostExecute(LegacyMmsConnection.Apn apnDefaults) { + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMSC_HOST_PREF)) + .setValidator(new CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.UriValidator()) + .setDefaultValue(apnDefaults.getMmsc()); + + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMSC_PROXY_HOST_PREF)) + .setValidator(new CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.HostnameValidator()) + .setDefaultValue(apnDefaults.getProxy()); + + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMSC_PROXY_PORT_PREF)) + .setValidator(new CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.PortValidator()) + .setDefaultValue(apnDefaults.getPort()); + + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMSC_USERNAME_PREF)) + .setDefaultValue(apnDefaults.getPort()); + + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMSC_PASSWORD_PREF)) + .setDefaultValue(apnDefaults.getPassword()); + + ((CustomDefaultPreference)findPreference(TextSecurePreferences.MMS_USER_AGENT)) + .setDefaultValue(LegacyMmsConnection.USER_AGENT); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java new file mode 100644 index 00000000..23709a56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -0,0 +1,240 @@ +package org.thoughtcrime.securesms.preferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.Settings; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import static android.app.Activity.RESULT_OK; + +public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragment { + + @SuppressWarnings("unused") + private static final String TAG = NotificationsPreferenceFragment.class.getSimpleName(); + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + Preference ledBlinkPref = this.findPreference(TextSecurePreferences.LED_BLINK_PREF); + + if (NotificationChannels.supported()) { + ledBlinkPref.setVisible(false); + TextSecurePreferences.setNotificationRingtone(getContext(), NotificationChannels.getMessageRingtone(getContext()).toString()); + TextSecurePreferences.setNotificationVibrateEnabled(getContext(), NotificationChannels.getMessageVibrate(getContext())); + + } else { + ledBlinkPref.setOnPreferenceChangeListener(new ListSummaryListener()); + initializeListSummary((ListPreference) ledBlinkPref); + } + + this.findPreference(TextSecurePreferences.LED_COLOR_PREF) + .setOnPreferenceChangeListener(new LedColorChangeListener()); + this.findPreference(TextSecurePreferences.RINGTONE_PREF) + .setOnPreferenceChangeListener(new RingtoneSummaryListener()); + this.findPreference(TextSecurePreferences.REPEAT_ALERTS_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) + .setOnPreferenceChangeListener(new NotificationPrivacyListener()); + this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF) + .setOnPreferenceChangeListener(new ListSummaryListener()); + this.findPreference(TextSecurePreferences.CALL_RINGTONE_PREF) + .setOnPreferenceChangeListener(new RingtoneSummaryListener()); + this.findPreference(TextSecurePreferences.VIBRATE_PREF) + .setOnPreferenceChangeListener((preference, newValue) -> { + NotificationChannels.updateMessageVibrate(getContext(), (boolean) newValue); + return true; + }); + + this.findPreference(TextSecurePreferences.RINGTONE_PREF) + .setOnPreferenceClickListener(preference -> { + Uri current = TextSecurePreferences.getNotificationRingtone(getContext()); + + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + + startActivityForResult(intent, 1); + + return true; + }); + + this.findPreference(TextSecurePreferences.CALL_RINGTONE_PREF) + .setOnPreferenceClickListener(preference -> { + Uri current = TextSecurePreferences.getCallNotificationRingtone(getContext()); + + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_RINGTONE_URI); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + + startActivityForResult(intent, 2); + + return true; + }); + + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.LED_COLOR_PREF)); + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.REPEAT_ALERTS_PREF)); + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)); + + if (NotificationChannels.supported()) { + this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF) + .setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(getContext())); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName()); + startActivity(intent); + return true; + }); + } else { + initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)); + } + + initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)); + initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF)); + initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF)); + initializeCallVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_VIBRATE_PREF)); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_notifications); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__notifications); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == 1 && resultCode == RESULT_OK && data != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + if (Settings.System.DEFAULT_NOTIFICATION_URI.equals(uri)) { + NotificationChannels.updateMessageRingtone(getContext(), uri); + TextSecurePreferences.removeNotificationRingtone(getContext()); + } else { + uri = uri == null ? Uri.EMPTY : uri; + NotificationChannels.updateMessageRingtone(getContext(), uri ); + TextSecurePreferences.setNotificationRingtone(getContext(), uri.toString()); + } + + initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)); + } else if (requestCode == 2 && resultCode == RESULT_OK && data != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + if (Settings.System.DEFAULT_RINGTONE_URI.equals(uri)) { + TextSecurePreferences.removeCallNotificationRingtone(getContext()); + } else { + TextSecurePreferences.setCallNotificationRingtone(getContext(), uri != null ? uri.toString() : Uri.EMPTY.toString()); + } + + initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF)); + } + } + + private class RingtoneSummaryListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + Uri value = (Uri) newValue; + + if (value == null || TextUtils.isEmpty(value.toString())) { + preference.setSummary(R.string.preferences__silent); + } else { + Ringtone tone = RingtoneManager.getRingtone(getActivity(), value); + + if (tone != null) { + preference.setSummary(tone.getTitle(getActivity())); + } + } + + return true; + } + } + + private void initializeRingtoneSummary(Preference pref) { + RingtoneSummaryListener listener = (RingtoneSummaryListener) pref.getOnPreferenceChangeListener(); + Uri uri = TextSecurePreferences.getNotificationRingtone(getContext()); + + listener.onPreferenceChange(pref, uri); + } + + private void initializeCallRingtoneSummary(Preference pref) { + RingtoneSummaryListener listener = (RingtoneSummaryListener) pref.getOnPreferenceChangeListener(); + Uri uri = TextSecurePreferences.getCallNotificationRingtone(getContext()); + + listener.onPreferenceChange(pref, uri); + } + + private void initializeMessageVibrateSummary(SwitchPreferenceCompat pref) { + pref.setChecked(TextSecurePreferences.isNotificationVibrateEnabled(getContext())); + } + + private void initializeCallVibrateSummary(SwitchPreferenceCompat pref) { + pref.setChecked(TextSecurePreferences.isCallNotificationVibrateEnabled(getContext())); + } + + public static CharSequence getSummary(Context context) { + final int onCapsResId = R.string.ApplicationPreferencesActivity_On; + final int offCapsResId = R.string.ApplicationPreferencesActivity_Off; + + return context.getString(TextSecurePreferences.isNotificationsEnabled(context) ? onCapsResId : offCapsResId); + } + + private class NotificationPrivacyListener extends ListSummaryListener { + @SuppressLint("StaticFieldLeak") + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + ApplicationDependencies.getMessageNotifier().updateNotification(getActivity()); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return super.onPreferenceChange(preference, value); + } + } + + @SuppressLint("StaticFieldLeak") + private class LedColorChangeListener extends ListSummaryListener { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + if (NotificationChannels.supported()) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + NotificationChannels.updateMessagesLedColor(getActivity(), (String) value); + return null; + } + }.execute(); + } + return super.onPreferenceChange(preference, value); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SmsMmsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/SmsMmsPreferenceFragment.java new file mode 100644 index 00000000..c5f47723 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SmsMmsPreferenceFragment.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.preferences; + +import android.content.Context; +import android.content.Intent; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.provider.Settings; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.SmsUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +public class SmsMmsPreferenceFragment extends CorrectedPreferenceFragment { + private static final String KITKAT_DEFAULT_PREF = "pref_set_default"; + private static final String MMS_PREF = "pref_mms_preferences"; + private static final short SMS_ROLE_REQUEST_CODE = 1234; + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + + + this.findPreference(MMS_PREF) + .setOnPreferenceClickListener(new ApnPreferencesClickListener()); + + initializePlatformSpecificOptions(); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_sms_mms); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__sms_mms); + + initializeDefaultPreference(); + } + + private void initializePlatformSpecificOptions() { + PreferenceScreen preferenceScreen = getPreferenceScreen(); + Preference defaultPreference = findPreference(KITKAT_DEFAULT_PREF); + Preference allSmsPreference = findPreference(TextSecurePreferences.ALL_SMS_PREF); + Preference allMmsPreference = findPreference(TextSecurePreferences.ALL_MMS_PREF); + Preference manualMmsPreference = findPreference(MMS_PREF); + + if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) { + if (allSmsPreference != null) preferenceScreen.removePreference(allSmsPreference); + if (allMmsPreference != null) preferenceScreen.removePreference(allMmsPreference); + } else if (defaultPreference != null) { + preferenceScreen.removePreference(defaultPreference); + } + + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && manualMmsPreference != null) { + preferenceScreen.removePreference(manualMmsPreference); + } + } + + private void initializeDefaultPreference() { + Preference defaultPreference = findPreference(KITKAT_DEFAULT_PREF); + if (Util.isDefaultSmsProvider(getActivity())) { + defaultPreference.setOnPreferenceClickListener(null); + + if (VERSION.SDK_INT < VERSION_CODES.M) defaultPreference.setIntent(new Intent(Settings.ACTION_WIRELESS_SETTINGS)); + if (VERSION.SDK_INT < VERSION_CODES.N) defaultPreference.setIntent(new Intent(Settings.ACTION_SETTINGS)); + else defaultPreference.setIntent(new Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)); + + defaultPreference.setTitle(getString(R.string.ApplicationPreferencesActivity_sms_enabled)); + defaultPreference.setSummary(getString(R.string.ApplicationPreferencesActivity_touch_to_change_your_default_sms_app)); + } else { + defaultPreference.setTitle(getString(R.string.ApplicationPreferencesActivity_sms_disabled)); + defaultPreference.setSummary(getString(R.string.ApplicationPreferencesActivity_touch_to_make_signal_your_default_sms_app)); + + defaultPreference.setOnPreferenceClickListener(preference -> { + startActivityForResult(SmsUtil.getSmsRoleIntent(requireContext()), SMS_ROLE_REQUEST_CODE); + return true; + }); + } + } + + private class ApnPreferencesClickListener implements Preference.OnPreferenceClickListener { + + @Override + public boolean onPreferenceClick(Preference preference) { + Fragment fragment = new MmsPreferencesFragment(); + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.replace(android.R.id.content, fragment); + fragmentTransaction.addToBackStack(null); + fragmentTransaction.commit(); + + return true; + } + } + + public static CharSequence getSummary(Context context) { + final String on = context.getString(R.string.ApplicationPreferencesActivity_on); + final String onCaps = context.getString(R.string.ApplicationPreferencesActivity_On); + final String off = context.getString(R.string.ApplicationPreferencesActivity_off); + final String offCaps = context.getString(R.string.ApplicationPreferencesActivity_Off); + final int smsMmsSummaryResId = R.string.ApplicationPreferencesActivity_sms_mms_summary; + + boolean postKitkatSMS = Util.isDefaultSmsProvider(context); + boolean preKitkatSMS = TextSecurePreferences.isInterceptAllSmsEnabled(context); + boolean preKitkatMMS = TextSecurePreferences.isInterceptAllMmsEnabled(context); + + if (postKitkatSMS) return onCaps; + else return offCaps; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java new file mode 100644 index 00000000..80b4c0f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.preferences; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; +import androidx.preference.Preference; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.BaseSettingsAdapter; +import org.thoughtcrime.securesms.components.settings.BaseSettingsFragment; +import org.thoughtcrime.securesms.components.settings.CustomizableSingleSelectSetting; +import org.thoughtcrime.securesms.components.settings.SingleSelectSetting; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; +import org.thoughtcrime.securesms.keyvalue.SettingsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.preferences.widgets.StoragePreferenceCategory; +import org.thoughtcrime.securesms.util.MappingModelList; +import org.thoughtcrime.securesms.util.StringUtil; + +import java.text.NumberFormat; + +public class StoragePreferenceFragment extends ListSummaryPreferenceFragment { + + private Preference keepMessages; + private Preference trimLength; + + @Override + public void onCreate(@Nullable Bundle paramBundle) { + super.onCreate(paramBundle); + + findPreference("pref_storage_clear_message_history") + .setOnPreferenceClickListener(new ClearMessageHistoryClickListener()); + + trimLength = findPreference(SettingsValues.THREAD_TRIM_LENGTH); + trimLength.setOnPreferenceClickListener(p -> { + getApplicationPreferencesActivity().requireSupportActionBar().setTitle(R.string.preferences__conversation_length_limit); + getApplicationPreferencesActivity().pushFragment(BaseSettingsFragment.create(new ConversationLengthLimitConfiguration())); + return true; + }); + + keepMessages = findPreference(SettingsValues.KEEP_MESSAGES_DURATION); + keepMessages.setOnPreferenceClickListener(p -> { + getApplicationPreferencesActivity().requireSupportActionBar().setTitle(R.string.preferences__keep_messages); + getApplicationPreferencesActivity().pushFragment(BaseSettingsFragment.create(new KeepMessagesConfiguration())); + return true; + }); + + StoragePreferenceCategory storageCategory = (StoragePreferenceCategory) findPreference("pref_storage_category"); + FragmentActivity activity = requireActivity(); + ApplicationPreferencesViewModel viewModel = ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(activity); + + storageCategory.setOnFreeUpSpace(() -> activity.startActivity(MediaOverviewActivity.forAll(activity))); + + viewModel.getStorageBreakdown().observe(activity, storageCategory::setStorage); + } + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + addPreferencesFromResource(R.xml.preferences_storage); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) requireActivity()).requireSupportActionBar().setTitle(R.string.preferences__storage); + + FragmentActivity activity = requireActivity(); + ApplicationPreferencesViewModel viewModel = ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(activity); + + viewModel.refreshStorageBreakdown(activity.getApplicationContext()); + + keepMessages.setSummary(SignalStore.settings().getKeepMessagesDuration().getStringResource()); + + trimLength.setSummary(SignalStore.settings().isTrimByLengthEnabled() ? getString(R.string.preferences_storage__s_messages, NumberFormat.getInstance().format(SignalStore.settings().getThreadTrimLength())) + : getString(R.string.preferences_storage__none)); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private @NonNull ApplicationPreferencesActivity getApplicationPreferencesActivity() { + return (ApplicationPreferencesActivity) requireActivity(); + } + + private class ClearMessageHistoryClickListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.preferences_storage__clear_message_history) + .setMessage(R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device) + .setPositiveButton(R.string.delete, (d, w) -> showAreYouReallySure()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return true; + } + + private void showAreYouReallySure() { + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.preferences_storage__are_you_sure_you_want_to_delete_all_message_history) + .setMessage(R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone) + .setPositiveButton(R.string.preferences_storage__delete_all_now, (d, w) -> SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()).deleteAllConversations())) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + } + + public static class KeepMessagesConfiguration extends BaseSettingsFragment.Configuration implements SingleSelectSetting.SingleSelectSelectionChangedListener { + + @Override + public void configureAdapter(@NonNull BaseSettingsAdapter adapter) { + adapter.configureSingleSelect(this); + } + + @Override + public @NonNull MappingModelList getSettings() { + KeepMessagesDuration currentDuration = SignalStore.settings().getKeepMessagesDuration(); + return Stream.of(KeepMessagesDuration.values()) + .map(duration -> new SingleSelectSetting.Item(duration, activity.getString(duration.getStringResource()), duration.equals(currentDuration))) + .collect(MappingModelList.toMappingModelList()); + } + + @Override + public void onSelectionChanged(@NonNull Object selection) { + KeepMessagesDuration currentDuration = SignalStore.settings().getKeepMessagesDuration(); + KeepMessagesDuration newDuration = (KeepMessagesDuration) selection; + + if (newDuration.ordinal() > currentDuration.ordinal()) { + new AlertDialog.Builder(activity) + .setTitle(R.string.preferences_storage__delete_older_messages) + .setMessage(activity.getString(R.string.preferences_storage__this_will_permanently_delete_all_message_history_and_media, activity.getString(newDuration.getStringResource()))) + .setPositiveButton(R.string.delete, (d, w) -> updateTrimByTime(newDuration)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + updateTrimByTime(newDuration); + } + } + + private void updateTrimByTime(@NonNull KeepMessagesDuration newDuration) { + SignalStore.settings().setKeepMessagesForDuration(newDuration); + updateSettingsList(); + ApplicationDependencies.getTrimThreadsByDateManager().scheduleIfNecessary(); + } + } + + public static class ConversationLengthLimitConfiguration extends BaseSettingsFragment.Configuration implements CustomizableSingleSelectSetting.CustomizableSingleSelectionListener { + + private static final int CUSTOM_LENGTH = -1; + + @Override + public void configureAdapter(@NonNull BaseSettingsAdapter adapter) { + adapter.configureSingleSelect(this); + adapter.configureCustomizableSingleSelect(this); + } + + @Override + public @NonNull MappingModelList getSettings() { + int trimLength = SignalStore.settings().isTrimByLengthEnabled() ? SignalStore.settings().getThreadTrimLength() : 0; + int[] options = activity.getResources().getIntArray(R.array.conversation_length_limit); + boolean hasSelection = false; + MappingModelList settings = new MappingModelList(); + + for (int option : options) { + boolean isSelected = option == trimLength; + String text = option == 0 ? activity.getString(R.string.preferences_storage__none) + : activity.getString(R.string.preferences_storage__s_messages, NumberFormat.getInstance().format(option)); + + settings.add(new SingleSelectSetting.Item(option, text, isSelected)); + + hasSelection = hasSelection || isSelected; + } + + int currentValue = SignalStore.settings().getThreadTrimLength(); + settings.add(new CustomizableSingleSelectSetting.Item(CUSTOM_LENGTH, + activity.getString(R.string.preferences_storage__custom), + !hasSelection, + currentValue, + activity.getString(R.string.preferences_storage__s_messages, NumberFormat.getInstance().format(currentValue)))); + return settings; + } + + @SuppressLint("InflateParams") + @Override + public void onCustomizeClicked(@Nullable CustomizableSingleSelectSetting.Item item) { + boolean trimLengthEnabled = SignalStore.settings().isTrimByLengthEnabled(); + int trimLength = trimLengthEnabled ? SignalStore.settings().getThreadTrimLength() : 0; + + View view = LayoutInflater.from(activity).inflate(R.layout.customizable_setting_edit_text, null, false); + EditText editText = view.findViewById(R.id.customizable_setting_edit_text); + if (trimLength > 0) { + editText.setText(String.valueOf(trimLength)); + } + + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle(R.string.preferences__conversation_length_limit) + .setView(view) + .setPositiveButton(android.R.string.ok, (d, w) -> onSelectionChanged(Integer.parseInt(editText.getText().toString()))) + .setNegativeButton(android.R.string.cancel, (d, w) -> updateSettingsList()) + .create(); + + dialog.setOnShowListener(d -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(!TextUtils.isEmpty(editText.getText())); + editText.requestFocus(); + editText.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(@NonNull Editable sequence) { + CharSequence trimmed = StringUtil.trimSequence(sequence); + if (TextUtils.isEmpty(trimmed)) { + sequence.replace(0, sequence.length(), ""); + } else { + try { + Integer.parseInt(trimmed.toString()); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); + return; + } catch (NumberFormatException e) { + String onlyDigits = trimmed.toString().replaceAll("[^\\d]", ""); + if (!onlyDigits.equals(trimmed.toString())) { + sequence.replace(0, sequence.length(), onlyDigits); + } + } + } + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); + } + + @Override + public void beforeTextChanged(@NonNull CharSequence sequence, int start, int count, int after) {} + + @Override + public void onTextChanged(@NonNull CharSequence sequence, int start, int before, int count) {} + }); + }); + + dialog.show(); + } + + @Override + public void onSelectionChanged(@NonNull Object selection) { + boolean trimLengthEnabled = SignalStore.settings().isTrimByLengthEnabled(); + int trimLength = trimLengthEnabled ? SignalStore.settings().getThreadTrimLength() : 0; + int newTrimLength = (Integer) selection; + + if (newTrimLength > 0 && (!trimLengthEnabled || newTrimLength < trimLength)) { + new AlertDialog.Builder(activity) + .setTitle(R.string.preferences_storage__delete_older_messages) + .setMessage(activity.getString(R.string.preferences_storage__this_will_permanently_trim_all_conversations_to_the_d_most_recent_messages, NumberFormat.getInstance().format(newTrimLength))) + .setPositiveButton(R.string.delete, (d, w) -> updateTrimByLength(newTrimLength)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else if (newTrimLength == CUSTOM_LENGTH) { + onCustomizeClicked(null); + } else { + updateTrimByLength(newTrimLength); + } + } + + private void updateTrimByLength(int length) { + boolean restrictingChange = !SignalStore.settings().isTrimByLengthEnabled() || length < SignalStore.settings().getThreadTrimLength(); + + SignalStore.settings().setThreadTrimByLengthEnabled(length > 0); + SignalStore.settings().setThreadTrimLength(length); + updateSettingsList(); + + if (SignalStore.settings().isTrimByLengthEnabled() && restrictingChange) { + KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); + + long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration() + : ThreadDatabase.NO_TRIM_BEFORE_DATE_SET; + + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()).trimAllThreads(length, trimBeforeDate)); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java new file mode 100644 index 00000000..87e28cf8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java @@ -0,0 +1,252 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.ImageView; + +import androidx.core.content.ContextCompat; +import androidx.core.content.res.TypedArrayUtils; +import androidx.preference.DialogPreference; +import androidx.preference.PreferenceViewHolder; + +import com.takisoft.colorpicker.ColorPickerDialog; +import com.takisoft.colorpicker.ColorPickerDialog.Size; +import com.takisoft.colorpicker.ColorStateDrawable; + +import org.thoughtcrime.securesms.R; + +public class ColorPickerPreference extends DialogPreference { + + private static final String TAG = ColorPickerPreference.class.getSimpleName(); + + private int[] colors; + private CharSequence[] colorDescriptions; + private int color; + private int columns; + private int size; + private boolean sortColors; + + private ImageView colorWidget; + private OnPreferenceChangeListener listener; + + public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0); + + int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors); + + if (colorsId != 0) { + colors = context.getResources().getIntArray(colorsId); + } + + colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions); + color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0); + columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3); + size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2); + sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false); + + a.recycle(); + + setWidgetLayoutResource(R.layout.preference_widget_color_swatch); + } + + public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressLint("RestrictedApi") + public ColorPickerPreference(Context context, AttributeSet attrs) { + this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle, + android.R.attr.dialogPreferenceStyle)); + } + + public ColorPickerPreference(Context context) { + this(context, null); + } + + @Override + public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) { + super.setOnPreferenceChangeListener(listener); + this.listener = listener; + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget); + setColorOnWidget(color); + } + + private void setColorOnWidget(int color) { + if (colorWidget == null) { + return; + } + + Drawable[] colorDrawable = new Drawable[] + {ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)}; + colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); + } + + /** + * Returns the current color. + * + * @return The current color. + */ + public int getColor() { + return color; + } + + /** + * Sets the current color. + * + * @param color The current color. + */ + public void setColor(int color) { + setInternalColor(color, false); + } + + /** + * Returns all of the available colors. + * + * @return The available colors. + */ + public int[] getColors() { + return colors; + } + + /** + * Sets the available colors. + * + * @param colors The available colors. + */ + public void setColors(int[] colors) { + this.colors = colors; + } + + /** + * Returns whether the available colors should be sorted automatically based on their HSV + * values. + * + * @return Whether the available colors should be sorted automatically based on their HSV + * values. + */ + public boolean isSortColors() { + return sortColors; + } + + /** + * Sets whether the available colors should be sorted automatically based on their HSV + * values. The sorting does not modify the order of the original colors supplied via + * {@link #setColors(int[])} or the XML attribute {@code app:colors}. + * + * @param sortColors Whether the available colors should be sorted automatically based on their + * HSV values. + */ + public void setSortColors(boolean sortColors) { + this.sortColors = sortColors; + } + + /** + * Returns the available colors' descriptions that can be used by accessibility services. + * + * @return The available colors' descriptions. + */ + public CharSequence[] getColorDescriptions() { + return colorDescriptions; + } + + /** + * Sets the available colors' descriptions that can be used by accessibility services. + * + * @param colorDescriptions The available colors' descriptions. + */ + public void setColorDescriptions(CharSequence[] colorDescriptions) { + this.colorDescriptions = colorDescriptions; + } + + /** + * Returns the number of columns to be used in the picker dialog for displaying the available + * colors. If the value is less than or equals to 0, the number of columns will be determined + * automatically by the system using FlexboxLayoutManager. + * + * @return The number of columns to be used in the picker dialog. + * @see com.google.android.flexbox.FlexboxLayoutManager + */ + public int getColumns() { + return columns; + } + + /** + * Sets the number of columns to be used in the picker dialog for displaying the available + * colors. If the value is less than or equals to 0, the number of columns will be determined + * automatically by the system using FlexboxLayoutManager. + * + * @param columns The number of columns to be used in the picker dialog. Use 0 to set it to + * 'auto' mode. + * @see com.google.android.flexbox.FlexboxLayoutManager + */ + public void setColumns(int columns) { + this.columns = columns; + } + + /** + * Returns the size of the color swatches in the dialog. It can be either + * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. + * + * @return The size of the color swatches in the dialog. + * @see ColorPickerDialog#SIZE_SMALL + * @see ColorPickerDialog#SIZE_LARGE + */ + @Size + public int getSize() { + return size; + } + + /** + * Sets the size of the color swatches in the dialog. It can be either + * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. + * + * @param size The size of the color swatches in the dialog. It can be either + * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. + * @see ColorPickerDialog#SIZE_SMALL + * @see ColorPickerDialog#SIZE_LARGE + */ + public void setSize(@Size int size) { + this.size = size; + } + + private void setInternalColor(int color, boolean force) { + int oldColor = getPersistedInt(0); + + boolean changed = oldColor != color; + + if (changed || force) { + this.color = color; + + persistInt(color); + + setColorOnWidget(color); + + if (listener != null) listener.onPreferenceChange(this, color); + notifyChanged(); + } + } + + @Override + protected Object onGetDefaultValue(TypedArray a, int index) { + return a.getString(index); + } + + @Override + protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) { + final String defaultValue = (String) defaultValueObj; + setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java new file mode 100644 index 00000000..0066e1e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceDialogFragmentCompat; + +import com.takisoft.colorpicker.ColorPickerDialog; +import com.takisoft.colorpicker.OnColorSelectedListener; + +public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener { + + private int pickedColor; + + public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) { + ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat(); + Bundle b = new Bundle(1); + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ColorPickerPreference pref = getColorPickerPreference(); + + ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext()) + .setSelectedColor(pref.getColor()) + .setColors(pref.getColors()) + .setColorContentDescriptions(pref.getColorDescriptions()) + .setSize(pref.getSize()) + .setSortColors(pref.isSortColors()) + .setColumns(pref.getColumns()) + .build(); + + ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params); + dialog.setTitle(pref.getDialogTitle()); + + return dialog; + } + + @Override + public void onDialogClosed(boolean positiveResult) { + ColorPickerPreference preference = getColorPickerPreference(); + + if (positiveResult) { + preference.setColor(pickedColor); + } + } + + @Override + public void onColorSelected(int color) { + this.pickedColor = color; + + super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); + } + + ColorPickerPreference getColorPickerPreference() { + return (ColorPickerPreference) getPreference(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java new file mode 100644 index 00000000..8369a177 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.graphics.PorterDuff; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; + +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class ContactPreference extends Preference { + + private ImageView messageButton; + private ImageView callButton; + private ImageView secureCallButton; + private ImageView secureVideoButton; + private View itemView; + + private Listener listener; + private boolean secure; + private boolean blocked; + + public ContactPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public ContactPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public ContactPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ContactPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setWidgetLayoutResource(R.layout.recipient_preference_contact_widget); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + + this.itemView = view.itemView; + this.messageButton = (ImageView) view.findViewById(R.id.message); + this.callButton = (ImageView) view.findViewById(R.id.call); + this.secureCallButton = (ImageView) view.findViewById(R.id.secure_call); + this.secureVideoButton = (ImageView) view.findViewById(R.id.secure_video); + + if (listener != null) setListener(listener); + setState(secure, blocked); + } + + public void setState(boolean secure, boolean blocked) { + this.secure = secure; + + if (secureCallButton != null) secureCallButton.setVisibility(secure && !blocked ? View.VISIBLE : View.GONE); + if (secureVideoButton != null) secureVideoButton.setVisibility(secure && !blocked ? View.VISIBLE : View.GONE); + if (callButton != null) callButton.setVisibility(secure || blocked ? View.GONE : View.VISIBLE); + if (messageButton != null) messageButton.setVisibility(blocked ? View.GONE : View.VISIBLE); + + int color; + + if (secure) { + color = getContext().getResources().getColor(R.color.core_ultramarine); + } else { + color = getContext().getResources().getColor(R.color.grey_600); + } + + if (messageButton != null) messageButton.setColorFilter(color, PorterDuff.Mode.SRC_IN); + if (secureCallButton != null) secureCallButton.setColorFilter(color, PorterDuff.Mode.SRC_IN); + if (secureVideoButton != null) secureVideoButton.setColorFilter(color, PorterDuff.Mode.SRC_IN); + if (callButton != null) callButton.setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + + public void setListener(Listener listener) { + this.listener = listener; + + if (this.messageButton != null) this.messageButton.setOnClickListener(v -> listener.onMessageClicked()); + if (this.secureCallButton != null) this.secureCallButton.setOnClickListener(v -> listener.onSecureCallClicked()); + if (this.secureVideoButton != null) this.secureVideoButton.setOnClickListener(v -> listener.onSecureVideoClicked()); + if (this.callButton != null) this.callButton.setOnClickListener(v -> listener.onInSecureCallClicked()); + + if (this.itemView != null) { + itemView.setOnLongClickListener(v -> { + listener.onLongClick(); + return true; + }); + } + } + + public interface Listener { + void onMessageClicked(); + void onSecureCallClicked(); + void onSecureVideoClicked(); + void onInSecureCallClicked(); + void onLongClick(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/LEDColorListPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/LEDColorListPreference.java new file mode 100644 index 00000000..4d229318 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/LEDColorListPreference.java @@ -0,0 +1,108 @@ +/** + * Copyright (C) 2017 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.content.Context; +import android.graphics.drawable.GradientDrawable; +import android.util.AttributeSet; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +/** + * List preference that disables dependents when set to "none", similar to a CheckBoxPreference. + * + * @author Taylor Kline + */ + +public class LEDColorListPreference extends ListPreference { + + private static final String TAG = LEDColorListPreference.class.getSimpleName(); + + private ImageView colorImageView; + + public LEDColorListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setWidgetLayoutResource(R.layout.led_color_preference_widget); + } + + public LEDColorListPreference(Context context) { + super(context); + setWidgetLayoutResource(R.layout.led_color_preference_widget); + } + + @Override + public void setValue(String value) { + CharSequence oldEntry = getEntry(); + super.setValue(value); + CharSequence newEntry = getEntry(); + if (oldEntry != newEntry) { + notifyDependencyChange(shouldDisableDependents()); + } + + if (value != null) setPreviewColor(value); + } + + @Override + public boolean shouldDisableDependents() { + CharSequence newEntry = getValue(); + boolean shouldDisable = newEntry.equals("none"); + return shouldDisable || super.shouldDisableDependents(); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + this.colorImageView = (ImageView)view.findViewById(R.id.color_view); + setPreviewColor(getValue()); + } + + @Override + public void setSummary(CharSequence summary) { + super.setSummary(null); + } + + private void setPreviewColor(@NonNull String value) { + int color; + + switch (value) { + case "green": color = getContext().getResources().getColor(R.color.green_500); break; + case "red": color = getContext().getResources().getColor(R.color.red_500); break; + case "blue": color = getContext().getResources().getColor(R.color.blue_500); break; + case "yellow": color = getContext().getResources().getColor(R.color.yellow_500); break; + case "cyan": color = getContext().getResources().getColor(R.color.cyan_500); break; + case "magenta": color = getContext().getResources().getColor(R.color.pink_500); break; + case "white": color = getContext().getResources().getColor(R.color.white); break; + default: color = getContext().getResources().getColor(R.color.transparent); break; + } + + if (colorImageView != null) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.OVAL); + drawable.setColor(color); + + colorImageView.setImageDrawable(drawable); + } + } + + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java new file mode 100644 index 00000000..c5ccbedd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import androidx.annotation.NonNull; + +public class NotificationPrivacyPreference { + + private final String preference; + + public NotificationPrivacyPreference(String preference) { + this.preference = preference; + } + + public boolean isDisplayContact() { + return "all".equals(preference) || "contact".equals(preference); + } + + public boolean isDisplayMessage() { + return "all".equals(preference); + } + + @Override + public @NonNull String toString() { + return preference; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java new file mode 100644 index 00000000..784ef38c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.RequiresApi; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class ProfilePreference extends Preference { + + private ImageView avatarView; + private TextView profileNameView; + private TextView profileSubtextView; + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public ProfilePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public ProfilePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public ProfilePreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ProfilePreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setLayoutResource(R.layout.profile_preference_view); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder viewHolder) { + super.onBindViewHolder(viewHolder); + avatarView = (ImageView)viewHolder.findViewById(R.id.avatar); + profileNameView = (TextView)viewHolder.findViewById(R.id.profile_name); + profileSubtextView = (TextView)viewHolder.findViewById(R.id.number); + + refresh(); + } + + public void refresh() { + if (profileSubtextView == null) return; + + final Recipient self = Recipient.self(); + final String profileName = Recipient.self().getProfileName().toString(); + + GlideApp.with(getContext().getApplicationContext()) + .load(new ProfileContactPhoto(self, self.getProfileAvatar())) + .error(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(getContext(), getContext().getResources().getColor(R.color.grey_400))) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(avatarView); + + if (!TextUtils.isEmpty(profileName)) { + profileNameView.setText(profileName); + } + + profileSubtextView.setText(self.getUsername().transform(username -> "@" + username).or(self.getE164().transform(PhoneNumberFormatter::prettyPrint)).orNull()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java new file mode 100644 index 00000000..25591f61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class ProgressPreference extends Preference { + + private View container; + private TextView progressText; + + public ProgressPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public ProgressPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public ProgressPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ProgressPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setWidgetLayoutResource(R.layout.preference_widget_progress); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + + this.container = view.findViewById(R.id.container); + this.progressText = (TextView) view.findViewById(R.id.progress_text); + + this.container.setVisibility(View.GONE); + } + + public void setProgress(int count) { + container.setVisibility(View.VISIBLE); + progressText.setText(getContext().getString(R.string.ProgressPreference_d_messages_so_far, count)); + } + + public void setProgressVisible(boolean visible) { + container.setVisibility(visible ? View.VISIBLE : View.GONE); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java new file mode 100644 index 00000000..10a82943 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.annotation.RequiresApi; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class SignalListPreference extends ListPreference { + + private TextView rightSummary; + private CharSequence summary; + private OnPreferenceClickListener clickListener; + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public SignalListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public SignalListPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setWidgetLayoutResource(R.layout.preference_right_summary_widget); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + + this.rightSummary = (TextView)view.findViewById(R.id.right_summary); + setSummary(this.summary); + } + + @Override + public void setSummary(CharSequence summary) { + super.setSummary(null); + + this.summary = summary; + + if (this.rightSummary != null) { + this.rightSummary.setText(summary); + } + } + + @Override + public void setOnPreferenceClickListener(OnPreferenceClickListener onPreferenceClickListener) { + this.clickListener = onPreferenceClickListener; + } + + @Override + protected void onClick() { + if (clickListener == null || !clickListener.onPreferenceClick(this)) { + super.onClick(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java new file mode 100644 index 00000000..9be8af15 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.preferences.widgets; + + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class SignalPreference extends Preference { + + private TextView rightSummary; + private CharSequence summary; + + public SignalPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public SignalPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public SignalPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public SignalPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setWidgetLayoutResource(R.layout.preference_right_summary_widget); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + + this.rightSummary = (TextView)view.findViewById(R.id.right_summary); + setSummary(this.summary); + } + + @Override + public void setSummary(CharSequence summary) { + super.setSummary(null); + + this.summary = summary; + + if (this.rightSummary != null) { + this.rightSummary.setText(summary); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StorageGraphView.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StorageGraphView.java new file mode 100644 index 00000000..43bfd23a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StorageGraphView.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class StorageGraphView extends View { + + private final RectF rect = new RectF(); + private final Path path = new Path(); + private final Paint paint = new Paint(); + @NonNull private StorageBreakdown storageBreakdown; + private StorageBreakdown emptyBreakdown; + + public StorageGraphView(Context context) { + super(context); + initialize(); + } + + public StorageGraphView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public StorageGraphView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + setWillNotDraw(false); + paint.setStyle(Paint.Style.FILL); + + Entry emptyEntry = new Entry(ContextCompat.getColor(getContext(), R.color.storage_color_empty), 1); + + emptyBreakdown = new StorageBreakdown(Collections.singletonList(emptyEntry)); + + setStorageBreakdown(emptyBreakdown); + } + + public void setStorageBreakdown(@NonNull StorageBreakdown storageBreakdown) { + if (storageBreakdown.totalSize == 0) { + storageBreakdown = emptyBreakdown; + } + this.storageBreakdown = storageBreakdown; + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + int radius = getHeight() / 2; + rect.set(0, 0, w, h); + path.reset(); + path.addRoundRect(rect, radius, radius, Path.Direction.CW); + } + + @Override + protected void onDraw(Canvas canvas) { + if (storageBreakdown.totalSize == 0) return; + + canvas.clipPath(path); + + int startX = 0; + int entryCount = storageBreakdown.entries.size(); + int width = getWidth(); + + for (int i = 0; i < entryCount; i++) { + Entry entry = storageBreakdown.entries.get(i); + int endX = i < entryCount - 1 ? startX + (int) (width * entry.size / storageBreakdown.totalSize) + : width; + rect.left = startX; + rect.right = endX; + paint.setColor(entry.color); + canvas.drawRect(rect, paint); + startX = endX; + } + } + + public static class StorageBreakdown { + + private final List entries; + private final long totalSize; + + public StorageBreakdown(@NonNull List entries) { + this.entries = new ArrayList<>(entries); + + long total = 0; + for (Entry entry : entries) { + total += entry.size; + } + this.totalSize = total; + } + + public long getTotalSize() { + return totalSize; + } + } + + public static class Entry { + + @ColorInt private final int color; + private final long size; + + public Entry(@ColorInt int color, long size) { + this.color = color; + this.size = size; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java new file mode 100644 index 00000000..18272fc2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +public final class StoragePreferenceCategory extends PreferenceCategory { + + private Runnable onFreeUpSpace; + private TextView totalSize; + private StorageGraphView storageGraphView; + private StorageGraphView.StorageBreakdown storage; + + public StoragePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public StoragePreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public StoragePreferenceCategory(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setLayoutResource(R.layout.preference_storage_category); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + + totalSize = (TextView) view.findViewById(R.id.total_size); + storageGraphView = (StorageGraphView) view.findViewById(R.id.storageGraphView); + + view.findViewById(R.id.free_up_space) + .setOnClickListener(v -> { + if (onFreeUpSpace != null) { + onFreeUpSpace.run(); + } + }); + + totalSize.setText(Util.getPrettyFileSize(0)); + + if (storage != null) { + setStorage(storage); + } + } + + public void setOnFreeUpSpace(Runnable onFreeUpSpace) { + this.onFreeUpSpace = onFreeUpSpace; + } + + public void setStorage(StorageGraphView.StorageBreakdown storage) { + this.storage = storage; + if (totalSize != null) { + totalSize.setText(Util.getPrettyFileSize(storage.getTotalSize())); + } + if (storageGraphView != null) { + storageGraphView.setStorageBreakdown(storage); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UsernamePreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UsernamePreference.java new file mode 100644 index 00000000..5b70d93c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UsernamePreference.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class UsernamePreference extends Preference { + + private View.OnLongClickListener onLongClickListener; + + public UsernamePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public UsernamePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public UsernamePreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public UsernamePreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setLayoutResource(R.layout.preference_username); + } + + public void setOnLongClickListener(View.OnLongClickListener onLongClickListener) { + this.onLongClickListener = onLongClickListener; + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + holder.itemView.setOnLongClickListener(onLongClickListener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java new file mode 100644 index 00000000..104907c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -0,0 +1,202 @@ +package org.thoughtcrime.securesms.profiles; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ByteUnit; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; +import java.util.Iterator; + +public class AvatarHelper { + + private static final String TAG = Log.tag(AvatarHelper.class); + + public static int AVATAR_DIMENSIONS = 1024; + public static long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = ByteUnit.MEGABYTES.toBytes(10); + + private static final String AVATAR_DIRECTORY = "avatars"; + + /** + * Retrieves an iterable set of avatars. Only intended to be used during backup. + */ + public static Iterable getAvatars(@NonNull Context context) { + File avatarDirectory = context.getDir(AVATAR_DIRECTORY, Context.MODE_PRIVATE); + File[] results = avatarDirectory.listFiles(); + + if (results == null) { + return Collections.emptyList(); + } + + return () -> { + return new Iterator() { + int i = 0; + @Override + public boolean hasNext() { + return i < results.length; + } + + @Override + public Avatar next() { + File file = results[i]; + try { + return new Avatar(getAvatar(context, RecipientId.from(file.getName())), + file.getName(), + ModernEncryptingPartOutputStream.getPlaintextLength(file.length())); + } catch (IOException e) { + return null; + } finally { + i++; + } + } + }; + }; + } + + /** + * Deletes and avatar. + */ + public static void delete(@NonNull Context context, @NonNull RecipientId recipientId) { + getAvatarFile(context, recipientId).delete(); + } + + /** + * Whether or not an avatar is present for the given recipient. + */ + public static boolean hasAvatar(@NonNull Context context, @NonNull RecipientId recipientId) { + File avatarFile = getAvatarFile(context, recipientId); + return avatarFile.exists() && avatarFile.length() > 0; + } + + /** + * Retrieves a stream for an avatar. If there is no avatar, the stream will likely throw an + * IOException. It is recommended to call {@link #hasAvatar(Context, RecipientId)} first. + */ + public static @NonNull InputStream getAvatar(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + File avatarFile = getAvatarFile(context, recipientId); + + return ModernDecryptingPartInputStream.createFor(attachmentSecret, avatarFile, 0); + } + + public static byte[] getAvatarBytes(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException { + return hasAvatar(context, recipientId) ? StreamUtil.readFully(getAvatar(context, recipientId)) + : null; + } + + /** + * Returns the size of the avatar on disk. + */ + public static long getAvatarLength(@NonNull Context context, @NonNull RecipientId recipientId) { + return ModernEncryptingPartOutputStream.getPlaintextLength(getAvatarFile(context, recipientId).length()); + } + + /** + * Saves the contents of the input stream as the avatar for the specified recipient. If you pass + * in null for the stream, the avatar will be deleted. + */ + public static void setAvatar(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable InputStream inputStream) + throws IOException + { + if (inputStream == null) { + delete(context, recipientId); + return; + } + + OutputStream outputStream = null; + try { + outputStream = getOutputStream(context, recipientId); + StreamUtil.copy(inputStream, outputStream); + } finally { + StreamUtil.close(outputStream); + } + } + + /** + * Retrieves an output stream you can write to that will be saved as the avatar for the specified + * recipient. Only intended to be used for backup. Otherwise, use {@link #setAvatar(Context, RecipientId, InputStream)}. + */ + public static @NonNull OutputStream getOutputStream(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + File targetFile = getAvatarFile(context, recipientId); + return ModernEncryptingPartOutputStream.createFor(attachmentSecret, targetFile, true).second; + } + + /** + * Returns the timestamp of when the avatar was last modified, or zero if the avatar doesn't exist. + */ + public static long getLastModified(@NonNull Context context, @NonNull RecipientId recipientId) { + File file = getAvatarFile(context, recipientId); + + if (file.exists()) { + return file.lastModified(); + } else { + return 0; + } + } + + /** + * Returns a {@link StreamDetails} for the local user's own avatar, or null if one does not exist. + */ + public static @Nullable StreamDetails getSelfProfileAvatarStream(@NonNull Context context) { + RecipientId selfId = Recipient.self().getId(); + + if (!hasAvatar(context, selfId)) { + return null; + } + + try { + InputStream stream = getAvatar(context, selfId); + return new StreamDetails(stream, MediaUtil.IMAGE_JPEG, getAvatarLength(context, selfId)); + } catch (IOException e) { + Log.w(TAG, "Failed to read own avatar!", e); + return null; + } + } + + private static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) { + File directory = context.getDir(AVATAR_DIRECTORY, Context.MODE_PRIVATE); + return new File(directory, recipientId.serialize()); + } + + public static class Avatar { + private final InputStream inputStream; + private final String filename; + private final long length; + + public Avatar(@NonNull InputStream inputStream, @NonNull String filename, long length) { + this.inputStream = inputStream; + this.filename = filename; + this.length = length; + } + + public @NonNull InputStream getInputStream() { + return inputStream; + } + + public @NonNull String getFilename() { + return filename; + } + + public long getLength() { + return length; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java new file mode 100644 index 00000000..c0a0fd9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.profiles; + + +import android.content.Context; + +import org.thoughtcrime.securesms.mms.MediaConstraints; + +public class ProfileMediaConstraints extends MediaConstraints { + @Override + public int getImageMaxWidth(Context context) { + return 640; + } + + @Override + public int getImageMaxHeight(Context context) { + return 640; + } + + @Override + public int getImageMaxSize(Context context) { + return 5 * 1024 * 1024; + } + + @Override + public int[] getImageDimensionTargets(Context context) { + return new int[] { getImageMaxWidth(context) }; + } + + @Override + public int getGifMaxSize(Context context) { + return 0; + } + + @Override + public int getVideoMaxSize(Context context) { + return 0; + } + + @Override + public int getAudioMaxSize(Context context) { + return 0; + } + + @Override + public int getDocumentMaxSize(Context context) { + return 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java new file mode 100644 index 00000000..daca12f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java @@ -0,0 +1,167 @@ +package org.thoughtcrime.securesms.profiles; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.cjkv.CJKVUtil; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; + +import java.util.Objects; + +public final class ProfileName implements Parcelable { + + public static final ProfileName EMPTY = new ProfileName("", ""); + public static final int MAX_PART_LENGTH = (ProfileCipher.MAX_POSSIBLE_NAME_LENGTH - 1) / 2; + + private final String givenName; + private final String familyName; + private final String joinedName; + + private ProfileName(@Nullable String givenName, @Nullable String familyName) { + this.givenName = givenName == null ? "" : givenName; + this.familyName = familyName == null ? "" : familyName; + this.joinedName = getJoinedName(this.givenName, this.familyName); + } + + private ProfileName(Parcel in) { + this(in.readString(), in.readString()); + } + + public @NonNull String getGivenName() { + return givenName; + } + + public @NonNull String getFamilyName() { + return familyName; + } + + @VisibleForTesting + boolean isProfileNameCJKV() { + return isCJKV(givenName, familyName); + } + + public boolean isEmpty() { + return joinedName.isEmpty(); + } + + public boolean isGivenNameEmpty() { + return givenName.isEmpty(); + } + + public @NonNull String serialize() { + if (isGivenNameEmpty()) { + return ""; + } else if (familyName.isEmpty()) { + return givenName; + } else { + return String.format("%s\0%s", givenName, familyName); + } + } + + @Override + public @NonNull String toString() { + return joinedName; + } + + /** + * Deserializes a profile name, trims if exceeds the limits. + */ + public static @NonNull ProfileName fromSerialized(@Nullable String profileName) { + if (profileName == null || profileName.isEmpty()) { + return EMPTY; + } + + String[] parts = profileName.split("\0"); + + if (parts.length == 0) { + return EMPTY; + } else if (parts.length == 1) { + return fromParts(parts[0], ""); + } else { + return fromParts(parts[0], parts[1]); + } + } + + /** + * Creates a profile name, trimming chars until it fits the limits. + */ + public static @NonNull ProfileName fromParts(@Nullable String givenName, @Nullable String familyName) { + givenName = givenName == null ? "" : givenName; + familyName = familyName == null ? "" : familyName; + + givenName = StringUtil.trimToFit(givenName.trim(), ProfileName.MAX_PART_LENGTH); + familyName = StringUtil.trimToFit(familyName.trim(), ProfileName.MAX_PART_LENGTH); + + if (givenName.isEmpty() && familyName.isEmpty()) { + return EMPTY; + } + + return new ProfileName(givenName, familyName); + } + + private static @NonNull String getJoinedName(@NonNull String givenName, @NonNull String familyName) { + if (givenName.isEmpty() && familyName.isEmpty()) return ""; + else if (givenName.isEmpty()) return familyName; + else if (familyName.isEmpty()) return givenName; + else if (isCJKV(givenName, familyName)) return String.format("%s %s", + familyName, + givenName); + else return String.format("%s %s", + givenName, + familyName); + } + + private static boolean isCJKV(@NonNull String givenName, @NonNull String familyName) { + if (givenName.isEmpty() && familyName.isEmpty()) { + return false; + } else { + return Stream.of(givenName, familyName) + .filterNot(String::isEmpty) + .reduce(true, (a, s) -> a && CJKVUtil.isCJKV(s)); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(givenName); + dest.writeString(familyName); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProfileName that = (ProfileName) o; + return Objects.equals(givenName, that.givenName) && + Objects.equals(familyName, that.familyName); + } + + @Override + public int hashCode() { + return Objects.hash(givenName, familyName); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ProfileName createFromParcel(Parcel in) { + return new ProfileName(in); + } + + @Override + public ProfileName[] newArray(int size) { + return new ProfileName[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/SystemProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/SystemProfileUtil.java new file mode 100644 index 00000000..93376383 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/SystemProfileUtil.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.profiles; + + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; + +public class SystemProfileUtil { + + private static final String TAG = SystemProfileUtil.class.getSimpleName(); + + @SuppressLint("StaticFieldLeak") + public static ListenableFuture getSystemProfileAvatar(final @NonNull Context context, MediaConstraints mediaConstraints) { + SettableFuture future = new SettableFuture<>(); + + new AsyncTask() { + @Override + protected @Nullable byte[] doInBackground(Void... params) { + try (Cursor cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_URI, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String photoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Profile.PHOTO_URI)); + + if (!TextUtils.isEmpty(photoUri)) { + try { + BitmapUtil.ScaleResult result = BitmapUtil.createScaledBytes(context, Uri.parse(photoUri), mediaConstraints); + return result.getBitmap(); + } catch (BitmapDecodingException e) { + Log.w(TAG, e); + } + } + } + } catch (SecurityException se) { + Log.w(TAG, se); + } + + return null; + } + + @Override + protected void onPostExecute(@Nullable byte[] result) { + future.set(result); + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return future; + } + + @SuppressLint("StaticFieldLeak") + public static ListenableFuture getSystemProfileName(final @NonNull Context context) { + SettableFuture future = new SettableFuture<>(); + + new AsyncTask() { + @Override + protected String doInBackground(Void... params) { + String name = null; + + try (Cursor cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_URI, null, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + name = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Profile.DISPLAY_NAME)); + } + } catch (SecurityException se) { + Log.w(TAG, se); + } + + if (name == null) { + AccountManager accountManager = AccountManager.get(context); + Account[] accounts = accountManager.getAccountsByType("com.google"); + + for (Account account : accounts) { + if (!TextUtils.isEmpty(account.name)) { + if (account.name.contains("@")) { + name = account.name.substring(0, account.name.indexOf("@")).replace('.', ' '); + } else { + name = account.name.replace('.', ' '); + } + + break; + } + } + } + + return name; + } + + @Override + protected void onPostExecute(@Nullable String result) { + future.set(result); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + return future; + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java new file mode 100644 index 00000000..39a81600 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.profiles.edit; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; + +class EditGroupProfileRepository implements EditProfileRepository { + + private static final String TAG = Log.tag(EditGroupProfileRepository.class); + + private final Context context; + private final GroupId groupId; + + EditGroupProfileRepository(@NonNull Context context, @NonNull GroupId groupId) { + this.context = context.getApplicationContext(); + this.groupId = groupId; + } + + @Override + public void getCurrentProfileName(@NonNull Consumer profileNameConsumer) { + profileNameConsumer.accept(ProfileName.EMPTY); + } + + @Override + public void getCurrentAvatar(@NonNull Consumer avatarConsumer) { + SimpleTask.run(() -> { + final RecipientId recipientId = getRecipientId(); + + if (AvatarHelper.hasAvatar(context, recipientId)) { + try { + return StreamUtil.readFully(AvatarHelper.getAvatar(context, recipientId)); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } else { + return null; + } + }, avatarConsumer::accept); + } + + @Override + public void getCurrentDisplayName(@NonNull Consumer displayNameConsumer) { + SimpleTask.run(() -> Recipient.resolved(getRecipientId()).getDisplayName(context), displayNameConsumer::accept); + } + + @Override + public void getCurrentName(@NonNull Consumer nameConsumer) { + SimpleTask.run(() -> { + RecipientId recipientId = getRecipientId(); + Recipient recipient = Recipient.resolved(recipientId); + + return DatabaseFactory.getGroupDatabase(context) + .getGroup(recipientId) + .transform(groupRecord -> { + String title = groupRecord.getTitle(); + return title == null ? "" : title; + }) + .or(() -> recipient.getName(context)); + }, nameConsumer::accept); + } + + @Override + public void uploadProfile(@NonNull ProfileName profileName, + @NonNull String displayName, + boolean displayNameChanged, + @Nullable byte[] avatar, + boolean avatarChanged, + @NonNull Consumer uploadResultConsumer) + { + SimpleTask.run(() -> { + try { + GroupManager.updateGroupDetails(context, groupId, avatar, avatarChanged, displayName, displayNameChanged); + + return UploadResult.SUCCESS; + } catch (GroupChangeException | IOException e) { + return UploadResult.ERROR_IO; + } + + }, uploadResultConsumer::accept); + } + + @Override + public void getCurrentUsername(@NonNull Consumer> callback) { + callback.accept(Optional.absent()); + } + + @WorkerThread + private RecipientId getRecipientId() { + return DatabaseFactory.getRecipientDatabase(context).getByGroupId(groupId) + .or(() -> { + throw new AssertionError("Recipient ID for Group ID does not exist."); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java new file mode 100644 index 00000000..2768df2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.profiles.edit; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.navigation.NavDirections; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.DynamicRegistrationTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +/** + * Shows editing screen for your profile during registration. Also handles group name editing. + */ +@SuppressLint("StaticFieldLeak") +public class EditProfileActivity extends BaseActivity implements EditProfileFragment.Controller { + + public static final String NEXT_INTENT = "next_intent"; + public static final String EXCLUDE_SYSTEM = "exclude_system"; + public static final String NEXT_BUTTON_TEXT = "next_button_text"; + public static final String SHOW_TOOLBAR = "show_back_arrow"; + public static final String GROUP_ID = "group_id"; + + private final DynamicTheme dynamicTheme = new DynamicRegistrationTheme(); + + public static @NonNull Intent getIntentForUserProfile(@NonNull Context context) { + Intent intent = new Intent(context, EditProfileActivity.class); + intent.putExtra(EditProfileActivity.SHOW_TOOLBAR, false); + return intent; + } + + public static @NonNull Intent getIntentForUserProfileEdit(@NonNull Context context) { + Intent intent = new Intent(context, EditProfileActivity.class); + intent.putExtra(EditProfileActivity.EXCLUDE_SYSTEM, true); + intent.putExtra(EditProfileActivity.NEXT_BUTTON_TEXT, R.string.save); + return intent; + } + + public static @NonNull Intent getIntentForGroupProfile(@NonNull Context context, @NonNull GroupId groupId) { + Intent intent = new Intent(context, EditProfileActivity.class); + intent.putExtra(EditProfileActivity.SHOW_TOOLBAR, true); + intent.putExtra(EditProfileActivity.GROUP_ID, groupId.toString()); + intent.putExtra(EditProfileActivity.NEXT_BUTTON_TEXT, R.string.save); + return intent; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + dynamicTheme.onCreate(this); + + setContentView(R.layout.profile_create_activity); + + if (bundle == null) { + Bundle extras = getIntent().getExtras(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle()); + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public void onProfileNameUploadCompleted() { + setResult(RESULT_OK); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java new file mode 100644 index 00000000..4c37b311 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -0,0 +1,330 @@ +package org.thoughtcrime.securesms.profiles.edit; + +import android.animation.Animator; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.dd.CircularProgressButton; + +import org.signal.core.util.EditTextUtil; +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.manage.EditProfileNameFragment; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.registration.RegistrationUtil; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.InputStream; + +import static android.app.Activity.RESULT_OK; +import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM; +import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.GROUP_ID; +import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT; +import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_INTENT; +import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.SHOW_TOOLBAR; + +public class EditProfileFragment extends LoggingFragment { + + private static final String TAG = Log.tag(EditProfileFragment.class); + private static final short REQUEST_CODE_SELECT_AVATAR = 31726; + + private Toolbar toolbar; + private View title; + private ImageView avatar; + private CircularProgressButton finishButton; + private EditText givenName; + private EditText familyName; + private View reveal; + private TextView preview; + + private Intent nextIntent; + + private EditProfileViewModel viewModel; + + private Controller controller; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof Controller) { + controller = (Controller) context; + } else { + throw new IllegalStateException("Context must subclass Controller"); + } + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.profile_create_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + GroupId groupId = GroupId.parseNullableOrThrow(requireArguments().getString(GROUP_ID, null)); + + initializeResources(view, groupId); + initializeViewModel(requireArguments().getBoolean(EXCLUDE_SYSTEM, false), groupId, savedInstanceState != null); + initializeProfileAvatar(); + initializeProfileName(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { + + if (data != null && data.getBooleanExtra("delete", false)) { + viewModel.setAvatar(null); + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), getResources().getColor(R.color.grey_400))); + return; + } + + SimpleTask.run(() -> { + try { + Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + InputStream stream = BlobProvider.getInstance().getStream(requireContext(), result.getUri()); + + return StreamUtil.readFully(stream); + } catch (IOException ioException) { + Log.w(TAG, ioException); + return null; + } + }, + (avatarBytes) -> { + if (avatarBytes != null) { + viewModel.setAvatar(avatarBytes); + GlideApp.with(EditProfileFragment.this) + .load(avatarBytes) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(avatar); + } else { + Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show(); + } + }); + } + } + + private void initializeViewModel(boolean excludeSystem, @Nullable GroupId groupId, boolean hasSavedInstanceState) { + EditProfileRepository repository; + + if (groupId != null) { + repository = new EditGroupProfileRepository(requireContext(), groupId); + } else { + repository = new EditSelfProfileRepository(requireContext(), excludeSystem); + } + + EditProfileViewModel.Factory factory = new EditProfileViewModel.Factory(repository, hasSavedInstanceState, groupId); + + viewModel = ViewModelProviders.of(requireActivity(), factory) + .get(EditProfileViewModel.class); + } + + private void initializeResources(@NonNull View view, @Nullable GroupId groupId) { + Bundle arguments = requireArguments(); + boolean isEditingGroup = groupId != null; + + this.toolbar = view.findViewById(R.id.toolbar); + this.title = view.findViewById(R.id.title); + this.avatar = view.findViewById(R.id.avatar); + this.givenName = view.findViewById(R.id.given_name); + this.familyName = view.findViewById(R.id.family_name); + this.finishButton = view.findViewById(R.id.finish_button); + this.reveal = view.findViewById(R.id.reveal); + this.preview = view.findViewById(R.id.name_preview); + this.nextIntent = arguments.getParcelable(NEXT_INTENT); + + this.avatar.setOnClickListener(v -> startAvatarSelection()); + + view.findViewById(R.id.mms_group_hint) + .setVisibility(isEditingGroup && groupId.isMms() ? View.VISIBLE : View.GONE); + + if (isEditingGroup) { + EditTextUtil.addGraphemeClusterLimitFilter(givenName, FeatureFlags.getMaxGroupNameGraphemeLength()); + givenName.addTextChangedListener(new AfterTextChanged(s -> viewModel.setGivenName(s.toString()))); + givenName.setHint(R.string.EditProfileFragment__group_name); + givenName.requestFocus(); + toolbar.setTitle(R.string.EditProfileFragment__edit_group_name_and_photo); + preview.setVisibility(View.GONE); + familyName.setVisibility(View.GONE); + familyName.setEnabled(false); + view.findViewById(R.id.description_text).setVisibility(View.GONE); + view.findViewById(R.id.avatar_placeholder).setImageResource(R.drawable.ic_group_outline_40); + } else { + EditTextUtil.addGraphemeClusterLimitFilter(givenName, EditProfileNameFragment.NAME_MAX_GLYPHS); + EditTextUtil.addGraphemeClusterLimitFilter(familyName, EditProfileNameFragment.NAME_MAX_GLYPHS); + this.givenName.addTextChangedListener(new AfterTextChanged(s -> { + EditProfileNameFragment.trimFieldToMaxByteLength(s); + viewModel.setGivenName(s.toString()); + })); + this.familyName.addTextChangedListener(new AfterTextChanged(s -> { + EditProfileNameFragment.trimFieldToMaxByteLength(s); + viewModel.setFamilyName(s.toString()); + })); + LearnMoreTextView descriptionText = view.findViewById(R.id.description_text); + descriptionText.setLearnMoreVisible(true); + descriptionText.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.EditProfileFragment__support_link))); + } + + this.finishButton.setOnClickListener(v -> { + this.finishButton.setIndeterminateProgressMode(true); + this.finishButton.setProgress(50); + handleUpload(); + }); + + this.finishButton.setText(arguments.getInt(NEXT_BUTTON_TEXT, R.string.CreateProfileActivity_next)); + + if (arguments.getBoolean(SHOW_TOOLBAR, true)) { + this.toolbar.setVisibility(View.VISIBLE); + this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish()); + this.title.setVisibility(View.GONE); + } + } + + private void initializeProfileName() { + viewModel.isFormValid().observe(getViewLifecycleOwner(), isValid -> { + finishButton.setEnabled(isValid); + finishButton.setAlpha(isValid ? 1f : 0.5f); + }); + + viewModel.givenName().observe(getViewLifecycleOwner(), givenName -> updateFieldIfNeeded(this.givenName, givenName)); + + viewModel.familyName().observe(getViewLifecycleOwner(), familyName -> updateFieldIfNeeded(this.familyName, familyName)); + + viewModel.profileName().observe(getViewLifecycleOwner(), profileName -> preview.setText(profileName.toString())); + } + + private void initializeProfileAvatar() { + viewModel.avatar().observe(getViewLifecycleOwner(), bytes -> { + if (bytes == null) return; + + GlideApp.with(this) + .load(bytes) + .circleCrop() + .into(avatar); + }); + } + + private static void updateFieldIfNeeded(@NonNull EditText field, @NonNull String value) { + String fieldTrimmed = field.getText().toString().trim(); + String valueTrimmed = value.trim(); + + if (!fieldTrimmed.equals(valueTrimmed)) { + boolean setSelectionToEnd = field.getText().length() == 0; + + field.setText(value); + + if (setSelectionToEnd) { + field.setSelection(field.getText().length()); + } + } + } + + private void startAvatarSelection() { + AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveProfilePhoto(), + true, + REQUEST_CODE_SELECT_AVATAR, + viewModel.isGroup()) + .show(getChildFragmentManager(), null); + } + + private void handleUpload() { + viewModel.submitProfile(uploadResult -> { + if (uploadResult == EditProfileRepository.UploadResult.SUCCESS) { + RegistrationUtil.maybeMarkRegistrationComplete(requireContext()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) handleFinishedLollipop(); + else handleFinishedLegacy(); + } else { + Toast.makeText(requireContext(), R.string.CreateProfileActivity_problem_setting_profile, Toast.LENGTH_LONG).show(); + } + }); + } + + private void handleFinishedLegacy() { + finishButton.setProgress(0); + if (nextIntent != null) startActivity(nextIntent); + + controller.onProfileNameUploadCompleted(); + } + + @RequiresApi(api = 21) + private void handleFinishedLollipop() { + int[] finishButtonLocation = new int[2]; + int[] revealLocation = new int[2]; + + finishButton.getLocationInWindow(finishButtonLocation); + reveal.getLocationInWindow(revealLocation); + + int finishX = finishButtonLocation[0] - revealLocation[0]; + int finishY = finishButtonLocation[1] - revealLocation[1]; + + finishX += finishButton.getWidth() / 2; + finishY += finishButton.getHeight() / 2; + + Animator animation = ViewAnimationUtils.createCircularReveal(reveal, finishX, finishY, 0f, (float) Math.max(reveal.getWidth(), reveal.getHeight())); + animation.setDuration(500); + animation.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) {} + + @Override + public void onAnimationEnd(Animator animation) { + finishButton.setProgress(0); + if (nextIntent != null) startActivity(nextIntent); + + controller.onProfileNameUploadCompleted(); + } + + @Override + public void onAnimationCancel(Animator animation) {} + + @Override + public void onAnimationRepeat(Animator animation) {} + }); + + reveal.setVisibility(View.VISIBLE); + animation.start(); + } + + public interface Controller { + void onProfileNameUploadCompleted(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java new file mode 100644 index 00000000..eb8743aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.profiles.edit; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.whispersystems.libsignal.util.guava.Optional; + +interface EditProfileRepository { + + void getCurrentProfileName(@NonNull Consumer profileNameConsumer); + + void getCurrentAvatar(@NonNull Consumer avatarConsumer); + + void getCurrentDisplayName(@NonNull Consumer displayNameConsumer); + + void getCurrentName(@NonNull Consumer nameConsumer); + + void uploadProfile(@NonNull ProfileName profileName, + @NonNull String displayName, + boolean displayNameChanged, + @Nullable byte[] avatar, + boolean avatarChanged, + @NonNull Consumer uploadResultConsumer); + + void getCurrentUsername(@NonNull Consumer> callback); + + enum UploadResult { + SUCCESS, + ERROR_IO, + ERROR_BAD_RECIPIENT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java new file mode 100644 index 00000000..53978025 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.profiles.edit; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Arrays; +import java.util.Objects; + +class EditProfileViewModel extends ViewModel { + + private final MutableLiveData givenName = new MutableLiveData<>(); + private final MutableLiveData familyName = new MutableLiveData<>(); + private final LiveData trimmedGivenName = Transformations.map(givenName, StringUtil::trimToVisualBounds); + private final LiveData trimmedFamilyName = Transformations.map(familyName, StringUtil::trimToVisualBounds); + private final LiveData internalProfileName = LiveDataUtil.combineLatest(trimmedGivenName, trimmedFamilyName, ProfileName::fromParts); + private final MutableLiveData internalAvatar = new MutableLiveData<>(); + private final MutableLiveData originalAvatar = new MutableLiveData<>(); + private final MutableLiveData originalDisplayName = new MutableLiveData<>(); + private final LiveData isFormValid; + private final EditProfileRepository repository; + private final GroupId groupId; + + private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) { + this.repository = repository; + this.groupId = groupId; + this.isFormValid = groupId != null && groupId.isMms() ? LiveDataUtil.just(true) + : Transformations.map(trimmedGivenName, s -> s.length() > 0); + + if (!hasInstanceState) { + if (groupId != null) { + repository.getCurrentDisplayName(originalDisplayName::setValue); + repository.getCurrentName(givenName::setValue); + } else { + repository.getCurrentProfileName(name -> { + givenName.setValue(name.getGivenName()); + familyName.setValue(name.getFamilyName()); + }); + } + + repository.getCurrentAvatar(value -> { + internalAvatar.setValue(value); + originalAvatar.setValue(value); + }); + } + } + + public LiveData givenName() { + return Transformations.distinctUntilChanged(givenName); + } + + public LiveData familyName() { + return Transformations.distinctUntilChanged(familyName); + } + + public LiveData profileName() { + return Transformations.distinctUntilChanged(internalProfileName); + } + + public LiveData isFormValid() { + return Transformations.distinctUntilChanged(isFormValid); + } + + public LiveData avatar() { + return Transformations.distinctUntilChanged(internalAvatar); + } + + public boolean hasAvatar() { + return internalAvatar.getValue() != null; + } + + public boolean isGroup() { + return groupId != null; + } + + public boolean canRemoveProfilePhoto() { + return hasAvatar(); + } + + public void setGivenName(String givenName) { + this.givenName.setValue(givenName); + } + + public void setFamilyName(String familyName) { + this.familyName.setValue(familyName); + } + + public void setAvatar(byte[] avatar) { + internalAvatar.setValue(avatar); + } + + public void submitProfile(Consumer uploadResultConsumer) { + ProfileName profileName = isGroup() ? ProfileName.EMPTY : internalProfileName.getValue(); + String displayName = isGroup() ? givenName.getValue() : ""; + + if (profileName == null || displayName == null) { + return; + } + + byte[] oldAvatar = originalAvatar.getValue(); + byte[] newAvatar = internalAvatar.getValue(); + String oldDisplayName = isGroup() ? originalDisplayName.getValue() : null; + + repository.uploadProfile(profileName, + displayName, + !Objects.equals(StringUtil.stripBidiProtection(oldDisplayName), displayName), + newAvatar, + !Arrays.equals(oldAvatar, newAvatar), + uploadResultConsumer); + } + + static class Factory implements ViewModelProvider.Factory { + + private final EditProfileRepository repository; + private final boolean hasInstanceState; + private final GroupId groupId; + + Factory(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) { + this.repository = repository; + this.hasInstanceState = hasInstanceState; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new EditProfileViewModel(repository, hasInstanceState, groupId); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java new file mode 100644 index 00000000..45f2f60a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.profiles.edit; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.SystemProfileUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; + +public class EditSelfProfileRepository implements EditProfileRepository { + + private static final String TAG = Log.tag(EditSelfProfileRepository.class); + + private final Context context; + private final boolean excludeSystem; + + EditSelfProfileRepository(@NonNull Context context, boolean excludeSystem) { + this.context = context.getApplicationContext(); + this.excludeSystem = excludeSystem; + } + + @Override + public void getCurrentProfileName(@NonNull Consumer profileNameConsumer) { + ProfileName storedProfileName = Recipient.self().getProfileName(); + if (!storedProfileName.isEmpty()) { + profileNameConsumer.accept(storedProfileName); + } else if (!excludeSystem) { + SystemProfileUtil.getSystemProfileName(context).addListener(new ListenableFuture.Listener() { + @Override + public void onSuccess(String result) { + if (!TextUtils.isEmpty(result)) { + profileNameConsumer.accept(ProfileName.fromSerialized(result)); + } else { + profileNameConsumer.accept(storedProfileName); + } + } + + @Override + public void onFailure(ExecutionException e) { + Log.w(TAG, e); + profileNameConsumer.accept(storedProfileName); + } + }); + } else { + profileNameConsumer.accept(storedProfileName); + } + } + + @Override + public void getCurrentAvatar(@NonNull Consumer avatarConsumer) { + RecipientId selfId = Recipient.self().getId(); + + if (AvatarHelper.hasAvatar(context, selfId)) { + SimpleTask.run(() -> { + try { + return StreamUtil.readFully(AvatarHelper.getAvatar(context, selfId)); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + }, avatarConsumer::accept); + } else if (!excludeSystem) { + SystemProfileUtil.getSystemProfileAvatar(context, new ProfileMediaConstraints()).addListener(new ListenableFuture.Listener() { + @Override + public void onSuccess(byte[] result) { + avatarConsumer.accept(result); + } + + @Override + public void onFailure(ExecutionException e) { + Log.w(TAG, e); + avatarConsumer.accept(null); + } + }); + } + } + + @Override + public void getCurrentDisplayName(@NonNull Consumer displayNameConsumer) { + displayNameConsumer.accept(""); + } + + @Override + public void getCurrentName(@NonNull Consumer nameConsumer) { + nameConsumer.accept(""); + } + + @Override + public void uploadProfile(@NonNull ProfileName profileName, + @NonNull String displayName, + boolean displayNameChanged, + @Nullable byte[] avatar, + boolean avatarChanged, + @NonNull Consumer uploadResultConsumer) + { + SimpleTask.run(() -> { + DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); + + if (avatarChanged) { + try { + AvatarHelper.setAvatar(context, Recipient.self().getId(), avatar != null ? new ByteArrayInputStream(avatar) : null); + } catch (IOException e) { + return UploadResult.ERROR_IO; + } + } + + ApplicationDependencies.getJobManager() + .startChain(new ProfileUploadJob()) + .then(Arrays.asList(new MultiDeviceProfileKeyUpdateJob(), new MultiDeviceProfileContentUpdateJob())) + .enqueue(); + + return UploadResult.SUCCESS; + }, uploadResultConsumer::accept); + } + + @Override + public void getCurrentUsername(@NonNull Consumer> callback) { + callback.accept(Recipient.self().getUsername()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java new file mode 100644 index 00000000..6ed6f1d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java @@ -0,0 +1,266 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.dd.CircularProgressButton; + +import org.signal.core.util.BreakIteratorCompat; +import org.signal.core.util.EditTextUtil; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; + +import java.util.Arrays; +import java.util.List; + +/** + * Let's you edit the 'About' section of your profile. + */ +public class EditAboutFragment extends Fragment implements ManageProfileActivity.EmojiController { + + public static final int ABOUT_MAX_GLYPHS = 140; + public static final int ABOUT_LIMIT_DISPLAY_THRESHOLD = 120; + + private static final String KEY_SELECTED_EMOJI = "selected_emoji"; + + private static final List PRESETS = Arrays.asList( + new AboutPreset("\uD83D\uDC4B", R.string.EditAboutFragment_speak_freely), + new AboutPreset("\uD83E\uDD10", R.string.EditAboutFragment_encrypted), + new AboutPreset("\uD83D\uDE4F", R.string.EditAboutFragment_be_kind), + new AboutPreset("☕", R.string.EditAboutFragment_coffee_lover), + new AboutPreset("\uD83D\uDC4D", R.string.EditAboutFragment_free_to_chat), + new AboutPreset("\uD83D\uDCF5", R.string.EditAboutFragment_taking_a_break), + new AboutPreset("\uD83D\uDE80", R.string.EditAboutFragment_working_on_something_new) + ); + + private ImageView emojiView; + private EditText bodyView; + private TextView countView; + private CircularProgressButton saveButton; + private EditAboutViewModel viewModel; + + private String selectedEmoji; + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.edit_about_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.emojiView = view.findViewById(R.id.edit_about_emoji); + this.bodyView = view.findViewById(R.id.edit_about_body); + this.countView = view.findViewById(R.id.edit_about_count); + this.saveButton = view.findViewById(R.id.edit_about_save); + + initializeViewModel(); + + view.findViewById(R.id.toolbar) + .setNavigationOnClickListener(v -> Navigation.findNavController(view) + .popBackStack()); + + EditTextUtil.addGraphemeClusterLimitFilter(bodyView, ABOUT_MAX_GLYPHS); + this.bodyView.addTextChangedListener(new AfterTextChanged(editable -> { + trimFieldToMaxByteLength(editable); + presentCount(editable.toString()); + })); + + this.emojiView.setOnClickListener(v -> { + ReactWithAnyEmojiBottomSheetDialogFragment.createForAboutSelection() + .show(requireFragmentManager(), "BOTTOM"); + }); + + view.findViewById(R.id.edit_about_clear).setOnClickListener(v -> onClearClicked()); + + saveButton.setOnClickListener(v -> viewModel.onSaveClicked(requireContext(), + bodyView.getText().toString(), + selectedEmoji)); + + + RecyclerView presetList = view.findViewById(R.id.edit_about_presets); + PresetAdapter presetAdapter = new PresetAdapter(); + + presetList.setAdapter(presetAdapter); + presetList.setLayoutManager(new LinearLayoutManager(requireContext())); + + presetAdapter.submitList(PRESETS); + + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_SELECTED_EMOJI)) { + onEmojiSelectedInternal(savedInstanceState.getString(KEY_SELECTED_EMOJI, "")); + } else { + this.bodyView.setText(Recipient.self().getAbout()); + onEmojiSelectedInternal(Optional.fromNullable(Recipient.self().getAboutEmoji()).or("")); + } + + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(bodyView); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + outState.putString(KEY_SELECTED_EMOJI, selectedEmoji); + } + + @Override + public void onEmojiSelected(@NonNull String emoji) { + onEmojiSelectedInternal(emoji); + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(bodyView); + } + + private void onEmojiSelectedInternal(@NonNull String emoji) { + Drawable drawable = EmojiUtil.convertToDrawable(requireContext(), emoji); + if (drawable != null) { + this.emojiView.setImageDrawable(drawable); + this.selectedEmoji = emoji; + } else { + this.emojiView.setImageResource(R.drawable.ic_add_emoji); + this.selectedEmoji = ""; + } + } + + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this).get(EditAboutViewModel.class); + + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + } + + private void presentCount(@NonNull String aboutBody) { + BreakIteratorCompat breakIterator = BreakIteratorCompat.getInstance(); + breakIterator.setText(aboutBody); + int glyphCount = breakIterator.countBreaks(); + + if (glyphCount >= ABOUT_LIMIT_DISPLAY_THRESHOLD) { + this.countView.setVisibility(View.VISIBLE); + this.countView.setText(getResources().getString(R.string.EditAboutFragment_count, glyphCount, ABOUT_MAX_GLYPHS)); + } else { + this.countView.setVisibility(View.GONE); + } + } + + private void presentSaveState(@NonNull EditAboutViewModel.SaveState state) { + switch (state) { + case IDLE: + saveButton.setClickable(true); + saveButton.setIndeterminateProgressMode(false); + saveButton.setProgress(0); + break; + case IN_PROGRESS: + saveButton.setClickable(false); + saveButton.setIndeterminateProgressMode(true); + saveButton.setProgress(50); + break; + case DONE: + saveButton.setClickable(false); + Navigation.findNavController(requireView()).popBackStack(); + break; + } + } + + private void presentEvent(@NonNull EditAboutViewModel.Event event) { + if (event == EditAboutViewModel.Event.NETWORK_FAILURE) { + Toast.makeText(requireContext(), R.string.EditProfileNameFragment_failed_to_save_due_to_network_issues_try_again_later, Toast.LENGTH_SHORT).show(); + } + } + + private void onClearClicked() { + bodyView.setText(""); + onEmojiSelectedInternal(""); + } + + private static void trimFieldToMaxByteLength(Editable s) { + int trimmedLength = StringUtil.trimToFit(s.toString(), ProfileCipher.MAX_POSSIBLE_ABOUT_LENGTH).length(); + + if (s.length() > trimmedLength) { + s.delete(trimmedLength, s.length()); + } + } + + private void onPresetSelected(@NonNull AboutPreset preset) { + onEmojiSelectedInternal(preset.getEmoji()); + bodyView.setText(requireContext().getString(preset.getBodyRes())); + bodyView.setSelection(bodyView.length(), bodyView.length()); + } + + private final class PresetAdapter extends ListAdapter { + + protected PresetAdapter() { + super(new AlwaysChangedDiffUtil<>()); + } + + @Override + public @NonNull PresetViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new PresetViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.about_preset_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull PresetViewHolder holder, int position) { + AboutPreset preset = getItem(position); + + holder.bind(preset); + holder.itemView.setOnClickListener(v -> onPresetSelected(preset)); + } + } + + private final class PresetViewHolder extends RecyclerView.ViewHolder { + + private final ImageView emoji; + private final TextView body; + + public PresetViewHolder(@NonNull View itemView) { + super(itemView); + + this.emoji = itemView.findViewById(R.id.about_preset_emoji); + this.body = itemView.findViewById(R.id.about_preset_body); + } + + public void bind(@NonNull AboutPreset preset) { + this.emoji.setImageDrawable(EmojiUtil.convertToDrawable(itemView.getContext(), preset.getEmoji())); + this.body.setText(preset.getBodyRes()); + } + } + + private static final class AboutPreset { + private final String emoji; + private final int bodyRes; + + private AboutPreset(@NonNull String emoji, @StringRes int bodyRes) { + this.emoji = emoji; + this.bodyRes = bodyRes; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public @StringRes int getBodyRes() { + return bodyRes; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java new file mode 100644 index 00000000..016223a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public final class EditAboutViewModel extends ViewModel { + + private final ManageProfileRepository repository; + private final MutableLiveData saveState; + private final SingleLiveEvent events; + + public EditAboutViewModel() { + this.repository = new ManageProfileRepository(); + this.saveState = new MutableLiveData<>(SaveState.IDLE); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData getSaveState() { + return saveState; + } + + @NonNull LiveData getEvents() { + return events; + } + + void onSaveClicked(@NonNull Context context, @NonNull String about, @NonNull String emoji) { + saveState.setValue(SaveState.IN_PROGRESS); + repository.setAbout(context, about, emoji, result -> { + switch (result) { + case SUCCESS: + saveState.postValue(SaveState.DONE); + break; + case FAILURE_NETWORK: + saveState.postValue(SaveState.IDLE); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + } + + enum SaveState { + IDLE, IN_PROGRESS, DONE + } + + enum Event { + NETWORK_FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java new file mode 100644 index 00000000..4f1c0125 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; + +import org.signal.core.util.EditTextUtil; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +/** + * Simple fragment to edit your profile name. + */ +public class EditProfileNameFragment extends Fragment { + + public static final int NAME_MAX_GLYPHS = 26; + + private EditText givenName; + private EditText familyName; + private CircularProgressButton saveButton; + private EditProfileNameViewModel viewModel; + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.edit_profile_name_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.givenName = view.findViewById(R.id.edit_profile_name_given_name); + this.familyName = view.findViewById(R.id.edit_profile_name_family_name); + this.saveButton = view.findViewById(R.id.edit_profile_name_save); + + initializeViewModel(); + + this.givenName.setText(Recipient.self().getProfileName().getGivenName()); + this.familyName.setText(Recipient.self().getProfileName().getFamilyName()); + + view.findViewById(R.id.toolbar) + .setNavigationOnClickListener(v -> Navigation.findNavController(view) + .popBackStack()); + + EditTextUtil.addGraphemeClusterLimitFilter(givenName, NAME_MAX_GLYPHS); + EditTextUtil.addGraphemeClusterLimitFilter(familyName, NAME_MAX_GLYPHS); + + this.givenName.addTextChangedListener(new AfterTextChanged(EditProfileNameFragment::trimFieldToMaxByteLength)); + this.familyName.addTextChangedListener(new AfterTextChanged(EditProfileNameFragment::trimFieldToMaxByteLength)); + + saveButton.setOnClickListener(v -> viewModel.onSaveClicked(requireContext(), + givenName.getText().toString(), + familyName.getText().toString())); + } + + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this).get(EditProfileNameViewModel.class); + + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + } + + private void presentSaveState(@NonNull EditProfileNameViewModel.SaveState state) { + switch (state) { + case IDLE: + saveButton.setClickable(true); + saveButton.setIndeterminateProgressMode(false); + saveButton.setProgress(0); + break; + case IN_PROGRESS: + saveButton.setClickable(false); + saveButton.setIndeterminateProgressMode(true); + saveButton.setProgress(50); + break; + case DONE: + saveButton.setClickable(false); + Navigation.findNavController(requireView()).popBackStack(); + break; + } + } + + private void presentEvent(@NonNull EditProfileNameViewModel.Event event) { + if (event == EditProfileNameViewModel.Event.NETWORK_FAILURE) { + Toast.makeText(requireContext(), R.string.EditProfileNameFragment_failed_to_save_due_to_network_issues_try_again_later, Toast.LENGTH_SHORT).show(); + } + } + + public static void trimFieldToMaxByteLength(Editable s) { + int trimmedLength = StringUtil.trimToFit(s.toString(), ProfileName.MAX_PART_LENGTH).length(); + + if (s.length() > trimmedLength) { + s.delete(trimmedLength, s.length()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java new file mode 100644 index 00000000..5c80cd57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public final class EditProfileNameViewModel extends ViewModel { + + private final ManageProfileRepository repository; + private final MutableLiveData saveState; + private final SingleLiveEvent events; + + public EditProfileNameViewModel() { + this.repository = new ManageProfileRepository(); + this.saveState = new MutableLiveData<>(SaveState.IDLE); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData getSaveState() { + return saveState; + } + + @NonNull LiveData getEvents() { + return events; + } + + void onSaveClicked(@NonNull Context context, @NonNull String givenName, @NonNull String familyName) { + saveState.setValue(SaveState.IN_PROGRESS); + repository.setName(context, ProfileName.fromParts(givenName, familyName), result -> { + switch (result) { + case SUCCESS: + saveState.postValue(SaveState.DONE); + break; + case FAILURE_NETWORK: + saveState.postValue(SaveState.IDLE); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + } + + enum SaveState { + IDLE, IN_PROGRESS, DONE + } + + enum Event { + NETWORK_FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java new file mode 100644 index 00000000..1a154b80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +/** + * Activity that manages the local user's profile, as accessed via the settings. + */ +public class ManageProfileActivity extends PassphraseRequiredActivity implements ReactWithAnyEmojiBottomSheetDialogFragment.Callback { + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static final String START_AT_USERNAME = "start_at_username"; + + public static @NonNull Intent getIntent(@NonNull Context context) { + return new Intent(context, ManageProfileActivity.class); + } + + public static @NonNull Intent getIntentForUsernameEdit(@NonNull Context context) { + Intent intent = new Intent(context, ManageProfileActivity.class); + intent.putExtra(START_AT_USERNAME, true); + return intent; + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.manage_profile_activity); + + if (bundle == null) { + Bundle extras = getIntent().getExtras(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle()); + + if (extras != null && extras.getBoolean(START_AT_USERNAME, false)) { + NavDirections action = ManageProfileFragmentDirections.actionManageUsername(); + Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action); + } + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public void onReactWithAnyEmojiDialogDismissed() { + } + + @Override + public void onReactWithAnyEmojiPageChanged(int page) { + } + + @Override + public void onReactWithAnyEmojiSelected(@NonNull String emoji) { + NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().getPrimaryNavigationFragment(); + Fragment activeFragment = navHostFragment.getChildFragmentManager().getPrimaryNavigationFragment(); + + if (activeFragment instanceof EmojiController) { + ((EmojiController) activeFragment).onEmojiSelected(emoji); + } + } + + interface EmojiController { + void onEmojiSelected(@NonNull String emoji); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java new file mode 100644 index 00000000..c3f1253f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -0,0 +1,202 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.res.ResourcesCompat; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import com.bumptech.glide.Glide; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import static android.app.Activity.RESULT_OK; + +public class ManageProfileFragment extends LoggingFragment { + + private static final String TAG = Log.tag(ManageProfileFragment.class); + private static final short REQUEST_CODE_SELECT_AVATAR = 31726; + + private Toolbar toolbar; + private ImageView avatarView; + private View avatarPlaceholderView; + private TextView profileNameView; + private View profileNameContainer; + private TextView usernameView; + private View usernameContainer; + private TextView aboutView; + private View aboutContainer; + private ImageView aboutEmojiView; + private AlertDialog avatarProgress; + + private ManageProfileViewModel viewModel; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.manage_profile_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.toolbar = view.findViewById(R.id.toolbar); + this.avatarView = view.findViewById(R.id.manage_profile_avatar); + this.avatarPlaceholderView = view.findViewById(R.id.manage_profile_avatar_placeholder); + this.profileNameView = view.findViewById(R.id.manage_profile_name); + this.profileNameContainer = view.findViewById(R.id.manage_profile_name_container); + this.usernameView = view.findViewById(R.id.manage_profile_username); + this.usernameContainer = view.findViewById(R.id.manage_profile_username_container); + this.aboutView = view.findViewById(R.id.manage_profile_about); + this.aboutContainer = view.findViewById(R.id.manage_profile_about_container); + this.aboutEmojiView = view.findViewById(R.id.manage_profile_about_icon); + + initializeViewModel(); + + this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish()); + this.avatarView.setOnClickListener(v -> onAvatarClicked()); + + this.profileNameContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageProfileName()); + }); + + this.usernameContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageUsername()); + }); + + this.aboutContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageAbout()); + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { + if (data != null && data.getBooleanExtra("delete", false)) { + viewModel.onAvatarSelected(requireContext(), null); + return; + } + + Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + + viewModel.onAvatarSelected(requireContext(), result); + } + } + + private void initializeViewModel() { + viewModel = ViewModelProviders.of(this, new ManageProfileViewModel.Factory()).get(ManageProfileViewModel.class); + + viewModel.getAvatar().observe(getViewLifecycleOwner(), this::presentAvatar); + viewModel.getProfileName().observe(getViewLifecycleOwner(), this::presentProfileName); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + viewModel.getAbout().observe(getViewLifecycleOwner(), this::presentAbout); + viewModel.getAboutEmoji().observe(getViewLifecycleOwner(), this::presentAboutEmoji); + + if (viewModel.shouldShowUsername()) { + viewModel.getUsername().observe(getViewLifecycleOwner(), this::presentUsername); + } else { + usernameContainer.setVisibility(View.GONE); + } + } + + private void presentAvatar(@NonNull AvatarState avatarState) { + if (avatarState.getAvatar() == null) { + avatarView.setImageDrawable(null); + avatarPlaceholderView.setVisibility(View.VISIBLE); + } else { + avatarPlaceholderView.setVisibility(View.GONE); + Glide.with(this) + .load(avatarState.getAvatar()) + .circleCrop() + .into(avatarView); + } + + if (avatarProgress == null && avatarState.getLoadingState() == ManageProfileViewModel.LoadingState.LOADING) { + avatarProgress = SimpleProgressDialog.show(requireContext()); + } else if (avatarProgress != null && avatarState.getLoadingState() == ManageProfileViewModel.LoadingState.LOADED) { + avatarProgress.dismiss(); + } + } + + private void presentProfileName(@Nullable ProfileName profileName) { + if (profileName == null || profileName.isEmpty()) { + profileNameView.setText(R.string.ManageProfileFragment_profile_name); + } else { + profileNameView.setText(profileName.toString()); + } + } + + private void presentUsername(@Nullable String username) { + if (username == null || username.isEmpty()) { + usernameView.setText(R.string.ManageProfileFragment_username); + usernameView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_secondary)); + } else { + usernameView.setText(username); + usernameView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_primary)); + } + } + + private void presentAbout(@Nullable String about) { + if (about == null || about.isEmpty()) { + aboutView.setText(R.string.ManageProfileFragment_about); + aboutView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_secondary)); + } else { + aboutView.setText(about); + aboutView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_primary)); + } + } + + private void presentAboutEmoji(@NonNull String aboutEmoji) { + if (aboutEmoji == null || aboutEmoji.isEmpty()) { + aboutEmojiView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null)); + } else { + Drawable emoji = EmojiUtil.convertToDrawable(requireContext(), aboutEmoji); + + if (emoji != null) { + aboutEmojiView.setImageDrawable(emoji); + } else { + aboutEmojiView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null)); + } + } + } + + private void presentEvent(@NonNull ManageProfileViewModel.Event event) { + switch (event) { + case AVATAR_DISK_FAILURE: + Toast.makeText(requireContext(), R.string.ManageProfileFragment_failed_to_set_avatar, Toast.LENGTH_LONG).show(); + break; + case AVATAR_NETWORK_FAILURE: + Toast.makeText(requireContext(), R.string.EditProfileNameFragment_failed_to_save_due_to_network_issues_try_again_later, Toast.LENGTH_LONG).show(); + break; + } + } + + private void onAvatarClicked() { + AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveAvatar(), + true, + REQUEST_CODE_SELECT_AVATAR, + false) + .show(getChildFragmentManager(), null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java new file mode 100644 index 00000000..9e9f0a42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import com.tm.androidcopysdk.CommonUtils; +import com.tm.androidcopysdk.utils.PrefManager; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +final class ManageProfileRepository { + + private static final String TAG = Log.tag(ManageProfileRepository.class); + + public void setName(@NonNull Context context, @NonNull ProfileName profileName, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithName(context, profileName); + DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during name change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + public void setAbout(@NonNull Context context, @NonNull String about, @NonNull String emoji, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithAbout(context, about, emoji); + DatabaseFactory.getRecipientDatabase(context).setAbout(Recipient.self().getId(), about, emoji); + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during about change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + public void setAvatar(@NonNull Context context, @NonNull byte[] data, @NonNull String contentType, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithAvatar(context, new StreamDetails(new ByteArrayInputStream(data), contentType, data.length)); + AvatarHelper.setAvatar(context, Recipient.self().getId(), new ByteArrayInputStream(data)); + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during avatar change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + public void clearAvatar(@NonNull Context context, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithAvatar(context, null); + AvatarHelper.delete(context, Recipient.self().getId()); + + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during name change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + enum Result { + SUCCESS, FAILURE_NETWORK + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java new file mode 100644 index 00000000..ce01fac0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +class ManageProfileViewModel extends ViewModel { + + private static final String TAG = Log.tag(ManageProfileViewModel.class); + + private final MutableLiveData avatar; + private final MutableLiveData profileName; + private final MutableLiveData username; + private final MutableLiveData about; + private final MutableLiveData aboutEmoji; + private final SingleLiveEvent events; + private final RecipientForeverObserver observer; + private final ManageProfileRepository repository; + + private byte[] previousAvatar; + + public ManageProfileViewModel() { + this.avatar = new MutableLiveData<>(); + this.profileName = new MutableLiveData<>(); + this.username = new MutableLiveData<>(); + this.about = new MutableLiveData<>(); + this.aboutEmoji = new MutableLiveData<>(); + this.events = new SingleLiveEvent<>(); + this.repository = new ManageProfileRepository(); + this.observer = this::onRecipientChanged; + + SignalExecutors.BOUNDED.execute(() -> { + onRecipientChanged(Recipient.self().fresh()); + + StreamDetails details = AvatarHelper.getSelfProfileAvatarStream(ApplicationDependencies.getApplication()); + if (details != null) { + try { + avatar.postValue(AvatarState.loaded(StreamUtil.readFully(details.getStream()))); + } catch (IOException e) { + Log.w(TAG, "Failed to read avatar!"); + avatar.postValue(AvatarState.none()); + } + } else { + avatar.postValue(AvatarState.none()); + } + + ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(Recipient.self().getId())); + }); + + Recipient.self().live().observeForever(observer); + } + + public @NonNull LiveData getAvatar() { + return avatar; + } + + public @NonNull LiveData getProfileName() { + return profileName; + } + + public @NonNull LiveData getUsername() { + return username; + } + + public @NonNull LiveData getAbout() { + return about; + } + + public @NonNull LiveData getAboutEmoji() { + return aboutEmoji; + } + + public @NonNull LiveData getEvents() { + return events; + } + + public boolean shouldShowUsername() { + return FeatureFlags.usernames(); + } + + public void onAvatarSelected(@NonNull Context context, @Nullable Media media) { + previousAvatar = avatar.getValue() != null ? avatar.getValue().getAvatar() : null; + + if (media == null) { + avatar.postValue(AvatarState.loading(null)); + repository.clearAvatar(context, result -> { + switch (result) { + case SUCCESS: + avatar.postValue(AvatarState.loaded(null)); + previousAvatar = null; + break; + case FAILURE_NETWORK: + avatar.postValue(AvatarState.loaded(previousAvatar)); + events.postValue(Event.AVATAR_NETWORK_FAILURE); + break; + } + }); + } else { + SignalExecutors.BOUNDED.execute(() -> { + try { + InputStream stream = BlobProvider.getInstance().getStream(context, media.getUri()); + byte[] data = StreamUtil.readFully(stream); + + avatar.postValue(AvatarState.loading(data)); + + repository.setAvatar(context, data, media.getMimeType(), result -> { + switch (result) { + case SUCCESS: + avatar.postValue(AvatarState.loaded(data)); + previousAvatar = data; + break; + case FAILURE_NETWORK: + avatar.postValue(AvatarState.loaded(previousAvatar)); + events.postValue(Event.AVATAR_NETWORK_FAILURE); + break; + } + }); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + events.postValue(Event.AVATAR_DISK_FAILURE); + } + }); + } + } + + public boolean canRemoveAvatar() { + return avatar.getValue() != null; + } + + private void onRecipientChanged(@NonNull Recipient recipient) { + profileName.postValue(recipient.getProfileName()); + username.postValue(recipient.getUsername().orNull()); + about.postValue(recipient.getAbout()); + aboutEmoji.postValue(recipient.getAboutEmoji()); + } + + @Override + protected void onCleared() { + Recipient.self().live().removeForeverObserver(observer); + } + + public static class AvatarState { + private final byte[] avatar; + private final LoadingState loadingState; + + public AvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) { + this.avatar = avatar; + this.loadingState = loadingState; + } + + private static @NonNull AvatarState none() { + return new AvatarState(null, LoadingState.LOADED); + } + + private static @NonNull AvatarState loaded(@Nullable byte[] avatar) { + return new AvatarState(avatar, LoadingState.LOADED); + } + + private static @NonNull AvatarState loading(@Nullable byte[] avatar) { + return new AvatarState(avatar, LoadingState.LOADING); + } + + public @Nullable byte[] getAvatar() { + return avatar; + } + + public LoadingState getLoadingState() { + return loadingState; + } + } + + public enum LoadingState { + LOADING, LOADED + } + + enum Event { + AVATAR_NETWORK_FAILURE, AVATAR_DISK_FAILURE + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new ManageProfileViewModel())); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java new file mode 100644 index 00000000..4d4c176b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; + +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.UsernameUtil; + +public class UsernameEditFragment extends LoggingFragment { + + private static final float DISABLED_ALPHA = 0.5f; + + private UsernameEditViewModel viewModel; + + private EditText usernameInput; + private TextView usernameSubtext; + private CircularProgressButton submitButton; + private CircularProgressButton deleteButton; + + public static UsernameEditFragment newInstance() { + return new UsernameEditFragment(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.username_edit_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + usernameInput = view.findViewById(R.id.username_text); + usernameSubtext = view.findViewById(R.id.username_subtext); + submitButton = view.findViewById(R.id.username_submit_button); + deleteButton = view.findViewById(R.id.username_delete_button); + + view.findViewById(R.id.toolbar) + .setNavigationOnClickListener(v -> Navigation.findNavController(view) + .popBackStack()); + + viewModel = ViewModelProviders.of(this, new UsernameEditViewModel.Factory()).get(UsernameEditViewModel.class); + + viewModel.getUiState().observe(getViewLifecycleOwner(), this::onUiStateChanged); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::onEvent); + + submitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted(usernameInput.getText().toString())); + deleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted()); + + usernameInput.setText(Recipient.self().getUsername().orNull()); + usernameInput.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.onUsernameUpdated(text); + } + }); + usernameInput.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + viewModel.onUsernameSubmitted(usernameInput.getText().toString()); + return true; + } + return false; + }); + } + + private void onUiStateChanged(@NonNull UsernameEditViewModel.State state) { + usernameInput.setEnabled(true); + + switch (state.getButtonState()) { + case SUBMIT: + cancelSpinning(submitButton); + submitButton.setVisibility(View.VISIBLE); + submitButton.setEnabled(true); + submitButton.setAlpha(1); + deleteButton.setVisibility(View.GONE); + break; + case SUBMIT_DISABLED: + cancelSpinning(submitButton); + submitButton.setVisibility(View.VISIBLE); + submitButton.setEnabled(false); + submitButton.setAlpha(DISABLED_ALPHA); + deleteButton.setVisibility(View.GONE); + break; + case SUBMIT_LOADING: + setSpinning(submitButton); + submitButton.setVisibility(View.VISIBLE); + submitButton.setAlpha(1); + deleteButton.setVisibility(View.GONE); + usernameInput.setEnabled(false); + break; + case DELETE: + cancelSpinning(deleteButton); + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setEnabled(true); + deleteButton.setAlpha(1); + submitButton.setVisibility(View.GONE); + break; + case DELETE_DISABLED: + cancelSpinning(deleteButton); + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setEnabled(false); + deleteButton.setAlpha(DISABLED_ALPHA); + submitButton.setVisibility(View.GONE); + break; + case DELETE_LOADING: + setSpinning(deleteButton); + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setAlpha(1); + submitButton.setVisibility(View.GONE); + usernameInput.setEnabled(false); + break; + } + + switch (state.getUsernameStatus()) { + case NONE: + usernameSubtext.setText(""); + break; + case TOO_SHORT: + case TOO_LONG: + usernameSubtext.setText(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH)); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_red)); + break; + case INVALID_CHARACTERS: + usernameSubtext.setText(R.string.UsernameEditFragment_usernames_can_only_include); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_red)); + break; + case CANNOT_START_WITH_NUMBER: + usernameSubtext.setText(R.string.UsernameEditFragment_usernames_cannot_begin_with_a_number); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_red)); + break; + case INVALID_GENERIC: + usernameSubtext.setText(R.string.UsernameEditFragment_username_is_invalid); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_red)); + break; + case TAKEN: + usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_taken); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_red)); + break; + case AVAILABLE: + usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_available); + usernameSubtext.setTextColor(getResources().getColor(R.color.core_green)); + break; + } + } + + private void onEvent(@NonNull UsernameEditViewModel.Event event) { + switch (event) { + case SUBMIT_SUCCESS: + Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_set_username, Toast.LENGTH_SHORT).show(); + NavHostFragment.findNavController(this).popBackStack(); + break; + case SUBMIT_FAIL_TAKEN: + Toast.makeText(requireContext(), R.string.UsernameEditFragment_this_username_is_taken, Toast.LENGTH_SHORT).show(); + break; + case SUBMIT_FAIL_INVALID: + Toast.makeText(requireContext(), R.string.UsernameEditFragment_username_is_invalid, Toast.LENGTH_SHORT).show(); + break; + case DELETE_SUCCESS: + Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_removed_username, Toast.LENGTH_SHORT).show(); + NavHostFragment.findNavController(this).popBackStack(); + break; + case NETWORK_FAILURE: + Toast.makeText(requireContext(), R.string.UsernameEditFragment_encountered_a_network_error, Toast.LENGTH_SHORT).show(); + break; + } + } + + private static void setSpinning(@NonNull CircularProgressButton button) { + button.setClickable(false); + button.setIndeterminateProgressMode(true); + button.setProgress(50); + } + + private static void cancelSpinning(@NonNull CircularProgressButton button) { + button.setProgress(0); + button.setIndeterminateProgressMode(false); + button.setClickable(true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java new file mode 100644 index 00000000..430256b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; +import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; + +import java.io.IOException; +import java.util.concurrent.Executor; + +class UsernameEditRepository { + + private static final String TAG = Log.tag(UsernameEditRepository.class); + + private final Application application; + private final SignalServiceAccountManager accountManager; + private final Executor executor; + + UsernameEditRepository() { + this.application = ApplicationDependencies.getApplication(); + this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + this.executor = SignalExecutors.UNBOUNDED; + } + + void setUsername(@NonNull String username, @NonNull Callback callback) { + executor.execute(() -> callback.onComplete(setUsernameInternal(username))); + } + + void deleteUsername(@NonNull Callback callback) { + executor.execute(() -> callback.onComplete(deleteUsernameInternal())); + } + + @WorkerThread + private @NonNull UsernameSetResult setUsernameInternal(@NonNull String username) { + try { + accountManager.setUsername(username); + DatabaseFactory.getRecipientDatabase(application).setUsername(Recipient.self().getId(), username); + Log.i(TAG, "[setUsername] Successfully set username."); + return UsernameSetResult.SUCCESS; + } catch (UsernameTakenException e) { + Log.w(TAG, "[setUsername] Username taken."); + return UsernameSetResult.USERNAME_UNAVAILABLE; + } catch (UsernameMalformedException e) { + Log.w(TAG, "[setUsername] Username malformed."); + return UsernameSetResult.USERNAME_INVALID; + } catch (IOException e) { + Log.w(TAG, "[setUsername] Generic network exception.", e); + return UsernameSetResult.NETWORK_ERROR; + } + } + + @WorkerThread + private @NonNull UsernameDeleteResult deleteUsernameInternal() { + try { + accountManager.deleteUsername(); + DatabaseFactory.getRecipientDatabase(application).setUsername(Recipient.self().getId(), null); + Log.i(TAG, "[deleteUsername] Successfully deleted the username."); + return UsernameDeleteResult.SUCCESS; + } catch (IOException e) { + Log.w(TAG, "[deleteUsername] Generic network exception.", e); + return UsernameDeleteResult.NETWORK_ERROR; + } + } + + enum UsernameSetResult { + SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR + } + + enum UsernameDeleteResult { + SUCCESS, NETWORK_ERROR + } + + enum UsernameAvailableResult { + TRUE, FALSE, NETWORK_ERROR + } + + interface Callback { + void onComplete(E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java new file mode 100644 index 00000000..c6873cec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.app.Application; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.UsernameUtil; +import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +class UsernameEditViewModel extends ViewModel { + + private static final String TAG = Log.tag(UsernameEditViewModel.class); + + private final Application application; + private final MutableLiveData uiState; + private final SingleLiveEvent events; + private final UsernameEditRepository repo; + + private UsernameEditViewModel() { + this.application = ApplicationDependencies.getApplication(); + this.repo = new UsernameEditRepository(); + this.uiState = new MutableLiveData<>(); + this.events = new SingleLiveEvent<>(); + + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE)); + } + + void onUsernameUpdated(@NonNull String username) { + if (TextUtils.isEmpty(username) && Recipient.self().getUsername().isPresent()) { + uiState.setValue(new State(ButtonState.DELETE, UsernameStatus.NONE)); + return; + } + + if (username.equals(Recipient.self().getUsername().orNull())) { + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE)); + return; + } + + Optional invalidReason = UsernameUtil.checkUsername(username); + + if (invalidReason.isPresent()) { + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get()))); + return; + } + + uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE)); + } + + void onUsernameSubmitted(@NonNull String username) { + if (username.equals(Recipient.self().getUsername().orNull())) { + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE)); + return; + } + + Optional invalidReason = UsernameUtil.checkUsername(username); + + if (invalidReason.isPresent()) { + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get()))); + return; + } + + uiState.setValue(new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE)); + + repo.setUsername(username, (result) -> { + Util.runOnMain(() -> { + switch (result) { + case SUCCESS: + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE)); + events.postValue(Event.SUBMIT_SUCCESS); + break; + case USERNAME_INVALID: + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC)); + events.postValue(Event.SUBMIT_FAIL_INVALID); + break; + case USERNAME_UNAVAILABLE: + uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN)); + events.postValue(Event.SUBMIT_FAIL_TAKEN); + break; + case NETWORK_ERROR: + uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE)); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + }); + } + + void onUsernameDeleted() { + uiState.setValue(new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE)); + + repo.deleteUsername((result) -> { + Util.runOnMain(() -> { + switch (result) { + case SUCCESS: + uiState.postValue(new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE)); + events.postValue(Event.DELETE_SUCCESS); + break; + case NETWORK_ERROR: + uiState.postValue(new State(ButtonState.DELETE, UsernameStatus.NONE)); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + }); + } + + @NonNull LiveData getUiState() { + return uiState; + } + + @NonNull LiveData getEvents() { + return events; + } + + private static UsernameStatus mapUsernameError(@NonNull InvalidReason invalidReason) { + switch (invalidReason) { + case TOO_SHORT: return UsernameStatus.TOO_SHORT; + case TOO_LONG: return UsernameStatus.TOO_LONG; + case STARTS_WITH_NUMBER: return UsernameStatus.CANNOT_START_WITH_NUMBER; + case INVALID_CHARACTERS: return UsernameStatus.INVALID_CHARACTERS; + default: return UsernameStatus.INVALID_GENERIC; + } + } + + static class State { + private final ButtonState buttonState; + private final UsernameStatus usernameStatus; + + private State(@NonNull ButtonState buttonState, + @NonNull UsernameStatus usernameStatus) + { + this.buttonState = buttonState; + this.usernameStatus = usernameStatus; + } + + @NonNull ButtonState getButtonState() { + return buttonState; + } + + @NonNull UsernameStatus getUsernameStatus() { + return usernameStatus; + } + } + + enum UsernameStatus { + NONE, AVAILABLE, TAKEN, TOO_SHORT, TOO_LONG, CANNOT_START_WITH_NUMBER, INVALID_CHARACTERS, INVALID_GENERIC + } + + enum ButtonState { + SUBMIT, SUBMIT_DISABLED, SUBMIT_LOADING, DELETE, DELETE_LOADING, DELETE_DISABLED + } + + enum Event { + NETWORK_FAILURE, SUBMIT_SUCCESS, DELETE_SUCCESS, SUBMIT_FAIL_INVALID, SUBMIT_FAIL_TAKEN + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new UsernameEditViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java new file mode 100644 index 00000000..a4c45946 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.content.Context; +import android.graphics.Outline; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * Banner displayed within a conversation when a review is suggested. + */ +public class ReviewBannerView extends LinearLayout { + + private ImageView bannerIcon; + private TextView bannerMessage; + private View bannerClose; + private AvatarImageView topLeftAvatar; + private AvatarImageView bottomRightAvatar; + private View stroke; + + public ReviewBannerView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ReviewBannerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + bannerIcon = findViewById(R.id.banner_icon); + bannerMessage = findViewById(R.id.banner_message); + bannerClose = findViewById(R.id.banner_close); + topLeftAvatar = findViewById(R.id.banner_avatar_1); + bottomRightAvatar = findViewById(R.id.banner_avatar_2); + stroke = findViewById(R.id.banner_avatar_stroke); + + FallbackPhotoProvider provider = new FallbackPhotoProvider(); + + topLeftAvatar.setFallbackPhotoProvider(provider); + bottomRightAvatar.setFallbackPhotoProvider(provider); + + bannerClose.setOnClickListener(v -> setVisibility(GONE)); + } + + public void setBannerMessage(@Nullable CharSequence charSequence) { + bannerMessage.setText(charSequence); + } + + public void setBannerIcon(@Nullable Drawable icon) { + bannerIcon.setImageDrawable(icon); + + bannerIcon.setVisibility(VISIBLE); + topLeftAvatar.setVisibility(GONE); + bottomRightAvatar.setVisibility(GONE); + stroke.setVisibility(GONE); + } + + public void setBannerRecipient(@NonNull Recipient recipient) { + topLeftAvatar.setAvatar(recipient); + bottomRightAvatar.setAvatar(recipient); + + bannerIcon.setVisibility(GONE); + topLeftAvatar.setVisibility(VISIBLE); + bottomRightAvatar.setVisibility(VISIBLE); + stroke.setVisibility(VISIBLE); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull + FallbackContactPhoto getPhotoForGroup() { + throw new UnsupportedOperationException("This provider does not support groups"); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForResolvingRecipient() { + throw new UnsupportedOperationException("This provider does not support resolving recipients"); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + throw new UnsupportedOperationException("This provider does not support local number"); + } + + @NonNull + @Override + public FallbackContactPhoto getPhotoForRecipientWithName(String name) { + return new FixedSizeGeneratedContactPhoto(name, R.drawable.ic_profile_outline_20); + } + + @NonNull + @Override + public FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new FallbackPhoto20dp(R.drawable.ic_profile_outline_20); + } + } + + private static final class FixedSizeGeneratedContactPhoto extends GeneratedContactPhoto { + public FixedSizeGeneratedContactPhoto(@NonNull String name, int fallbackResId) { + super(name, fallbackResId); + } + + @Override + protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, color, inverted); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java new file mode 100644 index 00000000..535fe10f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Represents a card showing user details for a recipient under review. + * + * See {@link ReviewCardViewHolder} for usage. + */ +class ReviewCard { + + private final ReviewRecipient reviewRecipient; + private final int inCommonGroupsCount; + private final CardType cardType; + private final Action primaryAction; + private final Action secondaryAction; + + ReviewCard(@NonNull ReviewRecipient reviewRecipient, + int inCommonGroupsCount, + @NonNull CardType cardType, + @Nullable Action primaryAction, + @Nullable Action secondaryAction) + { + this.reviewRecipient = reviewRecipient; + this.inCommonGroupsCount = inCommonGroupsCount; + this.cardType = cardType; + this.primaryAction = primaryAction; + this.secondaryAction = secondaryAction; + } + + @NonNull Recipient getReviewRecipient() { + return reviewRecipient.getRecipient(); + } + + @NonNull CardType getCardType() { + return cardType; + } + + int getInCommonGroupsCount() { + return inCommonGroupsCount; + } + + @Nullable ProfileChangeDetails.StringChange getNameChange() { + if (reviewRecipient.getProfileChangeDetails() == null || !reviewRecipient.getProfileChangeDetails().hasProfileNameChange()) { + return null; + } else { + return reviewRecipient.getProfileChangeDetails().getProfileNameChange(); + } + } + + @Nullable Action getPrimaryAction() { + return primaryAction; + } + + @Nullable Action getSecondaryAction() { + return secondaryAction; + } + + enum CardType { + MEMBER, + REQUEST, + YOUR_CONTACT + } + + enum Action { + UPDATE_CONTACT, + DELETE, + BLOCK, + REMOVE_FROM_GROUP + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java new file mode 100644 index 00000000..5178deb4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.ListAdapter; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; + +import java.util.Objects; + +class ReviewCardAdapter extends ListAdapter { + + private final @StringRes int noGroupsInCommonResId; + private final @PluralsRes int groupsInCommonResId; + private final CallbacksAdapter callbackAdapter; + + protected ReviewCardAdapter(@StringRes int noGroupsInCommonResId, @PluralsRes int groupsInCommonResId, @NonNull Callbacks callback) { + super(new AlwaysChangedDiffUtil<>()); + + this.noGroupsInCommonResId = noGroupsInCommonResId; + this.groupsInCommonResId = groupsInCommonResId; + this.callbackAdapter = new CallbacksAdapter(callback); + } + + @Override + public @NonNull ReviewCardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ReviewCardViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.review_card, parent, false), + noGroupsInCommonResId, + groupsInCommonResId, + callbackAdapter); + } + + @Override + public void onBindViewHolder(@NonNull ReviewCardViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + interface Callbacks { + void onCardClicked(@NonNull ReviewCard card); + void onActionClicked(@NonNull ReviewCard card, @NonNull ReviewCard.Action action); + } + + private final class CallbacksAdapter implements ReviewCardViewHolder.Callbacks { + + private final Callbacks callback; + + private CallbacksAdapter(@NonNull Callbacks callback) { + this.callback = callback; + } + + @Override + public void onCardClicked(int position) { + callback.onCardClicked(getItem(position)); + } + + @Override + public void onPrimaryActionItemClicked(int position) { + ReviewCard card = getItem(position); + callback.onActionClicked(card, Objects.requireNonNull(card.getPrimaryAction())); + } + + @Override + public void onSecondaryActionItemClicked(int position) { + ReviewCard card = getItem(position); + callback.onActionClicked(card, Objects.requireNonNull(card.getSecondaryAction())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java new file mode 100644 index 00000000..fc21e07f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.app.AlertDialog; +import android.content.Intent; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.FullScreenDialogFragment; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; + +public class ReviewCardDialogFragment extends FullScreenDialogFragment { + + private static final String EXTRA_TITLE_RES_ID = "extra.title.res.id"; + private static final String EXTRA_DESCRIPTION_RES_ID = "extra.description.res.id"; + private static final String EXTRA_GROUPS_IN_COMMON_RES_ID = "extra.groups.in.common.res.id"; + private static final String EXTRA_NO_GROUPS_IN_COMMON_RES_ID = "extra.no.groups.in.common.res.id"; + private static final String EXTRA_RECIPIENT_ID = "extra.recipient.id"; + private static final String EXTRA_GROUP_ID = "extra.group.id"; + + private ReviewCardViewModel viewModel; + + public static ReviewCardDialogFragment createForReviewRequest(@NonNull RecipientId recipientId) { + return create(R.string.ReviewCardDialogFragment__review_request, + R.string.ReviewCardDialogFragment__if_youre_not_sure, + R.string.ReviewCardDialogFragment__no_groups_in_common, + R.plurals.ReviewCardDialogFragment__d_groups_in_common, + recipientId, + null); + } + + public static ReviewCardDialogFragment createForReviewMembers(@NonNull GroupId.V2 groupId) { + return create(R.string.ReviewCardDialogFragment__review_members, + R.string.ReviewCardDialogFragment__d_group_members_have_the_same_name, + R.string.ReviewCardDialogFragment__no_other_groups_in_common, + R.plurals.ReviewCardDialogFragment__d_other_groups_in_common, + null, + groupId); + } + + private static ReviewCardDialogFragment create(@StringRes int titleResId, + @StringRes int descriptionResId, + @StringRes int noGroupsInCommonResId, + @PluralsRes int groupsInCommonResId, + @Nullable RecipientId recipientId, + @Nullable GroupId.V2 groupId) + { + ReviewCardDialogFragment fragment = new ReviewCardDialogFragment(); + Bundle args = new Bundle(); + + args.putInt(EXTRA_TITLE_RES_ID, titleResId); + args.putInt(EXTRA_DESCRIPTION_RES_ID, descriptionResId); + args.putInt(EXTRA_GROUPS_IN_COMMON_RES_ID, groupsInCommonResId); + args.putInt(EXTRA_NO_GROUPS_IN_COMMON_RES_ID, noGroupsInCommonResId); + args.putParcelable(EXTRA_RECIPIENT_ID, recipientId); + args.putString(EXTRA_GROUP_ID, groupId != null ? groupId.toString() : null); + + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + try { + initializeViewModel(); + } catch (BadGroupIdException e) { + throw new IllegalStateException(e); + } + + TextView description = view.findViewById(R.id.description); + RecyclerView recycler = view.findViewById(R.id.recycler); + + ReviewCardAdapter adapter = new ReviewCardAdapter(getNoGroupsInCommonResId(), getGroupsInCommonResId(), new AdapterCallbacks()); + recycler.setAdapter(adapter); + + viewModel.getReviewCards().observe(getViewLifecycleOwner(), cards -> { + adapter.submitList(cards); + description.setText(getString(getDescriptionResId(), cards.size())); + }); + + viewModel.getReviewEvents().observe(getViewLifecycleOwner(), this::onReviewEvent); + } + + private void initializeViewModel() throws BadGroupIdException { + ReviewCardRepository repository = getRepository(); + ReviewCardViewModel.Factory factory = new ReviewCardViewModel.Factory(repository, getGroupId() != null); + + viewModel = ViewModelProviders.of(this, factory).get(ReviewCardViewModel.class); + } + + private @StringRes int getDescriptionResId() { + return requireArguments().getInt(EXTRA_DESCRIPTION_RES_ID); + } + + private @PluralsRes int getGroupsInCommonResId() { + return requireArguments().getInt(EXTRA_GROUPS_IN_COMMON_RES_ID); + } + + private @StringRes int getNoGroupsInCommonResId() { + return requireArguments().getInt(EXTRA_NO_GROUPS_IN_COMMON_RES_ID); + } + + private @Nullable RecipientId getRecipientId() { + return requireArguments().getParcelable(EXTRA_RECIPIENT_ID); + } + + private @Nullable GroupId.V2 getGroupId() throws BadGroupIdException { + GroupId groupId = GroupId.parseNullable(requireArguments().getString(EXTRA_GROUP_ID)); + + if (groupId != null) { + return groupId.requireV2(); + } else { + return null; + } + } + + private @NonNull ReviewCardRepository getRepository() throws BadGroupIdException { + RecipientId recipientId = getRecipientId(); + GroupId.V2 groupId = getGroupId(); + + if (recipientId != null) { + return new ReviewCardRepository(requireContext(), recipientId); + } else if (groupId != null) { + return new ReviewCardRepository(requireContext(), groupId); + } else { + throw new AssertionError(); + } + } + + private void onReviewEvent(ReviewCardViewModel.Event reviewEvent) { + switch (reviewEvent) { + case DISMISS: + dismiss(); + break; + case REMOVE_FAILED: + toast(R.string.ReviewCardDialogFragment__failed_to_remove_group_member); + break; + default: + throw new IllegalArgumentException("Unhandled event: " + reviewEvent); + } + } + + private void toast(@StringRes int message) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + } + + @Override + protected int getTitle() { + return requireArguments().getInt(EXTRA_TITLE_RES_ID); + } + + @Override + protected int getDialogLayoutResource() { + return R.layout.fragment_review; + } + + private final class AdapterCallbacks implements ReviewCardAdapter.Callbacks { + + @Override + public void onCardClicked(@NonNull ReviewCard card) { + RecipientBottomSheetDialogFragment.create(card.getReviewRecipient().getId(), null) + .show(requireFragmentManager(), null); + } + + @Override + public void onActionClicked(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) { + switch (action) { + case UPDATE_CONTACT: + Intent contactEditIntent = new Intent(Intent.ACTION_EDIT); + contactEditIntent.setDataAndType(card.getReviewRecipient().getContactUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE); + startActivity(contactEditIntent); + break; + case REMOVE_FROM_GROUP: + new AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.ReviewCardDialogFragment__remove_s_from_group, + card.getReviewRecipient().getDisplayName(requireContext()))) + .setPositiveButton(R.string.ReviewCardDialogFragment__remove, (dialog, which) -> { + viewModel.act(card, action); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, + (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .show(); + break; + default: + viewModel.act(card, action); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java new file mode 100644 index 00000000..3b43b797 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +class ReviewCardRepository { + + private final Context context; + private final GroupId.V2 groupId; + private final RecipientId recipientId; + + protected ReviewCardRepository(@NonNull Context context, + @NonNull GroupId.V2 groupId) + { + this.context = context; + this.groupId = groupId; + this.recipientId = null; + } + + protected ReviewCardRepository(@NonNull Context context, + @NonNull RecipientId recipientId) + { + this.context = context; + this.groupId = null; + this.recipientId = recipientId; + } + + void loadRecipients(@NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) { + if (groupId != null) { + loadRecipientsForGroup(groupId, onRecipientsLoadedListener); + } else if (recipientId != null) { + loadSimilarRecipients(context, recipientId, onRecipientsLoadedListener); + } else { + throw new AssertionError(); + } + } + + @WorkerThread + int loadGroupsInCommonCount(@NonNull ReviewRecipient reviewRecipient) { + return ReviewUtil.getGroupsInCommonCount(context, reviewRecipient.getRecipient().getId()); + } + + void block(@NonNull ReviewCard reviewCard, @NonNull Runnable onActionCompleteListener) { + if (recipientId == null) { + throw new UnsupportedOperationException(); + } + + SignalExecutors.BOUNDED.execute(() -> { + RecipientUtil.blockNonGroup(context, reviewCard.getReviewRecipient()); + onActionCompleteListener.run(); + }); + } + + void delete(@NonNull ReviewCard reviewCard, @NonNull Runnable onActionCompleteListener) { + if (recipientId == null) { + throw new UnsupportedOperationException(); + } + + SignalExecutors.BOUNDED.execute(() -> { + Recipient resolved = Recipient.resolved(recipientId); + + if (resolved.isGroup()) throw new AssertionError(); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forDelete(recipientId)); + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + long threadId = Objects.requireNonNull(threadDatabase.getThreadIdFor(recipientId)); + + threadDatabase.deleteConversation(threadId); + onActionCompleteListener.run(); + }); + } + + void removeFromGroup(@NonNull ReviewCard reviewCard, @NonNull OnRemoveFromGroupListener onRemoveFromGroupListener) { + if (groupId == null) { + throw new UnsupportedOperationException(); + } + + SignalExecutors.BOUNDED.execute(() -> { + try { + GroupManager.ejectFromGroup(context, groupId, reviewCard.getReviewRecipient()); + onRemoveFromGroupListener.onActionCompleted(); + } catch (GroupChangeException | IOException e) { + onRemoveFromGroupListener.onActionFailed(); + } + }); + } + + private static void loadRecipientsForGroup(@NonNull GroupId.V2 groupId, + @NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) + { + SignalExecutors.BOUNDED.execute(() -> onRecipientsLoadedListener.onRecipientsLoaded(ReviewUtil.getDuplicatedRecipients(groupId))); + } + + private static void loadSimilarRecipients(@NonNull Context context, + @NonNull RecipientId recipientId, + @NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) + { + SignalExecutors.BOUNDED.execute(() -> { + Recipient resolved = Recipient.resolved(recipientId); + + List recipientIds = DatabaseFactory.getRecipientDatabase(context) + .getSimilarRecipientIds(resolved); + + if (recipientIds.isEmpty()) { + onRecipientsLoadedListener.onRecipientsLoadFailed(); + return; + } + + List recipients = Stream.of(recipientIds) + .map(Recipient::resolved) + .map(ReviewRecipient::new) + .sorted(new ReviewRecipient.Comparator(context, recipientId)) + .toList(); + + onRecipientsLoadedListener.onRecipientsLoaded(recipients); + }); + } + + interface OnRecipientsLoadedListener { + void onRecipientsLoaded(@NonNull List recipients); + void onRecipientsLoadFailed(); + } + + interface OnRemoveFromGroupListener { + void onActionCompleted(); + void onActionFailed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java new file mode 100644 index 00000000..54b9fc70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.content.Context; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.util.SpanUtil; + +class ReviewCardViewHolder extends RecyclerView.ViewHolder { + + private final int noGroupsInCommonResId; + private final int groupsInCommonResId; + private final TextView title; + private final AvatarImageView avatar; + private final TextView name; + private final TextView subtextLine1; + private final TextView subtextLine2; + private final Button primaryAction; + private final Button secondaryAction; + + public ReviewCardViewHolder(@NonNull View itemView, + @StringRes int noGroupsInCommonResId, + @PluralsRes int groupsInCommonResId, + @NonNull Callbacks callbacks) + { + super(itemView); + + this.noGroupsInCommonResId = noGroupsInCommonResId; + this.groupsInCommonResId = groupsInCommonResId; + this.title = itemView.findViewById(R.id.card_title); + this.avatar = itemView.findViewById(R.id.card_avatar); + this.name = itemView.findViewById(R.id.card_name); + this.subtextLine1 = itemView.findViewById(R.id.card_subtext_line1); + this.subtextLine2 = itemView.findViewById(R.id.card_subtext_line2); + this.primaryAction = itemView.findViewById(R.id.card_primary_action_button); + this.secondaryAction = itemView.findViewById(R.id.card_secondary_action_button); + + itemView.findViewById(R.id.card_tap_target).setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + callbacks.onCardClicked(getAdapterPosition()); + } + }); + + primaryAction.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + callbacks.onPrimaryActionItemClicked(getAdapterPosition()); + } + }); + + secondaryAction.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + callbacks.onSecondaryActionItemClicked(getAdapterPosition()); + } + }); + } + + void bind(@NonNull ReviewCard reviewCard) { + Context context = itemView.getContext(); + + avatar.setAvatar(reviewCard.getReviewRecipient()); + name.setText(reviewCard.getReviewRecipient().getDisplayName(context)); + title.setText(getTitleResId(reviewCard.getCardType())); + + switch (reviewCard.getCardType()) { + case MEMBER: + case REQUEST: + setNonContactSublines(context, reviewCard); + break; + case YOUR_CONTACT: + subtextLine1.setText(reviewCard.getReviewRecipient().getE164().orNull()); + subtextLine2.setText(getGroupsInCommon(reviewCard.getInCommonGroupsCount())); + break; + default: + throw new AssertionError(); + } + + setActions(reviewCard); + } + + private void setNonContactSublines(@NonNull Context context, @NonNull ReviewCard reviewCard) { + subtextLine1.setText(getGroupsInCommon(reviewCard.getInCommonGroupsCount())); + + if (reviewCard.getNameChange() != null) { + subtextLine2.setText(SpanUtil.italic(context.getString(R.string.ReviewCard__recently_changed, + reviewCard.getNameChange().getPrevious(), + reviewCard.getNameChange().getNew()))); + } + } + + private void setActions(@NonNull ReviewCard reviewCard) { + setAction(reviewCard.getPrimaryAction(), primaryAction); + setAction(reviewCard.getSecondaryAction(), secondaryAction); + } + + private String getGroupsInCommon(int groupsInCommon) { + if (groupsInCommon == 0) { + return itemView.getContext().getString(noGroupsInCommonResId); + } else { + return itemView.getResources().getQuantityString(groupsInCommonResId, groupsInCommon, groupsInCommon); + } + } + + private static void setAction(@Nullable ReviewCard.Action action, @NonNull Button actionButton) { + if (action != null) { + actionButton.setText(getActionLabelResId(action)); + actionButton.setVisibility(View.VISIBLE); + } else { + actionButton.setVisibility(View.GONE); + } + } + + interface Callbacks { + void onCardClicked(int position); + void onPrimaryActionItemClicked(int position); + void onSecondaryActionItemClicked(int position); + } + + private static @StringRes int getTitleResId(@NonNull ReviewCard.CardType cardType) { + switch (cardType) { + case MEMBER: + return R.string.ReviewCard__member; + case REQUEST: + return R.string.ReviewCard__request; + case YOUR_CONTACT: + return R.string.ReviewCard__your_contact; + default: + throw new IllegalArgumentException("Unsupported card type " + cardType); + } + } + + private static @StringRes int getActionLabelResId(@NonNull ReviewCard.Action action) { + switch (action) { + case UPDATE_CONTACT: + return R.string.ReviewCard__update_contact; + case DELETE: + return R.string.ReviewCard__delete; + case BLOCK: + return R.string.ReviewCard__block; + case REMOVE_FROM_GROUP: + return R.string.ReviewCard__remove_from_group; + default: + throw new IllegalArgumentException("Unsupported action: " + action); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java new file mode 100644 index 00000000..26e1aa02 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.List; +import java.util.Objects; + +public class ReviewCardViewModel extends ViewModel { + + private final ReviewCardRepository repository; + private final boolean isGroupThread; + private final MutableLiveData> reviewRecipients; + private final LiveData> reviewCards; + private final SingleLiveEvent reviewEvents; + + public ReviewCardViewModel(@NonNull ReviewCardRepository repository, boolean isGroupThread) { + this.repository = repository; + this.isGroupThread = isGroupThread; + this.reviewRecipients = new MutableLiveData<>(); + this.reviewCards = LiveDataUtil.mapAsync(reviewRecipients, this::transformReviewRecipients); + this.reviewEvents = new SingleLiveEvent<>(); + + repository.loadRecipients(new OnRecipientsLoadedListener()); + } + + LiveData> getReviewCards() { + return reviewCards; + } + + LiveData getReviewEvents() { + return reviewEvents; + } + + public void act(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) { + if (card.getPrimaryAction() == action || card.getSecondaryAction() == action) { + performAction(card, action); + } else { + throw new IllegalArgumentException("Cannot perform " + action + " on review card."); + } + } + + private void performAction(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) { + switch (action) { + case BLOCK: + repository.block(card, () -> reviewEvents.postValue(Event.DISMISS)); + break; + case DELETE: + repository.delete(card, () -> reviewEvents.postValue(Event.DISMISS)); + break; + case REMOVE_FROM_GROUP: + repository.removeFromGroup(card, new OnRemoveFromGroupListener()); + break; + default: + throw new IllegalArgumentException("Unsupported action: " + action); + } + } + + @WorkerThread + private @NonNull List transformReviewRecipients(@NonNull List reviewRecipients) { + return Stream.of(reviewRecipients) + .map(r -> new ReviewCard(r, + repository.loadGroupsInCommonCount(r) - (isGroupThread ? 1 : 0), + getCardType(r), + getPrimaryAction(r), + getSecondaryAction(r))) + .toList(); + + } + + private @NonNull ReviewCard.CardType getCardType(@NonNull ReviewRecipient reviewRecipient) { + if (reviewRecipient.getRecipient().isSystemContact()) { + return ReviewCard.CardType.YOUR_CONTACT; + } else if (isGroupThread) { + return ReviewCard.CardType.MEMBER; + } else { + return ReviewCard.CardType.REQUEST; + } + } + + private @NonNull ReviewCard.Action getPrimaryAction(@NonNull ReviewRecipient reviewRecipient) { + if (reviewRecipient.getRecipient().isSystemContact()) { + return ReviewCard.Action.UPDATE_CONTACT; + } else if (isGroupThread) { + return ReviewCard.Action.REMOVE_FROM_GROUP; + } else { + return ReviewCard.Action.BLOCK; + } + } + + private @Nullable ReviewCard.Action getSecondaryAction(@NonNull ReviewRecipient reviewRecipient) { + if (reviewRecipient.getRecipient().isSystemContact()) { + return null; + } else if (isGroupThread) { + return null; + } else { + return ReviewCard.Action.DELETE; + } + } + + private class OnRecipientsLoadedListener implements ReviewCardRepository.OnRecipientsLoadedListener { + @Override + public void onRecipientsLoaded(@NonNull List recipients) { + if (recipients.size() < 2) { + reviewEvents.postValue(Event.DISMISS); + } else { + reviewRecipients.postValue(recipients); + } + } + + @Override + public void onRecipientsLoadFailed() { + reviewEvents.postValue(Event.DISMISS); + } + } + + private class OnRemoveFromGroupListener implements ReviewCardRepository.OnRemoveFromGroupListener { + @Override + public void onActionCompleted() { + repository.loadRecipients(new OnRecipientsLoadedListener()); + } + + @Override + public void onActionFailed() { + reviewEvents.postValue(Event.REMOVE_FAILED); + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final ReviewCardRepository repository; + private final boolean isGroupThread; + + public Factory(@NonNull ReviewCardRepository repository, boolean isGroupThread) { + this.repository = repository; + this.isGroupThread = isGroupThread; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new ReviewCardViewModel(repository, isGroupThread))); + } + } + + public enum Event { + DISMISS, + REMOVE_FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java new file mode 100644 index 00000000..ee1f0c61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public class ReviewRecipient { + private final Recipient recipient; + private final ProfileChangeDetails profileChangeDetails; + + ReviewRecipient(@NonNull Recipient recipient) { + this(recipient, null); + } + + ReviewRecipient(@NonNull Recipient recipient, @Nullable ProfileChangeDetails profileChangeDetails) { + this.recipient = recipient; + this.profileChangeDetails = profileChangeDetails; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @Nullable ProfileChangeDetails getProfileChangeDetails() { + return profileChangeDetails; + } + + public static class Comparator implements java.util.Comparator { + + private final Context context; + private final RecipientId alwaysFirstId; + + public Comparator(@NonNull Context context, @Nullable RecipientId alwaysFirstId) { + this.context = context; + this.alwaysFirstId = alwaysFirstId; + } + + @Override + public int compare(ReviewRecipient recipient1, ReviewRecipient recipient2) { + int weight1 = recipient1.getRecipient().getId().equals(alwaysFirstId) ? -100 : 0; + int weight2 = recipient2.getRecipient().getId().equals(alwaysFirstId) ? -100 : 0; + + if (recipient1.getProfileChangeDetails() != null && recipient1.getProfileChangeDetails().hasProfileNameChange()) { + weight1--; + } + + if (recipient2.getProfileChangeDetails() != null && recipient2.getProfileChangeDetails().hasProfileNameChange()) { + weight2--; + } + + if (recipient1.getRecipient().isSystemContact()) { + weight1++; + } + + if (recipient2.getRecipient().isSystemContact()) { + weight1++; + } + + if (weight1 == weight2) { + return recipient1.getRecipient() + .getDisplayName(context) + .compareTo(recipient2.getRecipient() + .getDisplayName(context)); + } else { + return Integer.compare(weight1, weight2); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java new file mode 100644 index 00000000..68d264be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.profiles.spoofing; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class ReviewUtil { + + private ReviewUtil() { } + + private static final long TIMEOUT = TimeUnit.HOURS.toMillis(24); + + /** + * Checks a single recipient against the database to see whether duplicates exist. + * This should not be used in the context of a group, due to performance reasons. + * + * @param recipientId Id of the recipient we are interested in. + * @return Whether or not multiple recipients share this profile name. + */ + @WorkerThread + public static boolean isRecipientReviewSuggested(@NonNull RecipientId recipientId) + { + Recipient recipient = Recipient.resolved(recipientId); + + if (recipient.isGroup() || recipient.isSystemContact()) { + return false; + } + + return DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()) + .getSimilarRecipientIds(recipient) + .size() > 1; + } + + @WorkerThread + public static @NonNull List getDuplicatedRecipients(@NonNull GroupId.V2 groupId) + { + Context context = ApplicationDependencies.getApplication(); + List profileChangeRecords = getProfileChangeRecordsForGroup(context, groupId); + + if (profileChangeRecords.isEmpty()) { + return Collections.emptyList(); + } + + List members = DatabaseFactory.getGroupDatabase(context) + .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF); + + List changed = Stream.of(profileChangeRecords) + .distinctBy(record -> record.getRecipient().getId()) + .map(record -> new ReviewRecipient(record.getRecipient().resolve(), getProfileChangeDetails(record))) + .filter(recipient -> !recipient.getRecipient().isSystemContact()) + .toList(); + + List results = new LinkedList<>(); + + for (ReviewRecipient recipient : changed) { + if (results.contains(recipient)) { + continue; + } + + members.remove(recipient.getRecipient()); + + for (Recipient member : members) { + if (Objects.equals(member.getDisplayName(context), recipient.getRecipient().getDisplayName(context))) { + results.add(recipient); + results.add(new ReviewRecipient(member)); + } + } + } + + return results; + } + + @WorkerThread + public static @NonNull List getProfileChangeRecordsForGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) { + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getByGroupId(groupId).get(); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); + + if (threadId == null) { + return Collections.emptyList(); + } else { + return DatabaseFactory.getSmsDatabase(context).getProfileChangeDetailsRecords(threadId, System.currentTimeMillis() - TIMEOUT); + } + } + + @WorkerThread + public static int getGroupsInCommonCount(@NonNull Context context, @NonNull RecipientId recipientId) { + return Stream.of(DatabaseFactory.getGroupDatabase(context) + .getPushGroupsContainingMember(recipientId)) + .filter(g -> g.getMembers().contains(Recipient.self().getId())) + .map(GroupDatabase.GroupRecord::getRecipientId) + .toList() + .size(); + } + + private static @NonNull ProfileChangeDetails getProfileChangeDetails(@NonNull MessageRecord messageRecord) { + try { + return ProfileChangeDetails.parseFrom(Base64.decode(messageRecord.getBody())); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java new file mode 100644 index 00000000..5e4c6c28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentProvider; +import android.content.Context; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.provider.OpenableColumns; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; + +abstract class BaseContentProvider extends ContentProvider { + + private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + + /** + * Sanity checks the security like FileProvider does. + */ + @Override + public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) { + super.attachInfo(context, info); + + if (info.exported) { + throw new SecurityException("Provider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("Provider must grant uri permissions"); + } + } + + protected static Cursor createCursor(@Nullable String[] projection, @NonNull String fileName, long fileSize) { + if (projection == null || projection.length == 0) { + projection = COLUMNS; + } + + ArrayList cols = new ArrayList<>(projection.length); + ArrayList values = new ArrayList<>(projection.length); + + for (String col : projection) { + if (OpenableColumns.DISPLAY_NAME.equals(col)) { + cols.add(OpenableColumns.DISPLAY_NAME); + values.add(fileName); + } else if (OpenableColumns.SIZE.equals(col)) { + cols.add(OpenableColumns.SIZE); + values.add(fileSize); + } + } + + MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1); + + cursor.addRow(values.toArray(new Object[0])); + + return cursor; + } + + protected static String createFileNameForMimeType(String mimeType) { + return mimeType.replace('/', '.'); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java new file mode 100644 index 00000000..a11a3dd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.MemoryFileUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class BlobContentProvider extends BaseContentProvider { + + private static final String TAG = Log.tag(BlobContentProvider.class); + + @Override + public boolean onCreate() { + Log.i(TAG, "onCreate()"); + return true; + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + Log.i(TAG, "openFile() called: " + uri); + + try { + try (InputStream stream = BlobProvider.getInstance().getStream(ApplicationDependencies.getApplication(), uri)) { + Long fileSize = BlobProvider.getFileSize(uri); + if (fileSize == null) { + Log.w(TAG, "No file size available"); + throw new FileNotFoundException(); + } + + return getParcelStreamForStream(stream, Util.toIntExact(fileSize)); + } + } catch (IOException e) { + throw new FileNotFoundException(); + } + } + + private static @NonNull ParcelFileDescriptor getParcelStreamForStream(@NonNull InputStream in, int fileSize) throws IOException { + MemoryFile memoryFile = new MemoryFile(null, fileSize); + + try (OutputStream out = memoryFile.getOutputStream()) { + StreamUtil.copy(in, out); + } + + return MemoryFileUtil.getParcelFileDescriptor(memoryFile); + } + + @Nullable + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { + Log.i(TAG, "query() called: " + uri); + + if (projection == null || projection.length <= 0) return null; + + String mimeType = BlobProvider.getMimeType(uri); + String fileName = BlobProvider.getFileName(uri); + Long fileSize = BlobProvider.getFileSize(uri); + + if (fileSize == null) { + Log.w(TAG, "No file size"); + return null; + } + + if (mimeType == null) { + Log.w(TAG, "No mime type"); + return null; + } + + if (fileName == null) { + fileName = createFileNameForMimeType(mimeType); + } + + return createCursor(projection, fileName, fileSize); + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return BlobProvider.getMimeType(uri); + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java new file mode 100644 index 00000000..37c3ab89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -0,0 +1,586 @@ +package org.thoughtcrime.securesms.providers; + +import android.app.Application; +import android.content.Context; +import android.content.UriMatcher; +import android.media.MediaDataSource; +import android.net.Uri; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.util.IOFunction; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.video.ByteArrayMediaDataSource; +import org.thoughtcrime.securesms.video.EncryptedMediaDataSource; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Allows for the creation and retrieval of blobs. + */ +public class BlobProvider { + + private static final String TAG = BlobProvider.class.getSimpleName(); + + private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; + private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; + + public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".blob"; + public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/blob"); + public static final String PATH = "blob/*/*/*/*/*"; + + private static final int STORAGE_TYPE_PATH_SEGMENT = 1; + private static final int MIMETYPE_PATH_SEGMENT = 2; + private static final int FILENAME_PATH_SEGMENT = 3; + private static final int FILESIZE_PATH_SEGMENT = 4; + private static final int ID_PATH_SEGMENT = 5; + + private static final int MATCH = 1; + private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH) {{ + addURI(AUTHORITY, PATH, MATCH); + }}; + + private static final BlobProvider INSTANCE = new BlobProvider(); + + private final Map memoryBlobs = new HashMap<>(); + + private volatile boolean initialized = false; + + + public static BlobProvider getInstance() { + return INSTANCE; + } + + /** + * Begin building a blob for the provided data. Allows for the creation of in-memory blobs. + */ + public MemoryBlobBuilder forData(@NonNull byte[] data) { + return new MemoryBlobBuilder(data); + } + + /** + * Begin building a blob for the provided input stream. + */ + public BlobBuilder forData(@NonNull InputStream data, long fileSize) { + return new BlobBuilder(data, fileSize); + } + + /** + * Retrieve a stream for the content with the specified URI. + * @throws IOException If the stream fails to open or the spec of the URI doesn't match. + */ + public synchronized @NonNull InputStream getStream(@NonNull Context context, @NonNull Uri uri) throws IOException { + waitUntilInitialized(); + return getStream(context, uri, 0L); + } + + /** + * Retrieve a stream for the content with the specified URI starting from the specified position. + * @throws IOException If the stream fails to open or the spec of the URI doesn't match. + */ + public synchronized @NonNull InputStream getStream(@NonNull Context context, @NonNull Uri uri, long position) throws IOException { + waitUntilInitialized(); + return getBlobRepresentation(context, + uri, + bytes -> { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); + if (byteArrayInputStream.skip(position) != position) { + throw new IOException("Failed to skip to position " + position + " for: " + uri); + } + return byteArrayInputStream; + }, + file -> ModernDecryptingPartInputStream.createFor(getAttachmentSecret(context), + file, + position)); + } + + @RequiresApi(23) + public synchronized @NonNull MediaDataSource getMediaDataSource(@NonNull Context context, @NonNull Uri uri) throws IOException { + waitUntilInitialized(); + return getBlobRepresentation(context, + uri, + ByteArrayMediaDataSource::new, + file -> EncryptedMediaDataSource.createForDiskBlob(getAttachmentSecret(context), file)); + } + + private synchronized @NonNull T getBlobRepresentation(@NonNull Context context, + @NonNull Uri uri, + @NonNull IOFunction getByteRepresentation, + @NonNull IOFunction getFileRepresentation) + throws IOException + { + if (isAuthority(uri)) { + StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); + + if (storageType.isMemory()) { + byte[] data = memoryBlobs.get(uri); + + if (data != null) { + if (storageType == StorageType.SINGLE_USE_MEMORY) { + memoryBlobs.remove(uri); + } + return getByteRepresentation.apply(data); + } else { + throw new IOException("Failed to find in-memory blob for: " + uri); + } + } else { + String id = uri.getPathSegments().get(ID_PATH_SEGMENT); + String directory = getDirectory(storageType); + File file = new File(getOrCreateDirectory(context, directory), buildFileName(id)); + + return getFileRepresentation.apply(file); + } + } else { + throw new IOException("Provided URI does not match this spec. Uri: " + uri); + } + } + + private synchronized AttachmentSecret getAttachmentSecret(@NonNull Context context) { + return AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + } + + /** + * Delete the content with the specified URI. + */ + public synchronized void delete(@NonNull Context context, @NonNull Uri uri) { + waitUntilInitialized(); + + if (!isAuthority(uri)) { + Log.d(TAG, "Can't delete. Not the authority for uri: " + uri); + return; + } + + Log.d(TAG, "Deleting " + getId(uri)); + + try { + StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); + + if (storageType.isMemory()) { + memoryBlobs.remove(uri); + } else { + String id = uri.getPathSegments().get(ID_PATH_SEGMENT); + String directory = getDirectory(storageType); + File file = new File(getOrCreateDirectory(context, directory), buildFileName(id)); + + if (file.delete()) { + Log.d(TAG, "Successfully deleted " + getId(uri)); + } else { + throw new IOException("File wasn't deleted."); + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to delete uri: " + getId(uri), e); + } + } + + /** + * Allows the class to be initialized. Part of this initialization is deleting any leftover + * single-session blobs from the previous session. However, this class defers that work to a + * background thread, so callers don't have to worry about it. + */ + @AnyThread + public synchronized void initialize(@NonNull Context context) { + SignalExecutors.BOUNDED.execute(() -> { + synchronized (this) { + File directory = getOrCreateDirectory(context, SINGLE_SESSION_DIRECTORY); + File[] files = directory.listFiles(); + + if (files != null) { + for (File file : files) { + if (file.delete()) { + Log.d(TAG, "Deleted single-session file: " + file.getName()); + } else { + Log.w(TAG, "Failed to delete single-session file! " + file.getName()); + } + } + } else { + Log.w(TAG, "Null directory listing!"); + } + + Log.i(TAG, "Initialized."); + initialized = true; + notifyAll(); + } + }); + } + + public static @Nullable String getMimeType(@NonNull Uri uri) { + if (isAuthority(uri)) { + return uri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); + } + return null; + } + + public static @Nullable String getFileName(@NonNull Uri uri) { + if (isAuthority(uri)) { + return uri.getPathSegments().get(FILENAME_PATH_SEGMENT); + } + return null; + } + + public static @Nullable Long getFileSize(@NonNull Uri uri) { + if (isAuthority(uri)) { + try { + return Long.parseLong(uri.getPathSegments().get(FILESIZE_PATH_SEGMENT)); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private static @Nullable String getId(@NonNull Uri uri) { + if (isAuthority(uri)) { + return uri.getPathSegments().get(ID_PATH_SEGMENT); + } + return null; + } + + @WorkerThread + public long calculateFileSize(@NonNull Context context, @NonNull Uri uri) { + if (!isAuthority(uri)) { + return 0; + } + + try (InputStream stream = getStream(context, uri)) { + return StreamUtil.getStreamLength(stream); + } catch (IOException e) { + Log.w(TAG, e); + return 0; + } + } + + public static boolean isAuthority(@NonNull Uri uri) { + return URI_MATCHER.match(uri) == MATCH; + } + + @WorkerThread + private synchronized @NonNull Uri writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec) + throws IOException + { + waitUntilInitialized(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference exception = new AtomicReference<>(null); + Uri uri = writeBlobSpecToDiskAsync(context, blobSpec, latch::countDown, e -> { + exception.set(e); + latch.countDown(); + }); + + try { + latch.await(); + } catch (InterruptedException e) { + throw new IOException(e); + } + + if (exception.get() != null) { + throw exception.get(); + } + + return uri; + } + + + @WorkerThread + private synchronized @NonNull Uri writeBlobSpecToDiskAsync(@NonNull Context context, + @NonNull BlobSpec blobSpec, + @Nullable SuccessListener successListener, + @Nullable ErrorListener errorListener) + throws IOException + { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + String directory = getDirectory(blobSpec.getStorageType()); + File outputFile = new File(getOrCreateDirectory(context, directory), buildFileName(blobSpec.id)); + OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; + + SignalExecutors.UNBOUNDED.execute(() -> { + try { + StreamUtil.copy(blobSpec.getData(), outputStream); + + if (successListener != null) { + successListener.onSuccess(); + } + } catch (IOException e) { + Log.w(TAG, "Error during write!", e); + if (errorListener != null) { + errorListener.onError(e); + } + } + }); + + return buildUri(blobSpec); + } + + private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { + Uri uri = buildUri(blobSpec); + memoryBlobs.put(uri, data); + return uri; + } + + private static @NonNull String buildFileName(@NonNull String id) { + return id + ".blob"; + } + + private static @NonNull String getDirectory(@NonNull StorageType storageType) { + return storageType == StorageType.MULTI_SESSION_DISK ? MULTI_SESSION_DIRECTORY : SINGLE_SESSION_DIRECTORY; + } + + private static @NonNull Uri buildUri(@NonNull BlobSpec blobSpec) { + return CONTENT_URI.buildUpon() + .appendPath(blobSpec.getStorageType().encode()) + .appendPath(blobSpec.getMimeType()) + .appendPath(blobSpec.getFileName()) + .appendEncodedPath(String.valueOf(blobSpec.getFileSize())) + .appendPath(blobSpec.getId()) + .build(); + } + + private static File getOrCreateDirectory(@NonNull Context context, @NonNull String directory) { + return context.getDir(directory, Context.MODE_PRIVATE); + } + + public class BlobBuilder { + + private InputStream data; + private String id; + private String mimeType; + private String fileName; + private long fileSize; + + private BlobBuilder(@NonNull InputStream data, long fileSize) { + this.id = UUID.randomUUID().toString(); + this.data = data; + this.fileSize = fileSize; + } + + public BlobBuilder withMimeType(@NonNull String mimeType) { + this.mimeType = mimeType; + return this; + } + + public BlobBuilder withFileName(@Nullable String fileName) { + this.fileName = fileName; + return this; + } + + protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { + return new BlobSpec(data, id, storageType, mimeType, fileName, fileSize); + } + + /** + * Create a blob that will exist for a single app session. An app session is defined as the + * period from one {@link Application#onCreate()} to the next. + */ + @WorkerThread + public Uri createForSingleSessionOnDisk(@NonNull Context context) throws IOException { + return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK)); + } + + /** + * Create a blob that will exist for a single app session. An app session is defined as the + * period from one {@link Application#onCreate()} to the next. The file will be created on disk + * synchronously, but the data will copied asynchronously. This is helpful when the copy is + * long-running, such as in the case of recording a voice note. + */ + @WorkerThread + public Uri createForSingleSessionOnDiskAsync(@NonNull Context context, + @Nullable SuccessListener successListener, + @Nullable ErrorListener errorListener) + throws IOException + { + return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), successListener, errorListener); + } + + /** + * Create a blob that will exist for multiple app sessions. It is the caller's responsibility to + * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. + */ + @WorkerThread + public Uri createForMultipleSessionsOnDisk(@NonNull Context context) throws IOException { + return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK)); + } + + /** + * Create a blob that will exist for multiple app sessions. The file will be created on disk + * synchronously, but the data will copied asynchronously. This is helpful when the copy is + * long-running, such as in the case of recording a voice note. + * + * It is the caller's responsibility to eventually call {@link BlobProvider#delete(Context, Uri)} + * when the blob is no longer in use. + */ + @WorkerThread + public Uri createForMultipleSessionsOnDiskAsync(@NonNull Context context, + @Nullable SuccessListener successListener, + @Nullable ErrorListener errorListener) + throws IOException + { + return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), successListener, errorListener); + } + } + + private synchronized void waitUntilInitialized() { + if (!initialized) { + Log.i(TAG, "Waiting for initialization..."); + synchronized (this) { + while (!initialized) { + Util.wait(this, 0); + } + Log.i(TAG, "Initialization complete."); + } + } + } + + public class MemoryBlobBuilder extends BlobBuilder { + + private byte[] data; + + private MemoryBlobBuilder(@NonNull byte[] data) { + super(new ByteArrayInputStream(data), data.length); + this.data = data; + } + + @Override + public MemoryBlobBuilder withMimeType(@NonNull String mimeType) { + super.withMimeType(mimeType); + return this; + } + + @Override + public MemoryBlobBuilder withFileName(@NonNull String fileName) { + super.withFileName(fileName); + return this; + } + + /** + * Create a blob that is stored in memory and can only be read a single time. After a single + * read, it will be removed from storage. Useful for when a Uri is needed to read transient data. + */ + public Uri createForSingleUseInMemory() { + return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_USE_MEMORY), data); + } + + /** + * Create a blob that is stored in memory. Will persist for a single app session. You should + * always try to call {@link BlobProvider#delete(Context, Uri)} after you're done with the blob + * to free up memory. + */ + public Uri createForSingleSessionInMemory() { + return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_SESSION_MEMORY), data); + } + } + + public interface SuccessListener { + @WorkerThread + void onSuccess(); + } + + public interface ErrorListener { + @WorkerThread + void onError(IOException e); + } + + private static class BlobSpec { + + private final InputStream data; + private final String id; + private final StorageType storageType; + private final String mimeType; + private final String fileName; + private final long fileSize; + + private BlobSpec(@NonNull InputStream data, + @NonNull String id, + @NonNull StorageType storageType, + @NonNull String mimeType, + @Nullable String fileName, + @IntRange(from = 0) long fileSize) + { + this.data = data; + this.id = id; + this.storageType = storageType; + this.mimeType = mimeType; + this.fileName = fileName; + this.fileSize = fileSize; + } + + private @NonNull InputStream getData() { + return data; + } + + private @NonNull String getId() { + return id; + } + + private @NonNull StorageType getStorageType() { + return storageType; + } + + private @NonNull String getMimeType() { + return mimeType; + } + + private @Nullable String getFileName() { + return fileName; + } + + private long getFileSize() { + return fileSize; + } + } + + private enum StorageType { + + SINGLE_USE_MEMORY("single-use-memory", true), + SINGLE_SESSION_MEMORY("single-session-memory", true), + SINGLE_SESSION_DISK("single-session-disk", false), + MULTI_SESSION_DISK("multi-session-disk", false); + + private final String encoded; + private final boolean inMemory; + + StorageType(String encoded, boolean inMemory) { + this.encoded = encoded; + this.inMemory = inMemory; + } + + private String encode() { + return encoded; + } + + private boolean isMemory() { + return inMemory; + } + + private static StorageType decode(@NonNull String encoded) throws IOException { + for (StorageType storageType : StorageType.values()) { + if (storageType.encoded.equals(encoded)) { + return storageType; + } + } + throw new IOException("Failed to decode lifespan."); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/DeprecatedPersistentBlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/DeprecatedPersistentBlobProvider.java new file mode 100644 index 00000000..cca43149 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/DeprecatedPersistentBlobProvider.java @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentUris; +import android.content.Context; +import android.content.UriMatcher; +import android.net.Uri; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.util.FileProviderUtil; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * @deprecated Use {@link BlobProvider} instead. Keeping in read-only mode due to the number of + * legacy URIs it handles. Given that this was largely used for drafts, and that files were stored + * in the cache directory, it's possible that we could remove this class after a reasonable amount + * of time has passed. + */ +@Deprecated +public class DeprecatedPersistentBlobProvider { + + private static final String TAG = Log.tag(DeprecatedPersistentBlobProvider.class); + + public static final String AUTHORITY = BuildConfig.APPLICATION_ID; + private static final String URI_STRING = "content://" + AUTHORITY + "/capture-new"; + public static final Uri CONTENT_URI = Uri.parse(URI_STRING); + public static final String EXPECTED_PATH_OLD = "capture/*/*/#"; + public static final String EXPECTED_PATH_NEW = "capture-new/*/*/*/*/#"; + + private static final int MIMETYPE_PATH_SEGMENT = 1; + private static final int FILENAME_PATH_SEGMENT = 2; + private static final int FILESIZE_PATH_SEGMENT = 3; + + private static final String BLOB_EXTENSION = "blob"; + private static final int MATCH_OLD = 1; + private static final int MATCH_NEW = 2; + + private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH) {{ + addURI(AUTHORITY, EXPECTED_PATH_OLD, MATCH_OLD); + addURI(AUTHORITY, EXPECTED_PATH_NEW, MATCH_NEW); + }}; + + private static volatile DeprecatedPersistentBlobProvider instance; + + /** + * @deprecated Use {@link BlobProvider} instead. + */ + @Deprecated + public static DeprecatedPersistentBlobProvider getInstance(Context context) { + if (instance == null) { + synchronized (DeprecatedPersistentBlobProvider.class) { + if (instance == null) { + instance = new DeprecatedPersistentBlobProvider(context); + } + } + } + return instance; + } + + private final AttachmentSecret attachmentSecret; + + private DeprecatedPersistentBlobProvider(@NonNull Context context) { + this.attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + } + + public Uri createForExternal(@NonNull Context context, @NonNull String mimeType) throws IOException { + File target = new File(getExternalDir(context), String.valueOf(System.currentTimeMillis()) + "." + getExtensionFromMimeType(mimeType)); + return FileProviderUtil.getUriFor(context, target); + } + + public boolean delete(@NonNull Context context, @NonNull Uri uri) { + switch (MATCHER.match(uri)) { + case MATCH_OLD: + case MATCH_NEW: + long id = ContentUris.parseId(uri); + return getFile(context, ContentUris.parseId(uri)).file.delete(); + } + + //noinspection SimplifiableIfStatement + if (isExternalBlobUri(context, uri)) { + return FileProviderUtil.delete(context, uri); + } + + return false; + } + + public @NonNull InputStream getStream(@NonNull Context context, long id) throws IOException { + FileData fileData = getFile(context, id); + + if (fileData.modern) return ModernDecryptingPartInputStream.createFor(attachmentSecret, fileData.file, 0); + else return ClassicDecryptingPartInputStream.createFor(attachmentSecret, fileData.file); + } + + private FileData getFile(@NonNull Context context, long id) { + File legacy = getLegacyFile(context, id); + File cache = getCacheFile(context, id); + File modernCache = getModernCacheFile(context, id); + + if (legacy.exists()) return new FileData(legacy, false); + else if (cache.exists()) return new FileData(cache, false); + else return new FileData(modernCache, true); + } + + private File getLegacyFile(@NonNull Context context, long id) { + return new File(context.getDir("captures", Context.MODE_PRIVATE), id + "." + BLOB_EXTENSION); + } + + private File getCacheFile(@NonNull Context context, long id) { + return new File(context.getCacheDir(), "capture-" + id + "." + BLOB_EXTENSION); + } + + private File getModernCacheFile(@NonNull Context context, long id) { + return new File(context.getCacheDir(), "capture-m-" + id + "." + BLOB_EXTENSION); + } + + public static @Nullable String getMimeType(@NonNull Context context, @NonNull Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + return isExternalBlobUri(context, persistentBlobUri) + ? getMimeTypeFromExtension(persistentBlobUri) + : persistentBlobUri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); + } + + public static @Nullable String getFileName(@NonNull Context context, @NonNull Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + if (isExternalBlobUri(context, persistentBlobUri)) return null; + if (MATCHER.match(persistentBlobUri) == MATCH_OLD) return null; + + return persistentBlobUri.getPathSegments().get(FILENAME_PATH_SEGMENT); + } + + public static @Nullable Long getFileSize(@NonNull Context context, Uri persistentBlobUri) { + if (!isAuthority(context, persistentBlobUri)) return null; + if (isExternalBlobUri(context, persistentBlobUri)) return null; + if (MATCHER.match(persistentBlobUri) == MATCH_OLD) return null; + + try { + return Long.valueOf(persistentBlobUri.getPathSegments().get(FILESIZE_PATH_SEGMENT)); + } catch (NumberFormatException e) { + Log.w(TAG, e); + return null; + } + } + + private static @NonNull String getExtensionFromMimeType(String mimeType) { + final String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + return extension != null ? extension : BLOB_EXTENSION; + } + + private static @NonNull String getMimeTypeFromExtension(@NonNull Uri uri) { + final String mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(uri.toString())); + return mimeType != null ? mimeType : "application/octet-stream"; + } + + private static @NonNull File getExternalDir(Context context) throws IOException { + final File externalDir = context.getExternalCacheDir(); + if (externalDir == null) throw new IOException("no external files directory"); + return externalDir; + } + + public static boolean isAuthority(@NonNull Context context, @NonNull Uri uri) { + int matchResult = MATCHER.match(uri); + return matchResult == MATCH_NEW || matchResult == MATCH_OLD || isExternalBlobUri(context, uri); + } + + private static boolean isExternalBlobUri(@NonNull Context context, @NonNull Uri uri) { + try { + return uri.getPath().startsWith(getExternalDir(context).getAbsolutePath()) || FileProviderUtil.isAuthority(uri); + } catch (IOException ioe) { + Log.w(TAG, "Failed to determine if it's an external blob URI.", ioe); + return false; + } + } + + private static class FileData { + private final File file; + private final boolean modern; + + private FileData(File file, boolean modern) { + this.file = file; + this.modern = modern; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java new file mode 100644 index 00000000..30b244f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java @@ -0,0 +1,144 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class MmsBodyProvider extends BaseContentProvider { + private static final String TAG = MmsBodyProvider.class.getSimpleName(); + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".mms"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/mms"; + public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + private static final int SINGLE_ROW = 1; + + private static final UriMatcher uriMatcher; + + static { + uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + uriMatcher.addURI(CONTENT_AUTHORITY, "mms/#", SINGLE_ROW); + } + + @Override + public boolean onCreate() { + return true; + } + + + private File getFile(Uri uri) { + long id = Long.parseLong(uri.getPathSegments().get(1)); + return new File(getContext().getCacheDir(), id + ".mmsbody"); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + Log.i(TAG, "openFile(" + uri + ", " + mode + ")"); + + switch (uriMatcher.match(uri)) { + case SINGLE_ROW: + Log.i(TAG, "Fetching message body for a single row..."); + File tmpFile = getFile(uri); + + final int fileMode; + switch (mode) { + case "w": fileMode = ParcelFileDescriptor.MODE_TRUNCATE | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_WRITE_ONLY; break; + case "r": fileMode = ParcelFileDescriptor.MODE_READ_ONLY; break; + default: throw new IllegalArgumentException("requested file mode unsupported"); + } + + Log.i(TAG, "returning file " + tmpFile.getAbsolutePath()); + return ParcelFileDescriptor.open(tmpFile, fileMode); + } + + throw new FileNotFoundException("Request for bad message."); + } + + @Override + public int delete(@NonNull Uri uri, String arg1, String[] arg2) { + switch (uriMatcher.match(uri)) { + case SINGLE_ROW: + return getFile(uri).delete() ? 1 : 0; + } + return 0; + } + + @Override + public String getType(@NonNull Uri arg0) { + return null; + } + + @Override + public Uri insert(@NonNull Uri arg0, ContentValues arg1) { + return null; + } + + @Override + public Cursor query(@NonNull Uri arg0, String[] arg1, String arg2, String[] arg3, String arg4) { + return null; + } + + @Override + public int update(@NonNull Uri arg0, ContentValues arg1, String arg2, String[] arg3) { + return 0; + } + public static Pointer makeTemporaryPointer(Context context) { + return new Pointer(context, ContentUris.withAppendedId(MmsBodyProvider.CONTENT_URI, System.currentTimeMillis())); + } + + public static class Pointer { + private final Context context; + private final Uri uri; + + public Pointer(Context context, Uri uri) { + this.context = context; + this.uri = uri; + } + + public Uri getUri() { + return uri; + } + + public OutputStream getOutputStream() throws FileNotFoundException { + return context.getContentResolver().openOutputStream(uri, "w"); + } + + public InputStream getInputStream() throws FileNotFoundException { + return context.getContentResolver().openInputStream(uri); + } + + public void close() { + context.getContentResolver().delete(uri, null, null); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java new file mode 100644 index 00000000..bb4286b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.net.Uri; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.PartUriParser; +import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.MemoryFileUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class PartProvider extends BaseContentProvider { + + private static final String TAG = Log.tag(PartProvider.class); + + private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".part"; + private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/part"; + private static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + private static final int SINGLE_ROW = 1; + + private static final UriMatcher uriMatcher; + + static { + uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + uriMatcher.addURI(CONTENT_AUTHORITY, "part/*/#", SINGLE_ROW); + } + + @Override + public boolean onCreate() { + Log.i(TAG, "onCreate()"); + return true; + } + + public static Uri getContentUri(AttachmentId attachmentId) { + Uri uri = Uri.withAppendedPath(CONTENT_URI, String.valueOf(attachmentId.getUniqueId())); + return ContentUris.withAppendedId(uri, attachmentId.getRowId()); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + Log.i(TAG, "openFile() called!"); + + if (KeyCachingService.isLocked(getContext())) { + Log.w(TAG, "masterSecret was null, abandoning."); + return null; + } + + if (uriMatcher.match(uri) == SINGLE_ROW) { + Log.i(TAG, "Parting out a single row..."); + try { + final PartUriParser partUri = new PartUriParser(uri); + return getParcelStreamForAttachment(partUri.getPartId()); + } catch (IOException ioe) { + Log.w(TAG, ioe); + throw new FileNotFoundException("Error opening file"); + } + } + + throw new FileNotFoundException("Request for bad part."); + } + + @Override + public int delete(@NonNull Uri arg0, String arg1, String[] arg2) { + Log.i(TAG, "delete() called"); + return 0; + } + + @Override + public String getType(@NonNull Uri uri) { + Log.i(TAG, "getType() called: " + uri); + + if (uriMatcher.match(uri) == SINGLE_ROW) { + PartUriParser partUriParser = new PartUriParser(uri); + DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(partUriParser.getPartId()); + + if (attachment != null) { + Log.i(TAG, "getType() called: " + uri + " It's " + attachment.getContentType()); + return attachment.getContentType(); + } + } + + return null; + } + + @Override + public Uri insert(@NonNull Uri arg0, ContentValues arg1) { + Log.i(TAG, "insert() called"); + return null; + } + + @Override + public Cursor query(@NonNull Uri url, @Nullable String[] projection, String selection, String[] selectionArgs, String sortOrder) { + Log.i(TAG, "query() called: " + url); + + if (uriMatcher.match(url) == SINGLE_ROW) { + PartUriParser partUri = new PartUriParser(url); + DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(partUri.getPartId()); + + if (attachment == null) return null; + + long fileSize = attachment.getSize(); + + if (fileSize <= 0) { + Log.w(TAG, "Empty file " + fileSize); + return null; + } + + String fileName = attachment.getFileName() != null ? attachment.getFileName() + : createFileNameForMimeType(attachment.getContentType()); + + return createCursor(projection, fileName, fileSize); + } else { + return null; + } + } + + @Override + public int update(@NonNull Uri arg0, ContentValues arg1, String arg2, String[] arg3) { + Log.i(TAG, "update() called"); + return 0; + } + + private ParcelFileDescriptor getParcelStreamForAttachment(AttachmentId attachmentId) throws IOException { + long plaintextLength = StreamUtil.getStreamLength(DatabaseFactory.getAttachmentDatabase(getContext()).getAttachmentStream(attachmentId, 0)); + MemoryFile memoryFile = new MemoryFile(attachmentId.toString(), Util.toIntExact(plaintextLength)); + + InputStream in = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachmentStream(attachmentId, 0); + OutputStream out = memoryFile.getOutputStream(); + + StreamUtil.copy(in, out); + StreamUtil.close(out); + StreamUtil.close(in); + + return MemoryFileUtil.getParcelFileDescriptor(memoryFile); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/proxy/ProxyBottomSheetFragment.java b/app/src/main/java/org/thoughtcrime/securesms/proxy/ProxyBottomSheetFragment.java new file mode 100644 index 00000000..18a68599 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/proxy/ProxyBottomSheetFragment.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.proxy; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.dd.CircularProgressButton; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.preferences.EditProxyViewModel; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +/** + * A bottom sheet shown in response to a deep link. Allows a user to set a proxy. + */ +public final class ProxyBottomSheetFragment extends BottomSheetDialogFragment { + + private static final String TAG = Log.tag(ProxyBottomSheetFragment.class); + + private static final String ARG_PROXY_LINK = "proxy_link"; + + private TextView proxyText; + private View cancelButton; + private CircularProgressButton useProxyButton; + private EditProxyViewModel viewModel; + + public static void showForProxy(@NonNull FragmentManager manager, @NonNull String proxyLink) { + ProxyBottomSheetFragment fragment = new ProxyBottomSheetFragment(); + + Bundle args = new Bundle(); + args.putString(ARG_PROXY_LINK, proxyLink); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.proxy_bottom_sheet, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.proxyText = view.findViewById(R.id.proxy_sheet_host); + this.useProxyButton = view.findViewById(R.id.proxy_sheet_use_proxy); + this.cancelButton = view.findViewById(R.id.proxy_sheet_cancel); + + String host = getArguments().getString(ARG_PROXY_LINK); + proxyText.setText(host); + + initViewModel(); + + useProxyButton.setOnClickListener(v -> viewModel.onSaveClicked(host)); + cancelButton.setOnClickListener(v -> dismiss()); + } + + private void initViewModel() { + this.viewModel = ViewModelProviders.of(this).get(EditProxyViewModel.class); + + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvents); + } + + private void presentSaveState(@NonNull EditProxyViewModel.SaveState state) { + switch (state) { + case IDLE: + useProxyButton.setClickable(true); + useProxyButton.setIndeterminateProgressMode(false); + useProxyButton.setProgress(0); + break; + case IN_PROGRESS: + useProxyButton.setClickable(false); + useProxyButton.setIndeterminateProgressMode(true); + useProxyButton.setProgress(50); + break; + } + } + + private void presentEvents(@NonNull EditProxyViewModel.Event event) { + switch (event) { + case PROXY_SUCCESS: + Toast.makeText(requireContext(), R.string.ProxyBottomSheetFragment_successfully_connected_to_proxy, Toast.LENGTH_LONG).show(); + dismiss(); + break; + case PROXY_FAILURE: + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.preferences_failed_to_connect) + .setMessage(R.string.preferences_couldnt_connect_to_the_proxy) + .setPositiveButton(android.R.string.ok, (d, i) -> d.dismiss()) + .show(); + dismiss(); + break; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/AccountManagerFactory.java b/app/src/main/java/org/thoughtcrime/securesms/push/AccountManagerFactory.java new file mode 100644 index 00000000..5ce93c9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/AccountManagerFactory.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.android.gms.security.ProviderInstaller; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.util.UUID; + +public class AccountManagerFactory { + + private static final String TAG = AccountManagerFactory.class.getSimpleName(); + + public static @NonNull SignalServiceAccountManager createAuthenticated(@NonNull Context context, + @NonNull UUID uuid, + @NonNull String number, + @NonNull String password) + { + if (new SignalServiceNetworkAccess(context).isCensored(number)) { + SignalExecutors.BOUNDED.execute(() -> { + try { + ProviderInstaller.installIfNeeded(context); + } catch (Throwable t) { + Log.w(TAG, t); + } + }); + } + + return new SignalServiceAccountManager(new SignalServiceNetworkAccess(context).getConfiguration(number), + uuid, number, password, BuildConfig.SIGNAL_AGENT, FeatureFlags.okHttpAutomaticRetry()); + } + + /** + * Should only be used during registration when you haven't yet been assigned a UUID. + */ + public static @NonNull SignalServiceAccountManager createUnauthenticated(@NonNull Context context, + @NonNull String number, + @NonNull String password) + { + if (new SignalServiceNetworkAccess(context).isCensored(number)) { + SignalExecutors.BOUNDED.execute(() -> { + try { + ProviderInstaller.installIfNeeded(context); + } catch (Throwable t) { + Log.w(TAG, t); + } + }); + } + + return new SignalServiceAccountManager(new SignalServiceNetworkAccess(context).getConfiguration(number), + null, number, password, BuildConfig.SIGNAL_AGENT, FeatureFlags.okHttpAutomaticRetry()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingDigicertTrustStore.java b/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingDigicertTrustStore.java new file mode 100644 index 00000000..b5216a87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingDigicertTrustStore.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.InputStream; + +public class DomainFrontingDigicertTrustStore implements TrustStore { + + private final Context context; + + public DomainFrontingDigicertTrustStore(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public InputStream getKeyStoreInputStream() { + return context.getResources().openRawResource(R.raw.censorship_digicert); + } + + @Override + public String getKeyStorePassword() { + return "whisper"; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingTrustStore.java b/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingTrustStore.java new file mode 100644 index 00000000..f2ce38c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/DomainFrontingTrustStore.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.push; + + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.InputStream; + +public class DomainFrontingTrustStore implements TrustStore { + + private final Context context; + + public DomainFrontingTrustStore(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public InputStream getKeyStoreInputStream() { + return context.getResources().openRawResource(R.raw.censorship_fronting); + } + + @Override + public String getKeyStorePassword() { + return "whisper"; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/IasTrustStore.java b/app/src/main/java/org/thoughtcrime/securesms/push/IasTrustStore.java new file mode 100644 index 00000000..e06d2623 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/IasTrustStore.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.InputStream; + +public class IasTrustStore implements TrustStore { + + private final Context context; + + public IasTrustStore(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public InputStream getKeyStoreInputStream() { + return context.getResources().openRawResource(R.raw.ias); + } + + @Override + public String getKeyStorePassword() { + return "whisper"; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SecurityEventListener.java b/app/src/main/java/org/thoughtcrime/securesms/push/SecurityEventListener.java new file mode 100644 index 00000000..cbb7bd0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/SecurityEventListener.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import org.thoughtcrime.securesms.crypto.SecurityEvent; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class SecurityEventListener implements SignalServiceMessageSender.EventListener { + + private static final String TAG = SecurityEventListener.class.getSimpleName(); + + private final Context context; + + public SecurityEventListener(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void onSecurityEvent(SignalServiceAddress textSecureAddress) { + SecurityEvent.broadcastSecurityUpdateEvent(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java new file mode 100644 index 00000000..b7a6db3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java @@ -0,0 +1,304 @@ +package org.thoughtcrime.securesms.push; + + +import android.content.Context; + +import com.annimon.stream.Stream; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.net.CustomDns; +import org.thoughtcrime.securesms.net.DeprecatedClientPreventionInterceptor; +import org.thoughtcrime.securesms.net.RemoteDeprecationDetectorInterceptor; +import org.thoughtcrime.securesms.net.SequentialDns; +import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl; +import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl; +import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl; +import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.SSLContext; + +import okhttp3.CipherSuite; +import okhttp3.ConnectionSpec; +import okhttp3.Dns; +import okhttp3.Interceptor; +import okhttp3.TlsVersion; + +public class SignalServiceNetworkAccess { + + @SuppressWarnings("unused") + private static final String TAG = SignalServiceNetworkAccess.class.getSimpleName(); + + public static final Dns DNS = new SequentialDns(Dns.SYSTEM, new CustomDns("1.1.1.1")); + + private static final String COUNTRY_CODE_EGYPT = "+20"; + private static final String COUNTRY_CODE_UAE = "+971"; + private static final String COUNTRY_CODE_OMAN = "+968"; + private static final String COUNTRY_CODE_QATAR = "+974"; + private static final String COUNTRY_CODE_IRAN = "+98"; + + private static final String SERVICE_REFLECTOR_HOST = "europe-west1-signal-cdn-reflector.cloudfunctions.net"; + private static final String SERVICE_FASTLY_HOST = "textsecure-service.whispersystems.org.global.prod.fastly.net"; + private static final String STORAGE_FASTLY_HOST = "storage.signal.org.global.prod.fastly.net"; + private static final String CDN_FASTLY_HOST = "cdn.signal.org.global.prod.fastly.net"; + private static final String CDN2_FASTLY_HOST = "cdn2.signal.org.global.prod.fastly.net"; + private static final String DIRECTORY_FASTLY_HOST = "api.directory.signal.org.global.prod.fastly.net"; + private static final String KBS_FASTLY_HOST = "api.backup.signal.org.global.prod.fastly.net"; + + private static final ConnectionSpec GMAPS_CONNECTION_SPEC = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .cipherSuites(CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA) + .supportsTlsExtensions(true) + .build(); + + private static final ConnectionSpec GMAIL_CONNECTION_SPEC = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .cipherSuites(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA) + .supportsTlsExtensions(true) + .build(); + + private static final ConnectionSpec PLAY_CONNECTION_SPEC = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .cipherSuites(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA) + .supportsTlsExtensions(true) + .build(); + + private static final ConnectionSpec APP_CONNECTION_SPEC = ConnectionSpec.MODERN_TLS; + + private final Map censorshipConfiguration; + private final String[] censoredCountries; + private final SignalServiceConfiguration uncensoredConfiguration; + + public SignalServiceNetworkAccess(Context context) { + + final TrustStore trustStore = new DomainFrontingTrustStore(context); + final SignalServiceUrl baseGoogleService = new SignalServiceUrl("https://www.google.com/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalServiceUrl baseAndroidService = new SignalServiceUrl("https://android.clients.google.com/service", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalServiceUrl mapsOneAndroidService = new SignalServiceUrl("https://clients3.google.com/service", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalServiceUrl mapsTwoAndroidService = new SignalServiceUrl("https://clients4.google.com/service", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalServiceUrl mailAndroidService = new SignalServiceUrl("https://inbox.google.com/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalServiceUrl egyptGoogleService = new SignalServiceUrl("https://www.google.com.eg/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalServiceUrl uaeGoogleService = new SignalServiceUrl("https://www.google.ae/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalServiceUrl omanGoogleService = new SignalServiceUrl("https://www.google.com.om/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalServiceUrl qatarGoogleService = new SignalServiceUrl("https://www.google.com.qa/service", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + final SignalCdnUrl baseGoogleCdn = new SignalCdnUrl("https://www.google.com/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl baseAndroidCdn = new SignalCdnUrl("https://android.clients.google.com/cdn", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalCdnUrl mapsOneAndroidCdn = new SignalCdnUrl("https://clients3.google.com/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalCdnUrl mapsTwoAndroidCdn = new SignalCdnUrl("https://clients4.google.com/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalCdnUrl mailAndroidCdn = new SignalCdnUrl("https://inbox.google.com/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl egyptGoogleCdn = new SignalCdnUrl("https://www.google.com.eg/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl uaeGoogleCdn = new SignalCdnUrl("https://www.google.ae/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl omanGoogleCdn = new SignalCdnUrl("https://www.google.com.om/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl qatarGoogleCdn = new SignalCdnUrl("https://www.google.com.qa/cdn", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + final SignalCdnUrl baseGoogleCdn2 = new SignalCdnUrl("https://www.google.com/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl baseAndroidCdn2 = new SignalCdnUrl("https://android.clients.google.com/cdn2", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalCdnUrl mapsOneAndroidCdn2 = new SignalCdnUrl("https://clients3.google.com/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalCdnUrl mapsTwoAndroidCdn2 = new SignalCdnUrl("https://clients4.google.com/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalCdnUrl mailAndroidCdn2 = new SignalCdnUrl("https://inbox.google.com/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl egyptGoogleCdn2 = new SignalCdnUrl("https://www.google.com.eg/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl uaeGoogleCdn2 = new SignalCdnUrl("https://www.google.ae/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl omanGoogleCdn2 = new SignalCdnUrl("https://www.google.com.om/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalCdnUrl qatarGoogleCdn2 = new SignalCdnUrl("https://www.google.com.qa/cdn2", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + final SignalContactDiscoveryUrl baseGoogleDiscovery = new SignalContactDiscoveryUrl("https://www.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalContactDiscoveryUrl baseAndroidDiscovery = new SignalContactDiscoveryUrl("https://android.clients.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalContactDiscoveryUrl mapsOneAndroidDiscovery = new SignalContactDiscoveryUrl("https://clients3.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalContactDiscoveryUrl mapsTwoAndroidDiscovery = new SignalContactDiscoveryUrl("https://clients4.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalContactDiscoveryUrl mailAndroidDiscovery = new SignalContactDiscoveryUrl("https://inbox.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalContactDiscoveryUrl egyptGoogleDiscovery = new SignalContactDiscoveryUrl("https://www.google.com.eg/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalContactDiscoveryUrl uaeGoogleDiscovery = new SignalContactDiscoveryUrl("https://www.google.ae/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalContactDiscoveryUrl omanGoogleDiscovery = new SignalContactDiscoveryUrl("https://www.google.com.om/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalContactDiscoveryUrl qatarGoogleDiscovery = new SignalContactDiscoveryUrl("https://www.google.com.qa/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + + final SignalKeyBackupServiceUrl baseGoogleKbs = new SignalKeyBackupServiceUrl("https://www.google.com/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl baseAndroidKbs = new SignalKeyBackupServiceUrl("https://android.clients.google.com/backup", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl mapsOneAndroidKbs = new SignalKeyBackupServiceUrl("https://clients3.google.com/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl mapsTwoAndroidKbs = new SignalKeyBackupServiceUrl("https://clients4.google.com/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl mailAndroidKbs = new SignalKeyBackupServiceUrl("https://inbox.google.com/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl egyptGoogleKbs = new SignalKeyBackupServiceUrl("https://www.google.com.eg/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl uaeGoogleKbs = new SignalKeyBackupServiceUrl("https://www.google.ae/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl omanGoogleKbs = new SignalKeyBackupServiceUrl("https://www.google.com.om/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalKeyBackupServiceUrl qatarGoogleKbs = new SignalKeyBackupServiceUrl("https://www.google.com.qa/backup", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + final SignalStorageUrl baseGoogleStorage = new SignalStorageUrl("https://www.google.com/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalStorageUrl baseAndroidStorage = new SignalStorageUrl("https://android.clients.google.com/storage", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC); + final SignalStorageUrl mapsOneAndroidStorage = new SignalStorageUrl("https://clients3.google.com/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalStorageUrl mapsTwoAndroidStorage = new SignalStorageUrl("https://clients4.google.com/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC); + final SignalStorageUrl mailAndroidStorage = new SignalStorageUrl("https://inbox.google.com/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalStorageUrl egyptGoogleStorage = new SignalStorageUrl("https://www.google.com.eg/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalStorageUrl uaeGoogleStorage = new SignalStorageUrl("https://www.google.ae/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalStorageUrl omanGoogleStorage = new SignalStorageUrl("https://www.google.com.om/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + final SignalStorageUrl qatarGoogleStorage = new SignalStorageUrl("https://www.google.com.qa/storage", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC); + + final String[] fastUrls = {"https://cdn.sstatic.net", "https://github.githubassets.com", "https://pinterest.com", "https://open.scdn.co", "https://www.redditstatic.com"}; + + final List interceptors = Arrays.asList(new StandardUserAgentInterceptor(), new RemoteDeprecationDetectorInterceptor(), new DeprecatedClientPreventionInterceptor()); + final Optional dns = Optional.of(DNS); + + final byte[] zkGroupServerPublicParams; + + try { + zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS); + } catch (IOException e) { + throw new AssertionError(e); + } + + this.censorshipConfiguration = new HashMap() {{ + + put(COUNTRY_CODE_EGYPT, new SignalServiceConfiguration(new SignalServiceUrl[] {egyptGoogleService, baseGoogleService, baseAndroidService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService}, + makeSignalCdnUrlMapFor(new SignalCdnUrl[] {egyptGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn, mailAndroidCdn}, + new SignalCdnUrl[] {egyptGoogleCdn2, baseAndroidCdn2, baseGoogleCdn2, mapsOneAndroidCdn2, mapsTwoAndroidCdn2, mailAndroidCdn2, mailAndroidCdn2}), + new SignalContactDiscoveryUrl[] {egyptGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery}, + new SignalKeyBackupServiceUrl[] {egyptGoogleKbs, baseGoogleKbs, baseAndroidKbs, mapsOneAndroidKbs, mapsTwoAndroidKbs, mailAndroidKbs}, + new SignalStorageUrl[] {egyptGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}, + interceptors, + dns, + Optional.absent(), + zkGroupServerPublicParams)); + + put(COUNTRY_CODE_UAE, new SignalServiceConfiguration(new SignalServiceUrl[] {uaeGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService}, + makeSignalCdnUrlMapFor(new SignalCdnUrl[] {uaeGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn}, + new SignalCdnUrl[] {uaeGoogleCdn2, baseAndroidCdn2, baseGoogleCdn2, mapsOneAndroidCdn2, mapsTwoAndroidCdn2, mailAndroidCdn2}), + new SignalContactDiscoveryUrl[] {uaeGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery}, + new SignalKeyBackupServiceUrl[] {uaeGoogleKbs, baseGoogleKbs, baseAndroidKbs, mapsOneAndroidKbs, mapsTwoAndroidKbs, mailAndroidKbs}, + new SignalStorageUrl[] {uaeGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}, + interceptors, + dns, + Optional.absent(), + zkGroupServerPublicParams)); + + put(COUNTRY_CODE_OMAN, new SignalServiceConfiguration(new SignalServiceUrl[] {omanGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService}, + makeSignalCdnUrlMapFor(new SignalCdnUrl[] {omanGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn}, + new SignalCdnUrl[] {omanGoogleCdn2, baseAndroidCdn2, baseGoogleCdn2, mapsOneAndroidCdn2, mapsTwoAndroidCdn2, mailAndroidCdn2}), + new SignalContactDiscoveryUrl[] {omanGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery}, + new SignalKeyBackupServiceUrl[] {omanGoogleKbs, baseGoogleKbs, baseAndroidKbs, mapsOneAndroidKbs, mapsTwoAndroidKbs, mailAndroidKbs}, + new SignalStorageUrl[] {omanGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}, + interceptors, + dns, + Optional.absent(), + zkGroupServerPublicParams)); + + + put(COUNTRY_CODE_QATAR, new SignalServiceConfiguration(new SignalServiceUrl[] {qatarGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService}, + makeSignalCdnUrlMapFor(new SignalCdnUrl[] {qatarGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn}, + new SignalCdnUrl[] {qatarGoogleCdn2, baseAndroidCdn2, baseGoogleCdn2, mapsOneAndroidCdn2, mapsTwoAndroidCdn2, mailAndroidCdn2}), + new SignalContactDiscoveryUrl[] {qatarGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery}, + new SignalKeyBackupServiceUrl[] {qatarGoogleKbs, baseGoogleKbs, baseAndroidKbs, mapsOneAndroidKbs, mapsTwoAndroidKbs, mailAndroidKbs}, + new SignalStorageUrl[] {qatarGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}, + interceptors, + dns, + Optional.absent(), + zkGroupServerPublicParams)); + + put(COUNTRY_CODE_IRAN, new SignalServiceConfiguration(Stream.of(fastUrls).map(url -> new SignalServiceUrl(url, SERVICE_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalServiceUrl[]::new), + makeSignalCdnUrlMapFor(Stream.of(fastUrls).map(url -> new SignalCdnUrl(url, CDN_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalCdnUrl[]::new), + Stream.of(fastUrls).map(url -> new SignalCdnUrl(url, CDN2_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalCdnUrl[]::new)), + Stream.of(fastUrls).map(url -> new SignalContactDiscoveryUrl(url, DIRECTORY_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalContactDiscoveryUrl[]::new), + Stream.of(fastUrls).map(url -> new SignalKeyBackupServiceUrl(url, KBS_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalKeyBackupServiceUrl[]::new), + Stream.of(fastUrls).map(url -> new SignalStorageUrl(url, STORAGE_FASTLY_HOST, new DomainFrontingDigicertTrustStore(context), APP_CONNECTION_SPEC)).toArray(SignalStorageUrl[]::new), + interceptors, + dns, + Optional.absent(), + zkGroupServerPublicParams)); + }}; + + this.uncensoredConfiguration = new SignalServiceConfiguration(new SignalServiceUrl[] {new SignalServiceUrl(BuildConfig.SIGNAL_URL, new SignalServiceTrustStore(context))}, + makeSignalCdnUrlMapFor(new SignalCdnUrl[] {new SignalCdnUrl(BuildConfig.SIGNAL_CDN_URL, new SignalServiceTrustStore(context))}, + new SignalCdnUrl[] {new SignalCdnUrl(BuildConfig.SIGNAL_CDN2_URL, new SignalServiceTrustStore(context))}), + new SignalContactDiscoveryUrl[] {new SignalContactDiscoveryUrl(BuildConfig.SIGNAL_CONTACT_DISCOVERY_URL, new SignalServiceTrustStore(context))}, + new SignalKeyBackupServiceUrl[] { new SignalKeyBackupServiceUrl(BuildConfig.SIGNAL_KEY_BACKUP_URL, new SignalServiceTrustStore(context)) }, + new SignalStorageUrl[] {new SignalStorageUrl(BuildConfig.STORAGE_URL, new SignalServiceTrustStore(context))}, + interceptors, + dns, + SignalStore.proxy().isProxyEnabled() ? Optional.of(SignalStore.proxy().getProxy()) : Optional.absent(), + zkGroupServerPublicParams); + + this.censoredCountries = this.censorshipConfiguration.keySet().toArray(new String[0]); + } + + public SignalServiceConfiguration getConfiguration(Context context) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + return getConfiguration(localNumber); + } + + public SignalServiceConfiguration getConfiguration(@Nullable String localNumber) { + if (localNumber == null || SignalStore.proxy().isProxyEnabled()) { + return this.uncensoredConfiguration; + } + + if (SignalStore.internalValues().forcedCensorship()) { + return this.censorshipConfiguration.get(COUNTRY_CODE_IRAN); + } + + for (String censoredRegion : this.censoredCountries) { + if (localNumber.startsWith(censoredRegion)) { + return this.censorshipConfiguration.get(censoredRegion); + } + } + + return this.uncensoredConfiguration; + } + + public boolean isCensored(Context context) { + return getConfiguration(context) != this.uncensoredConfiguration; + } + + public boolean isCensored(String number) { + return getConfiguration(number) != this.uncensoredConfiguration; + } + + private static Map makeSignalCdnUrlMapFor(SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls) { + Map result = new HashMap<>(); + result.put(0, cdn0Urls); + result.put(2, cdn2Urls); + return Collections.unmodifiableMap(result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceTrustStore.java b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceTrustStore.java new file mode 100644 index 00000000..f19691e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceTrustStore.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import org.thoughtcrime.securesms.R; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.InputStream; + +public class SignalServiceTrustStore implements TrustStore { + + private final Context context; + + public SignalServiceTrustStore(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public InputStream getKeyStoreInputStream() { + return context.getResources().openRawResource(R.raw.whisper); + } + + @Override + public String getKeyStorePassword() { + return "whisper"; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java new file mode 100644 index 00000000..6cf1a3a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.qr; + +import android.graphics.Bitmap; +import android.graphics.Color; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Stopwatch; + +public final class QrCode { + + private QrCode() { + } + + public static final String TAG = Log.tag(QrCode.class); + + public static @NonNull Bitmap create(@Nullable String data) { + return create(data, Color.BLACK, Color.TRANSPARENT); + } + + public static @NonNull Bitmap create(@Nullable String data, + @ColorInt int foregroundColor, + @ColorInt int backgroundColor) + { + if (data == null || data.length() == 0) { + Log.w(TAG, "No data"); + return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888); + } + + try { + Stopwatch stopwatch = new Stopwatch("QrGen"); + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + BitMatrix qrData = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 512, 512); + int qrWidth = qrData.getWidth(); + int qrHeight = qrData.getHeight(); + int[] pixels = new int[qrWidth * qrHeight]; + + for (int y = 0; y < qrHeight; y++) { + int offset = y * qrWidth; + + for (int x = 0; x < qrWidth; x++) { + pixels[offset + x] = qrData.get(x, y) ? foregroundColor : backgroundColor; + } + } + stopwatch.split("Write pixels"); + + Bitmap bitmap = Bitmap.createBitmap(pixels, qrWidth, qrHeight, Bitmap.Config.ARGB_8888); + + stopwatch.split("Create bitmap"); + stopwatch.stop(TAG); + + return bitmap; + } catch (WriterException e) { + Log.w(TAG, e); + return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java new file mode 100644 index 00000000..83faae99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.qr; + +public interface ScanListener { + public void onQrDataFound(String data); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java new file mode 100644 index 00000000..d591a3ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.qr; + +import android.content.res.Configuration; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.zxing.BinaryBitmap; +import com.google.zxing.ChecksumException; +import com.google.zxing.DecodeHintType; +import com.google.zxing.FormatException; +import com.google.zxing.NotFoundException; +import com.google.zxing.PlanarYUVLuminanceSource; +import com.google.zxing.Result; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeReader; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.camera.CameraView; +import org.thoughtcrime.securesms.components.camera.CameraView.PreviewFrame; +import org.thoughtcrime.securesms.util.Util; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public class ScanningThread extends Thread implements CameraView.PreviewCallback { + + private static final String TAG = ScanningThread.class.getSimpleName(); + + private final QRCodeReader reader = new QRCodeReader(); + private final AtomicReference scanListener = new AtomicReference<>(); + private final Map hints = new HashMap<>(); + + private boolean scanning = true; + private PreviewFrame previewFrame; + + public void setCharacterSet(String characterSet) { + hints.put(DecodeHintType.CHARACTER_SET, characterSet); + } + + public void setScanListener(ScanListener scanListener) { + this.scanListener.set(scanListener); + } + + @Override + public void onPreviewFrame(@NonNull PreviewFrame previewFrame) { + try { + synchronized (this) { + this.previewFrame = previewFrame; + this.notify(); + } + } catch (RuntimeException e) { + Log.w(TAG, e); + } + } + + + @Override + public void run() { + while (true) { + PreviewFrame ourFrame; + + synchronized (this) { + while (scanning && previewFrame == null) { + Util.wait(this, 0); + } + + if (!scanning) return; + else ourFrame = previewFrame; + + previewFrame = null; + } + + String data = getScannedData(ourFrame.getData(), ourFrame.getWidth(), ourFrame.getHeight(), ourFrame.getOrientation()); + ScanListener scanListener = this.scanListener.get(); + + if (data != null && scanListener != null) { + scanListener.onQrDataFound(data); + return; + } + } + } + + public void stopScanning() { + synchronized (this) { + scanning = false; + notify(); + } + } + + private @Nullable String getScannedData(byte[] data, int width, int height, int orientation) { + try { + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + byte[] rotatedData = new byte[data.length]; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + rotatedData[x * height + height - y - 1] = data[x + y * width]; + } + } + + int tmp = width; + width = height; + height = tmp; + data = rotatedData; + } + + PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(data, width, height, + 0, 0, width, height, + false); + + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + Result result = reader.decode(bitmap, hints); + + if (result != null) return result.getText(); + + } catch (NullPointerException | ChecksumException | FormatException e) { + Log.w(TAG, e); + } catch (NotFoundException e) { + // Thanks ZXing... + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java new file mode 100644 index 00000000..4b1a14aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/EmojiCount.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.reactions; + +import androidx.annotation.NonNull; + +import java.util.List; + +final class EmojiCount { + + static EmojiCount all(@NonNull List reactions) { + return new EmojiCount("", "", reactions); + } + + private final String baseEmoji; + private final String displayEmoji; + private final List reactions; + + EmojiCount(@NonNull String baseEmoji, + @NonNull String emoji, + @NonNull List reactions) + { + this.baseEmoji = baseEmoji; + this.displayEmoji = emoji; + this.reactions = reactions; + } + + public @NonNull String getBaseEmoji() { + return baseEmoji; + } + + public @NonNull String getDisplayEmoji() { + return displayEmoji; + } + + public int getCount() { + return reactions.size(); + } + + public @NonNull List getReactions() { + return reactions; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.java new file mode 100644 index 00000000..8d8137c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionDetails.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.reactions; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class ReactionDetails { + private final Recipient sender; + private final String baseEmoji; + private final String displayEmoji; + private final long timestamp; + + ReactionDetails(@NonNull Recipient sender, @NonNull String baseEmoji, @NonNull String displayEmoji, long timestamp) { + this.sender = sender; + this.baseEmoji = baseEmoji; + this.displayEmoji = displayEmoji; + this.timestamp = timestamp; + } + + public @NonNull Recipient getSender() { + return sender; + } + + public @NonNull String getBaseEmoji() { + return baseEmoji; + } + + public @NonNull String getDisplayEmoji() { + return displayEmoji; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java new file mode 100644 index 00000000..7dfe4997 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.reactions; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.AvatarUtil; + +import java.util.Collections; +import java.util.List; + +final class ReactionRecipientsAdapter extends RecyclerView.Adapter { + + private List data = Collections.emptyList(); + + public void updateData(List newData) { + data = newData; + notifyDataSetChanged(); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.reactions_bottom_sheet_dialog_fragment_recipient_item, + parent, + false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + + static final class ViewHolder extends RecyclerView.ViewHolder { + + private final AvatarImageView avatar; + private final TextView recipient; + private final TextView emoji; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar); + recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name); + emoji = itemView.findViewById(R.id.reactions_bottom_view_recipient_emoji); + } + + void bind(@NonNull ReactionDetails reaction) { + this.emoji.setText(reaction.getDisplayEmoji()); + + if (reaction.getSender().isSelf()) { + this.recipient.setText(R.string.ReactionsRecipientAdapter_you); + this.avatar.setAvatar(GlideApp.with(avatar), null, false); + AvatarUtil.loadIconIntoImageView(reaction.getSender(), avatar); + } else { + this.recipient.setText(reaction.getSender().getDisplayName(itemView.getContext())); + this.avatar.setAvatar(GlideApp.with(avatar), reaction.getSender(), false); + } + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java new file mode 100644 index 00000000..ea8e62f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.reactions; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; + +import java.util.List; + +/** + * ReactionViewPagerAdapter provides pages to a ViewPager2 which contains the reactions on a given message. + */ +class ReactionViewPagerAdapter extends ListAdapter { + + private int selectedPosition = 0; + + protected ReactionViewPagerAdapter() { + super(new AlwaysChangedDiffUtil<>()); + } + + @NonNull EmojiCount getEmojiCount(int position) { + return getItem(position); + } + + void enableNestedScrollingForPosition(int position) { + selectedPosition = position; + + notifyItemRangeChanged(0, getItemCount(), new Object()); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_recycler, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List payloads) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position); + } else { + holder.setSelected(selectedPosition); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.onBind(getItem(position)); + holder.setSelected(selectedPosition); + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + recyclerView.setNestedScrollingEnabled(false); + ViewGroup.LayoutParams params = recyclerView.getLayoutParams(); + params.height = (int) (recyclerView.getResources().getDisplayMetrics().heightPixels * 0.80); + recyclerView.setLayoutParams(params); + recyclerView.setHasFixedSize(true); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final RecyclerView recycler; + private final ReactionRecipientsAdapter adapter = new ReactionRecipientsAdapter(); + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + recycler = (RecyclerView) itemView; + + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + + recycler.setLayoutParams(params); + recycler.setAdapter(adapter); + } + + public void onBind(@NonNull EmojiCount emojiCount) { + adapter.updateData(emojiCount.getReactions()); + } + + public void setSelected(int position) { + recycler.setNestedScrollingEnabled(getAdapterPosition() == position); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java new file mode 100644 index 00000000..2ba912e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.loader.app.LoaderManager; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; + +public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String ARGS_MESSAGE_ID = "reactions.args.message.id"; + private static final String ARGS_IS_MMS = "reactions.args.is.mms"; + + private long messageId; + private ViewPager2 recipientPagerView; + private ReactionsLoader reactionsLoader; + private ReactionViewPagerAdapter recipientsAdapter; + private ReactionsViewModel viewModel; + private Callback callback; + + public static DialogFragment create(long messageId, boolean isMms) { + Bundle args = new Bundle(); + DialogFragment fragment = new ReactionsBottomSheetDialogFragment(); + + args.putLong(ARGS_MESSAGE_ID, messageId); + args.putBoolean(ARGS_IS_MMS, isMms); + + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + callback = (Callback) context; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + + if (ThemeUtil.isDarkTheme(requireContext())) { + setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_BottomSheetDialog_Fixed_ReactWithAny); + } else { + setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_Light_BottomSheetDialog_Fixed_ReactWithAny); + } + + super.onCreate(savedInstanceState); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.reactions_bottom_sheet_dialog_fragment, container, false); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState == null) { + FrameLayout container = requireDialog().findViewById(R.id.container); + LayoutInflater layoutInflater = LayoutInflater.from(requireContext()); + View statusBarShader = layoutInflater.inflate(R.layout.react_with_any_emoji_status_fade, container, false); + TabLayout emojiTabs = (TabLayout) layoutInflater.inflate(R.layout.reactions_bottom_sheet_dialog_fragment_tabs, container, false); + + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtil.getStatusBarHeight(container)); + + statusBarShader.setLayoutParams(params); + + container.addView(statusBarShader, 0); + container.addView(emojiTabs); + + ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets()); + + new TabLayoutMediator(emojiTabs, recipientPagerView, (tab, position) -> { + tab.setCustomView(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item); + + View customView = Objects.requireNonNull(tab.getCustomView()); + EmojiImageView emoji = customView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji); + TextView text = customView.findViewById(R.id.reactions_bottom_view_emoji_item_text); + EmojiCount emojiCount = recipientsAdapter.getEmojiCount(position); + + if (position != 0) { + emoji.setVisibility(View.VISIBLE); + emoji.setImageEmoji(emojiCount.getDisplayEmoji()); + text.setText(String.valueOf(emojiCount.getCount())); + } else { + emoji.setVisibility(View.GONE); + text.setText(requireContext().getString(R.string.ReactionsBottomSheetDialogFragment_all, emojiCount.getCount())); + } + }).attach(); + } + + setUpViewModel(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + recipientPagerView = view.findViewById(R.id.reactions_bottom_view_recipient_pager); + messageId = requireArguments().getLong(ARGS_MESSAGE_ID); + + setUpRecipientsRecyclerView(); + + reactionsLoader = new ReactionsLoader(requireContext(), + requireArguments().getLong(ARGS_MESSAGE_ID), + requireArguments().getBoolean(ARGS_IS_MMS)); + + LoaderManager.getInstance(requireActivity()).initLoader((int) messageId, null, reactionsLoader); + } + + @Override + public void onDestroyView() { + LoaderManager.getInstance(requireActivity()).destroyLoader((int) messageId); + super.onDestroyView(); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + + callback.onReactionsDialogDismissed(); + } + + private void setUpRecipientsRecyclerView() { + recipientsAdapter = new ReactionViewPagerAdapter(); + + recipientPagerView.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + recipientPagerView.post(() -> { + recipientsAdapter.enableNestedScrollingForPosition(position); + }); + } + + @Override + public void onPageScrollStateChanged(int state) { + if (state == ViewPager2.SCROLL_STATE_IDLE) { + recipientPagerView.requestLayout(); + } + } + }); + + recipientPagerView.setAdapter(recipientsAdapter); + } + + private void setUpViewModel() { + ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(reactionsLoader); + + viewModel = ViewModelProviders.of(this, factory).get(ReactionsViewModel.class); + + viewModel.getEmojiCounts().observe(getViewLifecycleOwner(), emojiCounts -> { + if (emojiCounts.size() <= 1) dismiss(); + + recipientsAdapter.submitList(emojiCounts); + }); + } + + public interface Callback { + void onReactionsDialogDismissed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java new file mode 100644 index 00000000..6d2107d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java @@ -0,0 +1,215 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class ReactionsConversationView extends LinearLayout { + + // Normally 6dp, but we have 1dp left+right margin on the pills themselves + private static final int OUTER_MARGIN = ViewUtil.dpToPx(5); + + private boolean outgoing; + private List records; + private int bubbleWidth; + + public ReactionsConversationView(Context context) { + super(context); + init(null); + } + + public ReactionsConversationView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + records = new ArrayList<>(); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ReactionsConversationView, 0, 0); + outgoing = typedArray.getBoolean(R.styleable.ReactionsConversationView_rcv_outgoing, false); + } + } + + public void clear() { + this.records.clear(); + this.bubbleWidth = 0; + removeAllViews(); + } + + public void setReactions(@NonNull List records, int bubbleWidth) { + if (records.equals(this.records) && this.bubbleWidth == bubbleWidth) { + return; + } + + this.records.clear(); + this.records.addAll(records); + + this.bubbleWidth = bubbleWidth; + + List reactions = buildSortedReactionsList(records); + + removeAllViews(); + + for (Reaction reaction : reactions) { + View pill = buildPill(getContext(), this, reaction); + pill.setVisibility(bubbleWidth == 0 ? INVISIBLE : VISIBLE); + addView(pill); + } + + measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + + int railWidth = getMeasuredWidth(); + + if (railWidth < (bubbleWidth - OUTER_MARGIN)) { + int margin = (bubbleWidth - railWidth - OUTER_MARGIN); + + if (outgoing) { + ViewUtil.setRightMargin(this, margin); + } else { + ViewUtil.setLeftMargin(this, margin); + } + } else { + if (outgoing) { + ViewUtil.setRightMargin(this, OUTER_MARGIN); + } else { + ViewUtil.setLeftMargin(this, OUTER_MARGIN); + } + } + } + + private static @NonNull List buildSortedReactionsList(@NonNull List records) { + Map counters = new LinkedHashMap<>(); + RecipientId selfId = Recipient.self().getId(); + + for (ReactionRecord record : records) { + String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji()); + Reaction info = counters.get(baseEmoji); + + if (info == null) { + info = new Reaction(baseEmoji, record.getEmoji(), 1, record.getDateReceived(), selfId.equals(record.getAuthor())); + } else { + info.update(record.getEmoji(), record.getDateReceived(), selfId.equals(record.getAuthor())); + } + + counters.put(baseEmoji, info); + } + + List reactions = new ArrayList<>(counters.values()); + + Collections.sort(reactions, Collections.reverseOrder()); + + if (reactions.size() > 3) { + List shortened = new ArrayList<>(3); + shortened.add(reactions.get(0)); + shortened.add(reactions.get(1)); + shortened.add(Stream.of(reactions).skip(2).reduce(new Reaction(null, null, 0, 0, false), Reaction::merge)); + + return shortened; + } else { + return reactions; + } + } + + private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction) { + View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false); + TextView emojiView = root.findViewById(R.id.reactions_pill_emoji); + TextView countView = root.findViewById(R.id.reactions_pill_count); + View spacer = root.findViewById(R.id.reactions_pill_spacer); + + if (reaction.displayEmoji != null) { + emojiView.setText(reaction.displayEmoji); + + if (reaction.count > 1) { + countView.setText(String.valueOf(reaction.count)); + } else { + countView.setVisibility(GONE); + spacer.setVisibility(GONE); + } + } else { + emojiView.setVisibility(GONE); + spacer.setVisibility(GONE); + countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count)); + } + + if (reaction.userWasSender) { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); + countView.setTextColor(ContextCompat.getColor(context, R.color.reactions_pill_selected_text_color)); + } else { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)); + } + + return root; + } + + private static class Reaction implements Comparable { + private String baseEmoji; + private String displayEmoji; + private int count; + private long lastSeen; + private boolean userWasSender; + + Reaction(@Nullable String baseEmoji, @Nullable String displayEmoji, int count, long lastSeen, boolean userWasSender) { + this.baseEmoji = baseEmoji; + this.displayEmoji = displayEmoji; + this.count = count; + this.lastSeen = lastSeen; + this.userWasSender = userWasSender; + } + + void update(@NonNull String displayEmoji, long lastSeen, boolean userWasSender) { + if (!this.userWasSender) { + if (userWasSender || lastSeen > this.lastSeen) { + this.displayEmoji = displayEmoji; + } + } + + this.count = this.count + 1; + this.lastSeen = Math.max(this.lastSeen, lastSeen); + this.userWasSender = this.userWasSender || userWasSender; + } + + @NonNull Reaction merge(@NonNull Reaction other) { + this.count = this.count + other.count; + this.lastSeen = Math.max(this.lastSeen, other.lastSeen); + this.userWasSender = this.userWasSender || other.userWasSender; + return this; + } + + @Override + public int compareTo(Reaction rhs) { + Reaction lhs = this; + + if (lhs.count != rhs.count) { + return Integer.compare(lhs.count, rhs.count); + } + + return Long.compare(lhs.lastSeen, rhs.lastSeen); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsLoader.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsLoader.java new file mode 100644 index 00000000..9a1ef7e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsLoader.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AbstractCursorLoader; + +import java.util.Collections; +import java.util.List; + +public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderManager.LoaderCallbacks { + + private final long messageId; + private final boolean isMms; + private final Context appContext; + + private MutableLiveData> internalLiveData = new MutableLiveData<>(); + + public ReactionsLoader(@NonNull Context context, long messageId, boolean isMms) + { + this.messageId = messageId; + this.isMms = isMms; + this.appContext = context.getApplicationContext(); + } + + @Override + public @NonNull Loader onCreateLoader(int id, @Nullable Bundle args) { + return isMms ? new MmsMessageRecordCursorLoader(appContext, messageId) + : new SmsMessageRecordCursorLoader(appContext, messageId); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor data) { + SignalExecutors.BOUNDED.execute(() -> { + data.moveToPosition(-1); + + MessageRecord record = isMms ? MmsDatabase.readerFor(data).getNext() + : SmsDatabase.readerFor(data).getNext(); + + if (record == null) { + internalLiveData.postValue(Collections.emptyList()); + } else { + internalLiveData.postValue(Stream.of(record.getReactions()) + .map(reactionRecord -> new ReactionDetails(Recipient.resolved(reactionRecord.getAuthor()), + EmojiUtil.getCanonicalRepresentation(reactionRecord.getEmoji()), + reactionRecord.getEmoji(), + reactionRecord.getDateReceived())) + .toList()); + } + }); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + // Do nothing? + } + + @Override + public LiveData> getReactions() { + return internalLiveData; + } + + private static final class MmsMessageRecordCursorLoader extends AbstractCursorLoader { + + private final long messageId; + + public MmsMessageRecordCursorLoader(@NonNull Context context, long messageId) { + super(context); + this.messageId = messageId; + } + + @Override + public Cursor getCursor() { + return DatabaseFactory.getMmsDatabase(context).getMessageCursor(messageId); + } + } + + private static final class SmsMessageRecordCursorLoader extends AbstractCursorLoader { + + private final long messageId; + + public SmsMessageRecordCursorLoader(@NonNull Context context, long messageId) { + super(context); + this.messageId = messageId; + } + + @Override + public Cursor getCursor() { + return DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageId); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java new file mode 100644 index 00000000..90e9eaa4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController; + +public class ReactionsMegaphoneView extends FrameLayout { + + private View closeButton; + + public ReactionsMegaphoneView(Context context) { + super(context); + initialize(context); + } + + public ReactionsMegaphoneView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + private void initialize(@NonNull Context context) { + inflate(context, R.layout.reactions_megaphone, this); + + this.closeButton = findViewById(R.id.reactions_megaphone_x); + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController listener) { + this.closeButton.setOnClickListener(v -> listener.onMegaphoneCompleted(megaphone.getEvent())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java new file mode 100644 index 00000000..d13a0c31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.reactions; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import java.util.List; +import java.util.Map; + +public class ReactionsViewModel extends ViewModel { + + private final Repository repository; + + public ReactionsViewModel(@NonNull Repository repository) { + this.repository = repository; + } + + public @NonNull LiveData> getEmojiCounts() { + return Transformations.map(repository.getReactions(), + reactionList -> { + List emojiCounts = Stream.of(reactionList) + .groupBy(ReactionDetails::getBaseEmoji) + .sorted(this::compareReactions) + .map(entry -> new EmojiCount(entry.getKey(), + getCountDisplayEmoji(entry.getValue()), + entry.getValue())) + .toList(); + + emojiCounts.add(0, EmojiCount.all(reactionList)); + + return emojiCounts; + }); + } + + private int compareReactions(@NonNull Map.Entry> lhs, @NonNull Map.Entry> rhs) { + int lengthComparison = -Integer.compare(lhs.getValue().size(), rhs.getValue().size()); + if (lengthComparison != 0) return lengthComparison; + + long latestTimestampLhs = getLatestTimestamp(lhs.getValue()); + long latestTimestampRhs = getLatestTimestamp(rhs.getValue()); + + return -Long.compare(latestTimestampLhs, latestTimestampRhs); + } + + private long getLatestTimestamp(List reactions) { + return Stream.of(reactions) + .max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp())) + .map(ReactionDetails::getTimestamp) + .orElse(-1L); + } + + private @NonNull String getCountDisplayEmoji(@NonNull List reactions) { + for (ReactionDetails reaction : reactions) { + if (reaction.getSender().isSelf()) { + return reaction.getDisplayEmoji(); + } + } + + return reactions.get(reactions.size() - 1).getDisplayEmoji(); + } + + interface Repository { + LiveData> getReactions(); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final Repository repository; + + Factory(@NonNull Repository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return (T) new ReactionsViewModel(repository); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java new file mode 100644 index 00000000..2343f982 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiAdapter.java @@ -0,0 +1,181 @@ +package org.thoughtcrime.securesms.reactions.any; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.widget.NestedScrollView; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; +import org.thoughtcrime.securesms.components.emoji.EmojiPageView; +import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter; + +final class ReactWithAnyEmojiAdapter extends ListAdapter { + + private static final int VIEW_TYPE_SINGLE = 0; + private static final int VIEW_TYPE_DUAL = 1; + + private final EmojiKeyboardProvider.EmojiEventListener emojiEventListener; + private final EmojiPageViewGridAdapter.VariationSelectorListener variationSelectorListener; + private final Callbacks callbacks; + + ReactWithAnyEmojiAdapter(@NonNull EmojiKeyboardProvider.EmojiEventListener emojiEventListener, + @NonNull EmojiPageViewGridAdapter.VariationSelectorListener variationSelectorListener, + @NonNull Callbacks callbacks) + { + super(new PageChangedCallback()); + + this.emojiEventListener = emojiEventListener; + this.variationSelectorListener = variationSelectorListener; + this.callbacks = callbacks; + } + + public ReactWithAnyEmojiPage getItem(int position) { + return super.getItem(position); + } + + @Override + public @NonNull ReactWithAnyEmojiPageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_SINGLE: + return new SinglePageBlockViewHolder(createEmojiPageView(parent.getContext())); + case VIEW_TYPE_DUAL: + EmojiPageView block1 = createEmojiPageView(parent.getContext()); + EmojiPageView block2 = createEmojiPageView(parent.getContext()); + NestedScrollView scrollView = (NestedScrollView) LayoutInflater.from(parent.getContext()).inflate(R.layout.react_with_any_emoji_dual_block_item, parent, false); + LinearLayout container = scrollView.findViewById(R.id.react_with_any_emoji_dual_block_item_container); + + block1.setRecyclerNestedScrollingEnabled(false); + block2.setRecyclerNestedScrollingEnabled(false); + + container.addView(block1, 0); + container.addView(block2); + + return new DualPageBlockViewHolder(scrollView, block1, block2); + default: + throw new IllegalArgumentException("Unknown viewType: " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull ReactWithAnyEmojiPageViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + @Override + public void onViewAttachedToWindow(@NonNull ReactWithAnyEmojiPageViewHolder holder) { + callbacks.onViewHolderAttached(holder.getAdapterPosition(), holder); + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + recyclerView.setNestedScrollingEnabled(false); + ViewGroup.LayoutParams params = recyclerView.getLayoutParams(); + params.height = (int) (recyclerView.getResources().getDisplayMetrics().heightPixels * 0.80); + recyclerView.setLayoutParams(params); + recyclerView.setHasFixedSize(true); + } + + @Override + public int getItemViewType(int position) { + return getItem(position).getPageBlocks().size() > 1 ? VIEW_TYPE_DUAL : VIEW_TYPE_SINGLE; + } + + private EmojiPageView createEmojiPageView(@NonNull Context context) { + return new EmojiPageView(context, emojiEventListener, variationSelectorListener, true); + } + + static abstract class ReactWithAnyEmojiPageViewHolder extends RecyclerView.ViewHolder implements ScrollableChild { + + public ReactWithAnyEmojiPageViewHolder(@NonNull View itemView) { + super(itemView); + } + + abstract void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage); + } + + static final class SinglePageBlockViewHolder extends ReactWithAnyEmojiPageViewHolder { + + private final EmojiPageView emojiPageView; + + public SinglePageBlockViewHolder(@NonNull View itemView) { + super(itemView); + + emojiPageView = (EmojiPageView) itemView; + + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + + emojiPageView.setLayoutParams(params); + } + + @Override + void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage) { + emojiPageView.setModel(reactWithAnyEmojiPage.getPageBlocks().get(0).getPageModel()); + } + + @Override + public void setNestedScrollingEnabled(boolean isEnabled) { + emojiPageView.setRecyclerNestedScrollingEnabled(isEnabled); + } + } + + static final class DualPageBlockViewHolder extends ReactWithAnyEmojiPageViewHolder { + + private final EmojiPageView block1; + private final EmojiPageView block2; + private final TextView block2Label; + + public DualPageBlockViewHolder(@NonNull View itemView, + @NonNull EmojiPageView block1, + @NonNull EmojiPageView block2) + { + super(itemView); + + this.block1 = block1; + this.block2 = block2; + this.block2Label = itemView.findViewById(R.id.react_with_any_emoji_dual_block_item_block_2_label); + } + + @Override + void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage) { + block1.setModel(reactWithAnyEmojiPage.getPageBlocks().get(0).getPageModel()); + block2.setModel(reactWithAnyEmojiPage.getPageBlocks().get(1).getPageModel()); + block2Label.setText(reactWithAnyEmojiPage.getPageBlocks().get(1).getLabel()); + } + + @Override + public void setNestedScrollingEnabled(boolean isEnabled) { + ((NestedScrollView) itemView).setNestedScrollingEnabled(isEnabled); + } + } + + interface Callbacks { + void onViewHolderAttached(int adapterPosition, ScrollableChild pageView); + } + + interface ScrollableChild { + void setNestedScrollingEnabled(boolean isEnabled); + } + + private static class PageChangedCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull ReactWithAnyEmojiPage oldItem, @NonNull ReactWithAnyEmojiPage newItem) { + return oldItem.getLabel() == newItem.getLabel(); + } + + @Override + public boolean areContentsTheSame(@NonNull ReactWithAnyEmojiPage oldItem, @NonNull ReactWithAnyEmojiPage newItem) { + return oldItem.equals(newItem); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java new file mode 100644 index 00000000..518a580f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java @@ -0,0 +1,287 @@ +package org.thoughtcrime.securesms.reactions.any; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.util.SparseArray; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextSwitcher; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.loader.app.LoaderManager; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.MaterialShapeDrawable; +import com.google.android.material.shape.ShapeAppearanceModel; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; +import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.reactions.ReactionsLoader; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import static org.thoughtcrime.securesms.R.layout.react_with_any_emoji_tab; + +public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomSheetDialogFragment + implements EmojiKeyboardProvider.EmojiEventListener, + EmojiPageViewGridAdapter.VariationSelectorListener +{ + + private static final String REACTION_STORAGE_KEY = "reactions_recent_emoji"; + private static final String ABOUT_STORAGE_KEY = EmojiKeyboardProvider.RECENT_STORAGE_KEY; + + private static final String ARG_MESSAGE_ID = "arg_message_id"; + private static final String ARG_IS_MMS = "arg_is_mms"; + private static final String ARG_START_PAGE = "arg_start_page"; + private static final String ARG_SHADOWS = "arg_shadows"; + private static final String ARG_RECENT_KEY = "arg_recent_key"; + + private ReactWithAnyEmojiViewModel viewModel; + private TextSwitcher categoryLabel; + private ViewPager2 categoryPager; + private ReactWithAnyEmojiAdapter adapter; + private OnPageChanged onPageChanged; + private SparseArray pageArray = new SparseArray<>(); + private Callback callback; + private ReactionsLoader reactionsLoader; + + public static DialogFragment createForMessageRecord(@NonNull MessageRecord messageRecord, int startingPage) { + DialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment(); + Bundle args = new Bundle(); + + args.putLong(ARG_MESSAGE_ID, messageRecord.getId()); + args.putBoolean(ARG_IS_MMS, messageRecord.isMms()); + args.putInt(ARG_START_PAGE, startingPage); + args.putBoolean(ARG_SHADOWS, false); + args.putString(ARG_RECENT_KEY, REACTION_STORAGE_KEY); + fragment.setArguments(args); + + return fragment; + } + + public static DialogFragment createForAboutSelection() { + DialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment(); + Bundle args = new Bundle(); + + args.putLong(ARG_MESSAGE_ID, -1); + args.putBoolean(ARG_IS_MMS, false); + args.putInt(ARG_START_PAGE, -1); + args.putBoolean(ARG_SHADOWS, true); + args.putString(ARG_RECENT_KEY, ABOUT_STORAGE_KEY); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + callback = (Callback) context; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + boolean shadows = requireArguments().getBoolean(ARG_SHADOWS); + if (ThemeUtil.isDarkTheme(requireContext())) { + setStyle(DialogFragment.STYLE_NORMAL, shadows ? R.style.Theme_Signal_BottomSheetDialog_Fixed_ReactWithAny + : R.style.Theme_Signal_BottomSheetDialog_Fixed_ReactWithAny_Shadowless); + } else { + setStyle(DialogFragment.STYLE_NORMAL, shadows ? R.style.Theme_Signal_Light_BottomSheetDialog_Fixed_ReactWithAny + : R.style.Theme_Signal_Light_BottomSheetDialog_Fixed_ReactWithAny_Shadowless); + } + + super.onCreate(savedInstanceState); + } + + @Override + public @NonNull Dialog onCreateDialog(Bundle savedInstanceState) { + BottomSheetDialog dialog = (BottomSheetDialog) super.onCreateDialog(savedInstanceState); + ShapeAppearanceModel shapeAppearanceModel = ShapeAppearanceModel.builder() + .setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 8)) + .setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 8)) + .build(); + MaterialShapeDrawable dialogBackground = new MaterialShapeDrawable(shapeAppearanceModel); + + dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog)); + + dialog.getBehavior().addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + ViewCompat.setBackground(bottomSheet, dialogBackground); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + } + }); + + return dialog; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.react_with_any_emoji_bottom_sheet_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + reactionsLoader = new ReactionsLoader(requireContext(), + requireArguments().getLong(ARG_MESSAGE_ID), + requireArguments().getBoolean(ARG_IS_MMS)); + + LoaderManager.getInstance(requireActivity()).initLoader((int) requireArguments().getLong(ARG_MESSAGE_ID), null, reactionsLoader); + + initializeViewModel(); + + categoryLabel = view.findViewById(R.id.category_label); + categoryPager = view.findViewById(R.id.category_pager); + + adapter = new ReactWithAnyEmojiAdapter(this, this, (position, pageView) -> { + pageArray.put(position, pageView); + + if (categoryPager.getCurrentItem() == position) { + updateFocusedRecycler(position); + } + }); + + onPageChanged = new OnPageChanged(); + + categoryPager.setAdapter(adapter); + categoryPager.registerOnPageChangeCallback(onPageChanged); + + viewModel.getEmojiPageModels().observe(getViewLifecycleOwner(), pages -> { + int pageToSet = adapter.getItemCount() == 0 ? getStartingPage((pages.get(0).hasEmoji())) + : -1; + + adapter.submitList(pages); + + if (pageToSet >= 0) { + categoryPager.setCurrentItem(pageToSet); + } + }); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState == null) { + FrameLayout container = requireDialog().findViewById(R.id.container); + LayoutInflater layoutInflater = LayoutInflater.from(requireContext()); + TabLayout categoryTabs = (TabLayout) layoutInflater.inflate(R.layout.react_with_any_emoji_tabs, container, false); + + + if (!requireArguments().getBoolean(ARG_SHADOWS)) { + View statusBarShader = layoutInflater.inflate(R.layout.react_with_any_emoji_status_fade, container, false); + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtil.getStatusBarHeight(container)); + + statusBarShader.setLayoutParams(params); + container.addView(statusBarShader, 0); + } + + container.addView(categoryTabs); + ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets()); + + new TabLayoutMediator(categoryTabs, categoryPager, (tab, position) -> { + tab.setCustomView(react_with_any_emoji_tab) + .setIcon(ThemeUtil.getThemedDrawable(requireContext(), adapter.getItem(position).getIconAttr())); + }).attach(); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + LoaderManager.getInstance(requireActivity()).destroyLoader((int) requireArguments().getLong(ARG_MESSAGE_ID)); + + categoryPager.unregisterOnPageChangeCallback(onPageChanged); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + + callback.onReactWithAnyEmojiDialogDismissed(); + } + + private void initializeViewModel() { + Bundle args = requireArguments(); + ReactWithAnyEmojiRepository repository = new ReactWithAnyEmojiRepository(requireContext(), args.getString(ARG_RECENT_KEY)); + ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(reactionsLoader, repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS)); + + viewModel = ViewModelProviders.of(this, factory).get(ReactWithAnyEmojiViewModel.class); + } + + @Override + public void onEmojiSelected(String emoji) { + viewModel.onEmojiSelected(emoji); + callback.onReactWithAnyEmojiSelected(emoji); + dismiss(); + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + } + + @Override + public void onVariationSelectorStateChanged(boolean open) { + categoryPager.setUserInputEnabled(!open); + } + + private void updateFocusedRecycler(int position) { + for (int i = 0; i < pageArray.size(); i++) { + pageArray.valueAt(i).setNestedScrollingEnabled(false); + } + + ReactWithAnyEmojiAdapter.ScrollableChild toFocus = pageArray.get(position); + if (toFocus != null) { + toFocus.setNestedScrollingEnabled(true); + categoryPager.requestLayout(); + } + + categoryLabel.setText(getString(adapter.getItem(position).getLabel())); + } + + private int getStartingPage(boolean firstPageHasContent) { + int startPage = requireArguments().getInt(ARG_START_PAGE); + return startPage >= 0 ? startPage : (firstPageHasContent ? 0 : 1); + } + + private class OnPageChanged extends ViewPager2.OnPageChangeCallback { + @Override + public void onPageSelected(int position) { + updateFocusedRecycler(position); + callback.onReactWithAnyEmojiPageChanged(position); + } + } + + public interface Callback { + void onReactWithAnyEmojiDialogDismissed(); + void onReactWithAnyEmojiPageChanged(int page); + void onReactWithAnyEmojiSelected(@NonNull String emoji); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPage.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPage.java new file mode 100644 index 00000000..881b61f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPage.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.reactions.any; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.List; +import java.util.Objects; + +/** + * Represents a swipeable page in the ReactWithAnyEmoji dialog fragment, encapsulating any + * {@link ReactWithAnyEmojiPageBlock}s contained on that page. It is assumed that there is at least + * one page present. + * + * This class also exposes several properties based off of that list, in order to allow the ReactWithAny + * bottom sheet to properly lay out its tabs and assign labels as the user moves between pages. + */ +class ReactWithAnyEmojiPage { + + private final List pageBlocks; + + ReactWithAnyEmojiPage(@NonNull List pageBlocks) { + Preconditions.checkArgument(!pageBlocks.isEmpty()); + + this.pageBlocks = pageBlocks; + } + + public @StringRes int getLabel() { + return pageBlocks.get(0).getLabel(); + } + + public boolean hasEmoji() { + return !pageBlocks.get(0).getPageModel().getEmoji().isEmpty(); + } + + public List getPageBlocks() { + return pageBlocks; + } + + public @AttrRes int getIconAttr() { + return pageBlocks.get(0).getPageModel().getIconAttr(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReactWithAnyEmojiPage that = (ReactWithAnyEmojiPage) o; + return pageBlocks.equals(that.pageBlocks); + } + + @Override + public int hashCode() { + return Objects.hash(pageBlocks); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPageBlock.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPageBlock.java new file mode 100644 index 00000000..d3099d1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiPageBlock.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.reactions.any; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.components.emoji.EmojiPageModel; + +import java.util.Objects; + +/** + * Wraps a single "class" of Emojis, be it a predefined category, recents, etc. and provides + * a label for that "class". + */ +class ReactWithAnyEmojiPageBlock { + + private final int label; + private final EmojiPageModel pageModel; + + ReactWithAnyEmojiPageBlock(@StringRes int label, @NonNull EmojiPageModel pageModel) { + this.label = label; + this.pageModel = pageModel; + } + + public @StringRes int getLabel() { + return label; + } + + public EmojiPageModel getPageModel() { + return pageModel; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReactWithAnyEmojiPageBlock that = (ReactWithAnyEmojiPageBlock) o; + return label == that.label && + pageModel.getIconAttr() == that.pageModel.getIconAttr() && + Objects.equals(pageModel.getEmoji(), that.pageModel.getEmoji()); + } + + @Override + public int hashCode() { + return Objects.hash(label, pageModel.getEmoji()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java new file mode 100644 index 00000000..5d9b0e4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.reactions.any; + +import android.content.Context; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.reactions.ReactionDetails; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +final class ReactWithAnyEmojiRepository { + + private static final String TAG = Log.tag(ReactWithAnyEmojiRepository.class); + + private final Context context; + private final RecentEmojiPageModel recentEmojiPageModel; + private final List emojiPages; + + ReactWithAnyEmojiRepository(@NonNull Context context, @NonNull String storageKey) { + this.context = context; + this.recentEmojiPageModel = new RecentEmojiPageModel(context, storageKey); + this.emojiPages = new LinkedList<>(); + + emojiPages.addAll(Stream.of(EmojiUtil.getDisplayPages()) + .map(page -> new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(getCategoryLabel(page.getIconAttr()), page)))) + .toList()); + emojiPages.remove(emojiPages.size() - 1); + } + + List getEmojiPageModels(@NonNull List thisMessagesReactions) { + List pages = new LinkedList<>(); + List thisMessage = Stream.of(thisMessagesReactions) + .map(ReactionDetails::getDisplayEmoji) + .distinct() + .toList(); + + if (thisMessage.isEmpty()) { + pages.add(new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used, recentEmojiPageModel)))); + } else { + pages.add(new ReactWithAnyEmojiPage(Arrays.asList(new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__this_message, new ThisMessageEmojiPageModel(thisMessage)), + new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used, recentEmojiPageModel)))); + } + + pages.addAll(emojiPages); + + return pages; + } + + void addEmojiToMessage(@NonNull String emoji, long messageId, boolean isMms) { + SignalExecutors.BOUNDED.execute(() -> { + try { + MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + MessageRecord messageRecord = db.getMessageRecord(messageId); + ReactionRecord oldRecord = Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor().equals(Recipient.self().getId())) + .findFirst() + .orElse(null); + + if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) { + MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord); + } else { + MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji); + Util.runOnMain(() -> recentEmojiPageModel.onCodePointSelected(emoji)); + } + } catch (NoSuchMessageException e) { + Log.w(TAG, "Message not found! Ignoring."); + } + }); + } + + private @StringRes int getCategoryLabel(@AttrRes int iconAttr) { + switch (iconAttr) { + case R.attr.emoji_category_people: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people; + case R.attr.emoji_category_nature: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature; + case R.attr.emoji_category_foods: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food; + case R.attr.emoji_category_activity: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities; + case R.attr.emoji_category_places: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places; + case R.attr.emoji_category_objects: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects; + case R.attr.emoji_category_symbols: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols; + case R.attr.emoji_category_flags: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags; + case R.attr.emoji_category_emoticons: + return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons; + default: + throw new AssertionError(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java new file mode 100644 index 00000000..013500ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.reactions.any; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.reactions.ReactionsLoader; + +import java.util.List; + +public final class ReactWithAnyEmojiViewModel extends ViewModel { + + private final ReactionsLoader reactionsLoader; + private final ReactWithAnyEmojiRepository repository; + private final long messageId; + private final boolean isMms; + + private final LiveData> pages; + + private ReactWithAnyEmojiViewModel(@NonNull ReactionsLoader reactionsLoader, + @NonNull ReactWithAnyEmojiRepository repository, + long messageId, + boolean isMms) { + this.reactionsLoader = reactionsLoader; + this.repository = repository; + this.messageId = messageId; + this.isMms = isMms; + this.pages = Transformations.map(reactionsLoader.getReactions(), repository::getEmojiPageModels); + } + + LiveData> getEmojiPageModels() { + return pages; + } + + void onEmojiSelected(@NonNull String emoji) { + if (messageId > 0) { + SignalStore.emojiValues().setPreferredVariation(emoji); + repository.addEmojiToMessage(emoji, messageId, isMms); + } + } + + static class Factory implements ViewModelProvider.Factory { + + private final ReactionsLoader reactionsLoader; + private final ReactWithAnyEmojiRepository repository; + private final long messageId; + private final boolean isMms; + + Factory(@NonNull ReactionsLoader reactionsLoader, @NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) { + this.reactionsLoader = reactionsLoader; + this.repository = repository; + this.messageId = messageId; + this.isMms = isMms; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ReactWithAnyEmojiViewModel(reactionsLoader, repository, messageId, isMms)); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java new file mode 100644 index 00000000..cf9bc69c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.reactions.any; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.Emoji; +import org.thoughtcrime.securesms.components.emoji.EmojiPageModel; + +import java.util.List; + +/** + * Contains the Emojis that have been used in reactions for a given message. + */ +class ThisMessageEmojiPageModel implements EmojiPageModel { + + private final List emoji; + + ThisMessageEmojiPageModel(@NonNull List emoji) { + this.emoji = emoji; + } + + @Override + public int getIconAttr() { + return R.attr.emoji_category_recent; + } + + @Override + public @NonNull List getEmoji() { + return emoji; + } + + @Override + public @NonNull List getDisplayEmoji() { + return Stream.of(getEmoji()).map(Emoji::new).toList(); + } + + @Override + public boolean hasSpriteMap() { + return false; + } + + @Override + public @Nullable String getSprite() { + return null; + } + + @Override + public boolean isDynamic() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java new file mode 100644 index 00000000..e015377d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -0,0 +1,238 @@ +package org.thoughtcrime.securesms.recipients; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicReference; + +public final class LiveRecipient { + + private static final String TAG = Log.tag(LiveRecipient.class); + + private final Context context; + private final MutableLiveData liveData; + private final LiveData observableLiveData; + private final LiveData observableLiveDataResolved; + private final Set observers; + private final Observer foreverObserver; + private final AtomicReference recipient; + private final RecipientDatabase recipientDatabase; + private final GroupDatabase groupDatabase; + private final MutableLiveData refreshForceNotify; + + LiveRecipient(@NonNull Context context, @NonNull Recipient defaultRecipient) { + this.context = context.getApplicationContext(); + this.liveData = new MutableLiveData<>(defaultRecipient); + this.recipient = new AtomicReference<>(defaultRecipient); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.observers = new CopyOnWriteArraySet<>(); + this.foreverObserver = recipient -> { + for (RecipientForeverObserver o : observers) { + o.onRecipientChanged(recipient); + } + }; + this.refreshForceNotify = new MutableLiveData<>(new Object()); + this.observableLiveData = LiveDataUtil.combineLatest(LiveDataUtil.distinctUntilChanged(liveData, Recipient::hasSameContent), + refreshForceNotify, + (recipient, force) -> recipient); + this.observableLiveDataResolved = LiveDataUtil.filter(this.observableLiveData, r -> !r.isResolving()); + } + + public @NonNull RecipientId getId() { + return recipient.get().getId(); + } + + /** + * @return A recipient that may or may not be fully-resolved. + */ + public @NonNull Recipient get() { + return recipient.get(); + } + + /** + * Watch the recipient for changes. The callback will only be invoked if the provided lifecycle is + * in a valid state. No need to remove the observer. If you do wish to remove the observer (if, + * for instance, you wish to remove the listener before the end of the owner's lifecycle), you can + * use {@link #removeObservers(LifecycleOwner)}. + */ + public void observe(@NonNull LifecycleOwner owner, @NonNull Observer observer) { + Util.postToMain(() -> observableLiveData.observe(owner, observer)); + } + + /** + * Removes all observers of this data registered for the given LifecycleOwner. + */ + public void removeObservers(@NonNull LifecycleOwner owner) { + Util.runOnMain(() -> observableLiveData.removeObservers(owner)); + } + + /** + * Watch the recipient for changes. The callback could be invoked at any time. You MUST call + * {@link #removeForeverObserver(RecipientForeverObserver)} when finished. You should use + * {@link #observe(LifecycleOwner, Observer)} if possible, as it is lifecycle-safe. + */ + public void observeForever(@NonNull RecipientForeverObserver observer) { + Util.postToMain(() -> { + if (observers.isEmpty()) { + observableLiveData.observeForever(foreverObserver); + } + observers.add(observer); + }); + } + + /** + * Unsubscribes the provided {@link RecipientForeverObserver} from future changes. + */ + public void removeForeverObserver(@NonNull RecipientForeverObserver observer) { + Util.postToMain(() -> { + observers.remove(observer); + + if (observers.isEmpty()) { + observableLiveData.removeObserver(foreverObserver); + } + }); + } + + /** + * @return A fully-resolved version of the recipient. May require reading from disk. + */ + @WorkerThread + public @NonNull Recipient resolve() { + Recipient current = recipient.get(); + + if (!current.isResolving() || current.getId().isUnknown()) { + return current; + } + + if (Util.isMainThread()) { + Log.w(TAG, "[Resolve][MAIN] " + getId(), new Throwable()); + } + + Recipient updated = fetchAndCacheRecipientFromDisk(getId()); + List participants = Stream.of(updated.getParticipants()) + .filter(Recipient::isResolving) + .map(Recipient::getId) + .map(this::fetchAndCacheRecipientFromDisk) + .toList(); + + for (Recipient participant : participants) { + participant.live().set(participant); + } + + set(updated); + + return updated; + } + + @WorkerThread + public void refresh() { + refresh(getId()); + } + + /** + * Forces a reload of the underlying recipient. + */ + @WorkerThread + public void refresh(@NonNull RecipientId id) { + if (!getId().equals(id)) { + Log.w(TAG, "Switching ID from " + getId() + " to " + id); + } + + if (getId().isUnknown()) return; + + if (Util.isMainThread()) { + Log.w(TAG, "[Refresh][MAIN] " + id, new Throwable()); + } + + Recipient recipient = fetchAndCacheRecipientFromDisk(id); + List participants = Stream.of(recipient.getParticipants()) + .map(Recipient::getId) + .map(this::fetchAndCacheRecipientFromDisk) + .toList(); + + for (Recipient participant : participants) { + participant.live().set(participant); + } + + set(recipient); + refreshForceNotify.postValue(new Object()); + } + + public @NonNull LiveData getLiveData() { + return observableLiveData; + } + + public @NonNull LiveData getLiveDataResolved() { + return observableLiveDataResolved; + } + + private @NonNull Recipient fetchAndCacheRecipientFromDisk(@NonNull RecipientId id) { + RecipientSettings settings = recipientDatabase.getRecipientSettings(id); + RecipientDetails details = settings.getGroupId() != null ? getGroupRecipientDetails(settings) + : RecipientDetails.forIndividual(context, settings); + + Recipient recipient = new Recipient(id, details, true); + RecipientIdCache.INSTANCE.put(recipient); + return recipient; + } + + @WorkerThread + private @NonNull RecipientDetails getGroupRecipientDetails(@NonNull RecipientSettings settings) { + Optional groupRecord = groupDatabase.getGroup(settings.getId()); + + if (groupRecord.isPresent()) { + String title = groupRecord.get().getTitle(); + List members = Stream.of(groupRecord.get().getMembers()).filterNot(RecipientId::isUnknown).map(this::fetchAndCacheRecipientFromDisk).toList(); + Optional avatarId = Optional.absent(); + + if (groupRecord.get().hasAvatar()) { + avatarId = Optional.of(groupRecord.get().getAvatarId()); + } + + return new RecipientDetails(title, avatarId, false, false, settings, members); + } + + return new RecipientDetails(null, Optional.absent(), false, false, settings, null); + } + + synchronized void set(@NonNull Recipient recipient) { + this.recipient.set(recipient); + this.liveData.postValue(recipient); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LiveRecipient that = (LiveRecipient) o; + return recipient.equals(that.recipient); + } + + @Override + public int hashCode() { + return Objects.hash(recipient); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipientCache.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipientCache.java new file mode 100644 index 00000000..39f71cbf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipientCache.java @@ -0,0 +1,172 @@ +package org.thoughtcrime.securesms.recipients; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.MissingRecipientException; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public final class LiveRecipientCache { + + private static final String TAG = Log.tag(LiveRecipientCache.class); + + private static final int CACHE_MAX = 1000; + private static final int CACHE_WARM_MAX = 500; + + private static final Object SELF_LOCK = new Object(); + + private final Context context; + private final RecipientDatabase recipientDatabase; + private final Map recipients; + private final LiveRecipient unknown; + + @GuardedBy("SELF_LOCK") + private RecipientId localRecipientId; + private boolean warmedUp; + + @SuppressLint("UseSparseArrays") + public LiveRecipientCache(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.recipients = new LRUCache<>(CACHE_MAX); + this.unknown = new LiveRecipient(context, Recipient.UNKNOWN); + } + + @AnyThread + synchronized @NonNull LiveRecipient getLive(@NonNull RecipientId id) { + if (id.isUnknown()) return unknown; + + LiveRecipient live = recipients.get(id); + + if (live == null) { + final LiveRecipient newLive = new LiveRecipient(context, new Recipient(id)); + + recipients.put(id, newLive); + + MissingRecipientException prettyStackTraceError = new MissingRecipientException(newLive.getId()); + + SignalExecutors.BOUNDED.execute(() -> { + try { + newLive.resolve(); + } catch (MissingRecipientException e) { + throw prettyStackTraceError; + } + }); + + live = newLive; + } + + return live; + } + + /** + * Adds a recipient to the cache if we don't have an entry. This will also update a cache entry + * if the provided recipient is resolved, or if the existing cache entry is unresolved. + * + * If the recipient you add is unresolved, this will enqueue a resolve on a background thread. + */ + @AnyThread + public synchronized void addToCache(@NonNull Collection newRecipients) { + for (Recipient recipient : newRecipients) { + LiveRecipient live = recipients.get(recipient.getId()); + boolean needsResolve = false; + + if (live == null) { + live = new LiveRecipient(context, recipient); + recipients.put(recipient.getId(), live); + needsResolve = recipient.isResolving(); + } else if (live.get().isResolving() || !recipient.isResolving()) { + live.set(recipient); + needsResolve = recipient.isResolving(); + } + + if (needsResolve) { + MissingRecipientException prettyStackTraceError = new MissingRecipientException(recipient.getId()); + SignalExecutors.BOUNDED.execute(() -> { + try { + recipient.resolve(); + } catch (MissingRecipientException e) { + throw prettyStackTraceError; + } + }); + } + } + } + + @NonNull Recipient getSelf() { + synchronized (SELF_LOCK) { + if (localRecipientId == null) { + UUID localUuid = TextSecurePreferences.getLocalUuid(context); + String localE164 = TextSecurePreferences.getLocalNumber(context); + + if (localUuid != null) { + localRecipientId = recipientDatabase.getByUuid(localUuid).or(recipientDatabase.getByE164(localE164)).orNull(); + } else if (localE164 != null) { + localRecipientId = recipientDatabase.getByE164(localE164).orNull(); + } else { + throw new IllegalStateException("Tried to call getSelf() before local data was set!"); + } + + if (localRecipientId == null) { + throw new MissingRecipientException(localRecipientId); + } + } + } + + return getLive(localRecipientId).resolve(); + } + + @AnyThread + public synchronized void warmUp() { + if (warmedUp) { + return; + } else { + warmedUp = true; + } + + SignalExecutors.BOUNDED.execute(() -> { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + List recipients = new ArrayList<>(); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(CACHE_WARM_MAX, false, false))) { + int i = 0; + ThreadRecord record = null; + + while ((record = reader.getNext()) != null && i < CACHE_WARM_MAX) { + recipients.add(record.getRecipient()); + i++; + } + } + + Log.d(TAG, "Warming up " + recipients.size() + " recipients."); + addToCache(recipients); + }); + } + + @AnyThread + public synchronized void clearSelf() { + localRecipientId = null; + } + + @AnyThread + public synchronized void clear() { + recipients.clear(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java new file mode 100644 index 00000000..e9da7384 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -0,0 +1,1063 @@ +package org.thoughtcrime.securesms.recipients; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GroupRecordContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; +import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; +import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.phonenumbers.NumberUtil; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.libsignal.util.guava.Preconditions; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import static org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; + +public class Recipient { + + private static final String TAG = Log.tag(Recipient.class); + + public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails(), true); + + public static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + + private final RecipientId id; + private final boolean resolving; + private final UUID uuid; + private final String username; + private final String e164; + private final String email; + private final GroupId groupId; + private final List participants; + private final Optional groupAvatarId; + private final boolean isSelf; + private final boolean blocked; + private final long muteUntil; + private final VibrateState messageVibrate; + private final VibrateState callVibrate; + private final Uri messageRingtone; + private final Uri callRingtone; + private final MaterialColor color; + private final Optional defaultSubscriptionId; + private final int expireMessages; + private final RegisteredState registered; + private final byte[] profileKey; + private final ProfileKeyCredential profileKeyCredential; + private final String name; + private final Uri systemContactPhoto; + private final String customLabel; + private final Uri contactUri; + private final ProfileName profileName; + private final String profileAvatar; + private final boolean hasProfileImage; + private final boolean profileSharing; + private final long lastProfileFetch; + private final String notificationChannel; + private final UnidentifiedAccessMode unidentifiedAccessMode; + private final boolean forceSmsSelection; + private final Capability groupsV2Capability; + private final Capability groupsV1MigrationCapability; + private final InsightsBannerTier insightsBannerTier; + private final byte[] storageId; + private final MentionSetting mentionSetting; + private final ChatWallpaper wallpaper; + private final String about; + private final String aboutEmoji; + + + /** + * Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be + * populated with data. However, you can observe the value that's returned to be notified when the + * {@link Recipient} changes. + */ + @AnyThread + public static @NonNull LiveRecipient live(@NonNull RecipientId id) { + Preconditions.checkNotNull(id, "ID cannot be null."); + return ApplicationDependencies.getRecipientCache().getLive(id); + } + + /** + * Returns a fully-populated {@link Recipient}. May hit the disk, and therefore should be + * called on a background thread. + */ + @WorkerThread + public static @NonNull Recipient resolved(@NonNull RecipientId id) { + Preconditions.checkNotNull(id, "ID cannot be null."); + return live(id).resolve(); + } + + @WorkerThread + public static @NonNull List resolvedList(@NonNull Collection ids) { + List recipients = new ArrayList<>(ids.size()); + + for (RecipientId recipientId : ids) { + recipients.add(resolved(recipientId)); + } + + return recipients; + } + + /** + * Returns a fully-populated {@link Recipient} and associates it with the provided username. + */ + @WorkerThread + public static @NonNull Recipient externalUsername(@NonNull Context context, @NonNull UUID uuid, @NonNull String username) { + Recipient recipient = externalPush(context, uuid, null, false); + DatabaseFactory.getRecipientDatabase(context).setUsername(recipient.getId(), username); + return recipient; + } + + /** + * Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress}, + * creating one in the database if necessary. Convenience overload of + * {@link #externalPush(Context, UUID, String, boolean)} + */ + @WorkerThread + public static @NonNull Recipient externalPush(@NonNull Context context, @NonNull SignalServiceAddress signalServiceAddress) { + return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull(), false); + } + + /** + * Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress}, + * creating one in the database if necessary. We special-case GV1 members because we want to + * prioritize E164 addresses and not use the UUIDs if possible. + */ + @WorkerThread + public static @NonNull Recipient externalGV1Member(@NonNull Context context, @NonNull SignalServiceAddress address) { + if (address.getNumber().isPresent()) { + return externalPush(context, null, address.getNumber().get(), false); + } else { + return externalPush(context, address.getUuid().orNull(), null, false); + } + } + + /** + * Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress}, + * creating one in the database if necessary. This should only used for high-trust sources, + * which are limited to: + * - Envelopes + * - UD Certs + * - CDS + * - Storage Service + */ + @WorkerThread + public static @NonNull Recipient externalHighTrustPush(@NonNull Context context, @NonNull SignalServiceAddress signalServiceAddress) { + return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull(), true); + } + + /** + * Returns a fully-populated {@link Recipient} based off of a UUID and phone number, creating one + * in the database if necessary. We want both piece of information so we're able to associate them + * both together, depending on which are available. + * + * In particular, while we'll eventually get the UUID of a user created via a phone number + * (through a directory sync), the only way we can store the phone number is by retrieving it from + * sent messages and whatnot. So we should store it when available. + * + * @param highTrust This should only be set to true if the source of the E164-UUID pairing is one + * that can be trusted as accurate (like an envelope). + */ + @WorkerThread + public static @NonNull Recipient externalPush(@NonNull Context context, @Nullable UUID uuid, @Nullable String e164, boolean highTrust) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + throw new AssertionError(); + } + + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + RecipientId recipientId = db.getAndPossiblyMerge(uuid, e164, highTrust); + + return resolved(recipientId); + } + + /** + * A safety wrapper around {@link #external(Context, String)} for when you know you're using an + * identifier for a system contact, and therefore always want to prevent interpreting it as a + * UUID. This will crash if given a UUID. + * + * (This may seem strange, but apparently some devices are returning valid UUIDs for contacts) + */ + @WorkerThread + public static @NonNull Recipient externalContact(@NonNull Context context, @NonNull String identifier) { + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + RecipientId id = null; + + if (UuidUtil.isUuid(identifier)) { + throw new AssertionError("UUIDs are not valid system contact identifiers!"); + } else if (NumberUtil.isValidEmail(identifier)) { + id = db.getOrInsertFromEmail(identifier); + } else { + id = db.getOrInsertFromE164(identifier); + } + + return Recipient.resolved(id); + } + + /** + * A version of {@link #external(Context, String)} that should be used when you know the + * identifier is a groupId. + * + * Important: This will throw an exception if the groupId you're using could have been migrated. + * If you're dealing with inbound data, you should be using + * {@link #externalPossiblyMigratedGroup(Context, GroupId)}, or checking the database before + * calling this method. + */ + @WorkerThread + public static @NonNull Recipient externalGroupExact(@NonNull Context context, @NonNull GroupId groupId) { + return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId)); + } + + /** + * Will give you one of: + * - The recipient that matches the groupId specified exactly + * - The recipient whose V1 ID would map to the provided V2 ID + * - The recipient whose V2 ID would be derived from the provided V1 ID + * - A newly-created recipient for the provided ID if none of the above match + * + * Important: You could get back a recipient with a different groupId than the one you provided. + * You should be very cautious when using the groupId on the returned recipient. + */ + @WorkerThread + public static @NonNull Recipient externalPossiblyMigratedGroup(@NonNull Context context, @NonNull GroupId groupId) { + return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(groupId)); + } + + /** + * Returns a fully-populated {@link Recipient} based off of a string identifier, creating one in + * the database if necessary. The identifier may be a uuid, phone number, email, + * or serialized groupId. + * + * If the identifier is a UUID of a Signal user, prefer using + * {@link #externalPush(Context, UUID, String, boolean)} or its overload, as this will let us associate + * the phone number with the recipient. + */ + @WorkerThread + public static @NonNull Recipient external(@NonNull Context context, @NonNull String identifier) { + Preconditions.checkNotNull(identifier, "Identifier cannot be null!"); + + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + RecipientId id = null; + + if (UuidUtil.isUuid(identifier)) { + UUID uuid = UuidUtil.parseOrThrow(identifier); + id = db.getOrInsertFromUuid(uuid); + } else if (GroupId.isEncodedGroup(identifier)) { + id = db.getOrInsertFromGroupId(GroupId.parseOrThrow(identifier)); + } else if (NumberUtil.isValidEmail(identifier)) { + id = db.getOrInsertFromEmail(identifier); + } else { + String e164 = PhoneNumberFormatter.get(context).format(identifier); + id = db.getOrInsertFromE164(e164); + } + + return Recipient.resolved(id); + } + + public static @NonNull Recipient self() { + return ApplicationDependencies.getRecipientCache().getSelf(); + } + + Recipient(@NonNull RecipientId id) { + this.id = id; + this.resolving = true; + this.uuid = null; + this.username = null; + this.e164 = null; + this.email = null; + this.groupId = null; + this.participants = Collections.emptyList(); + this.groupAvatarId = Optional.absent(); + this.isSelf = false; + this.blocked = false; + this.muteUntil = 0; + this.messageVibrate = VibrateState.DEFAULT; + this.callVibrate = VibrateState.DEFAULT; + this.messageRingtone = null; + this.callRingtone = null; + this.color = null; + this.insightsBannerTier = InsightsBannerTier.TIER_TWO; + this.defaultSubscriptionId = Optional.absent(); + this.expireMessages = 0; + this.registered = RegisteredState.UNKNOWN; + this.profileKey = null; + this.profileKeyCredential = null; + this.name = null; + this.systemContactPhoto = null; + this.customLabel = null; + this.contactUri = null; + this.profileName = ProfileName.EMPTY; + this.profileAvatar = null; + this.hasProfileImage = false; + this.profileSharing = false; + this.lastProfileFetch = 0; + this.notificationChannel = null; + this.unidentifiedAccessMode = UnidentifiedAccessMode.DISABLED; + this.forceSmsSelection = false; + this.groupsV2Capability = Capability.UNKNOWN; + this.groupsV1MigrationCapability = Capability.UNKNOWN; + this.storageId = null; + this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; + this.wallpaper = null; + this.about = null; + this.aboutEmoji = null; + } + + public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { + this.id = id; + this.resolving = !resolved; + this.uuid = details.uuid; + this.username = details.username; + this.e164 = details.e164; + this.email = details.email; + this.groupId = details.groupId; + this.participants = details.participants; + this.groupAvatarId = details.groupAvatarId; + this.isSelf = details.isSelf; + this.blocked = details.blocked; + this.muteUntil = details.mutedUntil; + this.messageVibrate = details.messageVibrateState; + this.callVibrate = details.callVibrateState; + this.messageRingtone = details.messageRingtone; + this.callRingtone = details.callRingtone; + this.color = details.color; + this.insightsBannerTier = details.insightsBannerTier; + this.defaultSubscriptionId = details.defaultSubscriptionId; + this.expireMessages = details.expireMessages; + this.registered = details.registered; + this.profileKey = details.profileKey; + this.profileKeyCredential = details.profileKeyCredential; + this.name = details.name; + this.systemContactPhoto = details.systemContactPhoto; + this.customLabel = details.customLabel; + this.contactUri = details.contactUri; + this.profileName = details.profileName; + this.profileAvatar = details.profileAvatar; + this.hasProfileImage = details.hasProfileImage; + this.profileSharing = details.profileSharing; + this.lastProfileFetch = details.lastProfileFetch; + this.notificationChannel = details.notificationChannel; + this.unidentifiedAccessMode = details.unidentifiedAccessMode; + this.forceSmsSelection = details.forceSmsSelection; + this.groupsV2Capability = details.groupsV2Capability; + this.groupsV1MigrationCapability = details.groupsV1MigrationCapability; + this.storageId = details.storageId; + this.mentionSetting = details.mentionSetting; + this.wallpaper = details.wallpaper; + this.about = details.about; + this.aboutEmoji = details.aboutEmoji; + } + + public @NonNull RecipientId getId() { + return id; + } + + public boolean isSelf() { + return isSelf; + } + + public @Nullable Uri getContactUri() { + return contactUri; + } + + public @Nullable String getName(@NonNull Context context) { + if (this.name == null && groupId != null && groupId.isMms()) { + List names = new LinkedList<>(); + + for (Recipient recipient : participants) { + names.add(recipient.getDisplayName(context)); + } + + return Util.join(names, ", "); + } else if (name == null && groupId != null && groupId.isPush()) { + return context.getString(R.string.RecipientProvider_unnamed_group); + } else { + return this.name; + } + } + + public boolean hasName() { + return name != null; + } + + /** + * False iff it {@link #getDisplayName} would fall back to e164, email or unknown. + */ + public boolean hasAUserSetDisplayName(@NonNull Context context) { + return !TextUtils.isEmpty(getName(context)) || + !TextUtils.isEmpty(getProfileName().toString()); + } + + public @NonNull String getDisplayName(@NonNull Context context) { + String name = getName(context); + + if (Util.isEmpty(name)) { + name = getProfileName().toString(); + } + + if (Util.isEmpty(name) && !Util.isEmpty(e164)) { + name = PhoneNumberFormatter.prettyPrint(e164); + } + + if (Util.isEmpty(name)) { + name = email; + } + + if (Util.isEmpty(name)) { + name = context.getString(R.string.Recipient_unknown); + } + + return StringUtil.isolateBidi(name); + } + + public @NonNull String getDisplayNameOrUsername(@NonNull Context context) { + String name = getName(context); + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(getProfileName().toString()); + } + + if (Util.isEmpty(name) && !Util.isEmpty(e164)) { + name = PhoneNumberFormatter.prettyPrint(e164); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(email); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(username); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(context.getString(R.string.Recipient_unknown)); + } + + return name; + } + + public @NonNull String getMentionDisplayName(@NonNull Context context) { + String name = isSelf ? getProfileName().toString() : getName(context); + name = StringUtil.isolateBidi(name); + + if (Util.isEmpty(name)) { + name = isSelf ? getName(context) : getProfileName().toString(); + name = StringUtil.isolateBidi(name); + } + + if (Util.isEmpty(name) && !Util.isEmpty(e164)) { + name = PhoneNumberFormatter.prettyPrint(e164); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(email); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(context.getString(R.string.Recipient_unknown)); + } + + return name; + } + + public @NonNull String getShortDisplayName(@NonNull Context context) { + String name = Util.getFirstNonEmpty(getName(context), + getProfileName().getGivenName(), + getDisplayName(context)); + + return StringUtil.isolateBidi(name); + } + + public @NonNull String getShortDisplayNameIncludingUsername(@NonNull Context context) { + String name = Util.getFirstNonEmpty(getName(context), + getProfileName().getGivenName(), + getDisplayName(context), + getUsername().orNull()); + + return StringUtil.isolateBidi(name); + } + + public @NonNull MaterialColor getColor() { + if (isGroupInternal()) { + return MaterialColor.GROUP; + } else if (color != null) { + return color; + } else if (name != null || profileSharing) { + Log.w(TAG, "Had no color for " + id + "! Saving a new one."); + + Context context = ApplicationDependencies.getApplication(); + MaterialColor color = ContactColors.generateFor(getDisplayName(context)); + + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(context).setColorIfNotSet(id, color)); + return color; + } else { + return ContactColors.UNKNOWN_COLOR; + } + } + + public @NonNull Optional getUuid() { + return Optional.fromNullable(uuid); + } + + public @NonNull Optional getUsername() { + if (FeatureFlags.usernames()) { + return Optional.fromNullable(username); + } else { + return Optional.absent(); + } + } + + public @NonNull Optional getE164() { + return Optional.fromNullable(e164); + } + + public @NonNull Optional getEmail() { + return Optional.fromNullable(email); + } + + public @NonNull Optional getGroupId() { + return Optional.fromNullable(groupId); + } + + public @NonNull Optional getSmsAddress() { + return Optional.fromNullable(e164).or(Optional.fromNullable(email)); + } + + public @NonNull UUID requireUuid() { + UUID resolved = resolving ? resolve().uuid : uuid; + + if (resolved == null) { + throw new MissingAddressError(); + } + + return resolved; + } + + + public @NonNull String requireE164() { + String resolved = resolving ? resolve().e164 : e164; + + if (resolved == null) { + throw new MissingAddressError(); + } + + return resolved; + } + + public @NonNull String requireEmail() { + String resolved = resolving ? resolve().email : email; + + if (resolved == null) { + throw new MissingAddressError(); + } + + return resolved; + } + + public @NonNull String requireSmsAddress() { + Recipient recipient = resolving ? resolve() : this; + + if (recipient.getE164().isPresent()) { + return recipient.getE164().get(); + } else if (recipient.getEmail().isPresent()) { + return recipient.getEmail().get(); + } else { + throw new MissingAddressError(); + } + } + + public boolean hasSmsAddress() { + return getE164().or(getEmail()).isPresent(); + } + + public boolean hasE164() { + return getE164().isPresent(); + } + + public boolean hasUuid() { + return getUuid().isPresent(); + } + + public @NonNull GroupId requireGroupId() { + GroupId resolved = resolving ? resolve().groupId : groupId; + + if (resolved == null) { + throw new MissingAddressError(); + } + + return resolved; + } + + public boolean hasServiceIdentifier() { + return uuid != null || e164 != null; + } + + /** + * @return A string identifier able to be used with the Signal service. Prefers UUID, and if not + * available, will return an E164 number. + */ + public @NonNull String requireServiceId() { + Recipient resolved = resolving ? resolve() : this; + + if (resolved.getUuid().isPresent()) { + return resolved.getUuid().get().toString(); + } else { + return getE164().get(); + } + } + + /** + * @return A single string to represent the recipient, in order of precedence: + * + * Group ID > UUID > Phone > Email + */ + public @NonNull String requireStringId() { + Recipient resolved = resolving ? resolve() : this; + + if (resolved.isGroup()) { + return resolved.requireGroupId().toString(); + } else if (resolved.getUuid().isPresent()) { + return resolved.getUuid().get().toString(); + } + + return requireSmsAddress(); + } + + public Optional getDefaultSubscriptionId() { + return defaultSubscriptionId; + } + + public @NonNull ProfileName getProfileName() { + return profileName; + } + + public @Nullable String getProfileAvatar() { + return profileAvatar; + } + + public boolean isProfileSharing() { + return profileSharing; + } + + public long getLastProfileFetchTime() { + return lastProfileFetch; + } + + public boolean isGroup() { + return resolve().groupId != null; + } + + private boolean isGroupInternal() { + return groupId != null; + } + + public boolean isMmsGroup() { + GroupId groupId = resolve().groupId; + return groupId != null && groupId.isMms(); + } + + public boolean isPushGroup() { + GroupId groupId = resolve().groupId; + return groupId != null && groupId.isPush(); + } + + public boolean isPushV1Group() { + GroupId groupId = resolve().groupId; + return groupId != null && groupId.isV1(); + } + + public boolean isPushV2Group() { + GroupId groupId = resolve().groupId; + return groupId != null && groupId.isV2(); + } + + public boolean isActiveGroup() { + return Stream.of(getParticipants()).anyMatch(Recipient::isSelf); + } + + public @NonNull List getParticipants() { + return new ArrayList<>(participants); + } + + public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { + return getFallbackContactPhotoDrawable(context, inverted, DEFAULT_FALLBACK_PHOTO_PROVIDER); + } + + public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted) { + return getSmallFallbackContactPhotoDrawable(context, inverted, DEFAULT_FALLBACK_PHOTO_PROVIDER); + } + + public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, getColor().toAvatarColor(context), inverted); + } + + public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, getColor().toAvatarColor(context), inverted); + } + + public @NonNull FallbackContactPhoto getFallbackContactPhoto() { + return getFallbackContactPhoto(DEFAULT_FALLBACK_PHOTO_PROVIDER); + } + + public @NonNull FallbackContactPhoto getFallbackContactPhoto(@NonNull FallbackPhotoProvider fallbackPhotoProvider) { + if (isSelf) return fallbackPhotoProvider.getPhotoForLocalNumber(); + else if (isResolving()) return fallbackPhotoProvider.getPhotoForResolvingRecipient(); + else if (isGroupInternal()) return fallbackPhotoProvider.getPhotoForGroup(); + else if (isGroup()) return fallbackPhotoProvider.getPhotoForGroup(); + else if (!TextUtils.isEmpty(name)) return fallbackPhotoProvider.getPhotoForRecipientWithName(name); + else return fallbackPhotoProvider.getPhotoForRecipientWithoutName(); + } + + public @Nullable ContactPhoto getContactPhoto() { + if (isSelf) return null; + else if (isGroupInternal() && groupAvatarId.isPresent()) return new GroupRecordContactPhoto(groupId, groupAvatarId.get()); + else if (systemContactPhoto != null && SignalStore.settings().isPreferSystemContactPhotos()) return new SystemContactPhoto(id, systemContactPhoto, 0); + else if (profileAvatar != null && hasProfileImage) return new ProfileContactPhoto(this, profileAvatar); + else if (systemContactPhoto != null) return new SystemContactPhoto(id, systemContactPhoto, 0); + else return null; + } + + public @Nullable Uri getMessageRingtone() { + if (messageRingtone != null && messageRingtone.getScheme() != null && messageRingtone.getScheme().startsWith("file")) { + return null; + } + + return messageRingtone; + } + + public @Nullable Uri getCallRingtone() { + if (callRingtone != null && callRingtone.getScheme() != null && callRingtone.getScheme().startsWith("file")) { + return null; + } + + return callRingtone; + } + + public boolean isMuted() { + return System.currentTimeMillis() <= muteUntil; + } + + public long getMuteUntil() { + return muteUntil; + } + + public boolean isBlocked() { + return blocked; + } + + public @NonNull VibrateState getMessageVibrate() { + return messageVibrate; + } + + public @NonNull VibrateState getCallVibrate() { + return callVibrate; + } + + public int getExpireMessages() { + return expireMessages; + } + + public boolean hasSeenFirstInviteReminder() { + return insightsBannerTier.seen(InsightsBannerTier.TIER_ONE); + } + + public boolean hasSeenSecondInviteReminder() { + return insightsBannerTier.seen(InsightsBannerTier.TIER_TWO); + } + + public @NonNull RegisteredState getRegistered() { + if (isPushGroup()) return RegisteredState.REGISTERED; + else if (isMmsGroup()) return RegisteredState.NOT_REGISTERED; + + return registered; + } + + public boolean isRegistered() { + return registered == RegisteredState.REGISTERED || isPushGroup(); + } + + public @Nullable String getNotificationChannel() { + return !NotificationChannels.supported() ? null : notificationChannel; + } + + public boolean isForceSmsSelection() { + return forceSmsSelection; + } + + public @NonNull Capability getGroupsV2Capability() { + return groupsV2Capability; + } + + public @NonNull Capability getGroupsV1MigrationCapability() { + return groupsV1MigrationCapability; + } + + public @Nullable byte[] getProfileKey() { + return profileKey; + } + + public @Nullable ProfileKeyCredential getProfileKeyCredential() { + return profileKeyCredential; + } + + public boolean hasProfileKeyCredential() { + return profileKeyCredential != null; + } + + public @Nullable byte[] getStorageServiceId() { + return storageId; + } + + public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() { + return unidentifiedAccessMode; + } + + public @Nullable ChatWallpaper getWallpaper() { + if (wallpaper != null) { + return wallpaper; + } else { + return SignalStore.wallpaper().getWallpaper(); + } + } + + public boolean hasOwnWallpaper() { + return wallpaper != null; + } + + /** + * A cheap way to check if wallpaper is set without doing any unnecessary proto parsing. + */ + public boolean hasWallpaper() { + return wallpaper != null || SignalStore.wallpaper().hasWallpaperSet(); + } + + public boolean isSystemContact() { + return contactUri != null; + } + + public @Nullable String getAbout() { + return about; + } + + public @Nullable String getAboutEmoji() { + return aboutEmoji; + } + + public @Nullable String getCombinedAboutAndEmoji() { + if (!Util.isEmpty(aboutEmoji)) { + if (!Util.isEmpty(about)) { + return aboutEmoji + " " + about; + } else { + return aboutEmoji; + } + } else if (!Util.isEmpty(about)) { + return about; + } else { + return null; + } + } + + /** + * If this recipient is missing crucial data, this will return a populated copy. Otherwise it + * returns itself. + */ + public @NonNull Recipient resolve() { + if (resolving) { + return live().resolve(); + } else { + return this; + } + } + + public boolean isResolving() { + return resolving; + } + + /** + * Forces retrieving a fresh copy of the recipient, regardless of its state. + */ + public @NonNull Recipient fresh() { + return live().resolve(); + } + + public @NonNull LiveRecipient live() { + return ApplicationDependencies.getRecipientCache().getLive(id); + } + + public @NonNull MentionSetting getMentionSetting() { + return mentionSetting; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Recipient recipient = (Recipient) o; + return id.equals(recipient.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + + public enum Capability { + UNKNOWN(0), + SUPPORTED(1), + NOT_SUPPORTED(2); + + private final int value; + + Capability(int value) { + this.value = value; + } + + public int serialize() { + return value; + } + + public static Capability deserialize(int value) { + switch (value) { + case 0: return UNKNOWN; + case 1: return SUPPORTED; + case 2: return NOT_SUPPORTED; + default: throw new IllegalArgumentException(); + } + } + + public static Capability fromBoolean(boolean supported) { + return supported ? SUPPORTED : NOT_SUPPORTED; + } + } + + public boolean hasSameContent(@NonNull Recipient other) { + return Objects.equals(id, other.id) && + resolving == other.resolving && + isSelf == other.isSelf && + blocked == other.blocked && + muteUntil == other.muteUntil && + expireMessages == other.expireMessages && + hasProfileImage == other.hasProfileImage && + profileSharing == other.profileSharing && + lastProfileFetch == other.lastProfileFetch && + forceSmsSelection == other.forceSmsSelection && + Objects.equals(id, other.id) && + Objects.equals(uuid, other.uuid) && + Objects.equals(username, other.username) && + Objects.equals(e164, other.e164) && + Objects.equals(email, other.email) && + Objects.equals(groupId, other.groupId) && + allContentsAreTheSame(participants, other.participants) && + Objects.equals(groupAvatarId, other.groupAvatarId) && + messageVibrate == other.messageVibrate && + callVibrate == other.callVibrate && + Objects.equals(messageRingtone, other.messageRingtone) && + Objects.equals(callRingtone, other.callRingtone) && + color == other.color && + Objects.equals(defaultSubscriptionId, other.defaultSubscriptionId) && + registered == other.registered && + Arrays.equals(profileKey, other.profileKey) && + Objects.equals(profileKeyCredential, other.profileKeyCredential) && + Objects.equals(name, other.name) && + Objects.equals(systemContactPhoto, other.systemContactPhoto) && + Objects.equals(customLabel, other.customLabel) && + Objects.equals(contactUri, other.contactUri) && + Objects.equals(profileName, other.profileName) && + Objects.equals(profileAvatar, other.profileAvatar) && + Objects.equals(notificationChannel, other.notificationChannel) && + unidentifiedAccessMode == other.unidentifiedAccessMode && + groupsV2Capability == other.groupsV2Capability && + groupsV1MigrationCapability == other.groupsV1MigrationCapability && + insightsBannerTier == other.insightsBannerTier && + Arrays.equals(storageId, other.storageId) && + mentionSetting == other.mentionSetting && + Objects.equals(wallpaper, other.wallpaper) && + Objects.equals(about, other.about) && + Objects.equals(aboutEmoji, other.aboutEmoji); + } + + private static boolean allContentsAreTheSame(@NonNull List a, @NonNull List b) { + if (a.size() != b.size()) { + return false; + } + + for (int i = 0, len = a.size(); i < len; i++) { + if (!a.get(i).hasSameContent(b.get(i))) { + return false; + } + } + + return true; + } + + + public static class FallbackPhotoProvider { + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new ResourceContactPhoto(R.drawable.ic_note_34, R.drawable.ic_note_24); + } + + public @NonNull FallbackContactPhoto getPhotoForResolvingRecipient() { + return new TransparentContactPhoto(); + } + + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20, R.drawable.ic_group_outline_48); + } + + public @NonNull FallbackContactPhoto getPhotoForRecipientWithName(String name) { + return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40); + } + + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_profile_outline_48); + } + } + + private static class MissingAddressError extends AssertionError { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java new file mode 100644 index 00000000..d8af3b51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -0,0 +1,179 @@ +package org.thoughtcrime.securesms.recipients; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; +import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; +import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +public class RecipientDetails { + + final UUID uuid; + final String username; + final String e164; + final String email; + final GroupId groupId; + final String name; + final String customLabel; + final Uri systemContactPhoto; + final Uri contactUri; + final Optional groupAvatarId; + final MaterialColor color; + final Uri messageRingtone; + final Uri callRingtone; + final long mutedUntil; + final VibrateState messageVibrateState; + final VibrateState callVibrateState; + final boolean blocked; + final int expireMessages; + final List participants; + final ProfileName profileName; + final Optional defaultSubscriptionId; + final RegisteredState registered; + final byte[] profileKey; + final ProfileKeyCredential profileKeyCredential; + final String profileAvatar; + final boolean hasProfileImage; + final boolean profileSharing; + final long lastProfileFetch; + final boolean systemContact; + final boolean isSelf; + final String notificationChannel; + final UnidentifiedAccessMode unidentifiedAccessMode; + final boolean forceSmsSelection; + final Recipient.Capability groupsV2Capability; + final Recipient.Capability groupsV1MigrationCapability; + final InsightsBannerTier insightsBannerTier; + final byte[] storageId; + final MentionSetting mentionSetting; + final ChatWallpaper wallpaper; + final String about; + final String aboutEmoji; + + public RecipientDetails(@Nullable String name, + @NonNull Optional groupAvatarId, + boolean systemContact, + boolean isSelf, + @NonNull RecipientSettings settings, + @Nullable List participants) + { + this.groupAvatarId = groupAvatarId; + this.systemContactPhoto = Util.uri(settings.getSystemContactPhotoUri()); + this.customLabel = settings.getSystemPhoneLabel(); + this.contactUri = Util.uri(settings.getSystemContactUri()); + this.uuid = settings.getUuid(); + this.username = settings.getUsername(); + this.e164 = settings.getE164(); + this.email = settings.getEmail(); + this.groupId = settings.getGroupId(); + this.color = settings.getColor(); + this.messageRingtone = settings.getMessageRingtone(); + this.callRingtone = settings.getCallRingtone(); + this.mutedUntil = settings.getMuteUntil(); + this.messageVibrateState = settings.getMessageVibrateState(); + this.callVibrateState = settings.getCallVibrateState(); + this.blocked = settings.isBlocked(); + this.expireMessages = settings.getExpireMessages(); + this.participants = participants == null ? new LinkedList<>() : participants; + this.profileName = settings.getProfileName(); + this.defaultSubscriptionId = settings.getDefaultSubscriptionId(); + this.registered = settings.getRegistered(); + this.profileKey = settings.getProfileKey(); + this.profileKeyCredential = settings.getProfileKeyCredential(); + this.profileAvatar = settings.getProfileAvatar(); + this.hasProfileImage = settings.hasProfileImage(); + this.profileSharing = settings.isProfileSharing(); + this.lastProfileFetch = settings.getLastProfileFetch(); + this.systemContact = systemContact; + this.isSelf = isSelf; + this.notificationChannel = settings.getNotificationChannel(); + this.unidentifiedAccessMode = settings.getUnidentifiedAccessMode(); + this.forceSmsSelection = settings.isForceSmsSelection(); + this.groupsV2Capability = settings.getGroupsV2Capability(); + this.groupsV1MigrationCapability = settings.getGroupsV1MigrationCapability(); + this.insightsBannerTier = settings.getInsightsBannerTier(); + this.storageId = settings.getStorageId(); + this.mentionSetting = settings.getMentionSetting(); + this.wallpaper = settings.getWallpaper(); + this.about = settings.getAbout(); + this.aboutEmoji = settings.getAboutEmoji(); + + if (name == null) this.name = settings.getSystemDisplayName(); + else this.name = name; + } + + /** + * Only used for {@link Recipient#UNKNOWN}. + */ + RecipientDetails() { + this.groupAvatarId = null; + this.systemContactPhoto = null; + this.customLabel = null; + this.contactUri = null; + this.uuid = null; + this.username = null; + this.e164 = null; + this.email = null; + this.groupId = null; + this.color = null; + this.messageRingtone = null; + this.callRingtone = null; + this.mutedUntil = 0; + this.messageVibrateState = VibrateState.DEFAULT; + this.callVibrateState = VibrateState.DEFAULT; + this.blocked = false; + this.expireMessages = 0; + this.participants = new LinkedList<>(); + this.profileName = ProfileName.EMPTY; + this.insightsBannerTier = InsightsBannerTier.TIER_TWO; + this.defaultSubscriptionId = Optional.absent(); + this.registered = RegisteredState.UNKNOWN; + this.profileKey = null; + this.profileKeyCredential = null; + this.profileAvatar = null; + this.hasProfileImage = false; + this.profileSharing = false; + this.lastProfileFetch = 0; + this.systemContact = true; + this.isSelf = false; + this.notificationChannel = null; + this.unidentifiedAccessMode = UnidentifiedAccessMode.UNKNOWN; + this.forceSmsSelection = false; + this.name = null; + this.groupsV2Capability = Recipient.Capability.UNKNOWN; + this.groupsV1MigrationCapability = Recipient.Capability.UNKNOWN; + this.storageId = null; + this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; + this.wallpaper = null; + this.about = null; + this.aboutEmoji = null; + } + + public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) { + boolean systemContact = !TextUtils.isEmpty(settings.getSystemDisplayName()); + boolean isSelf = (settings.getE164() != null && settings.getE164().equals(TextSecurePreferences.getLocalNumber(context))) || + (settings.getUuid() != null && settings.getUuid().equals(TextSecurePreferences.getLocalUuid(context))); + + return new RecipientDetails(null, Optional.absent(), systemContact, isSelf, settings, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientExporter.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientExporter.java new file mode 100644 index 00000000..63935e59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientExporter.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.recipients; + +import android.content.Intent; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import static android.content.Intent.ACTION_INSERT_OR_EDIT; + +public final class RecipientExporter { + + public static RecipientExporter export(Recipient recipient) { + return new RecipientExporter(recipient); + } + + private final Recipient recipient; + + private RecipientExporter(Recipient recipient) { + this.recipient = recipient; + } + + public Intent asAddContactIntent() { + Intent intent = new Intent(ACTION_INSERT_OR_EDIT); + intent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE); + + addNameToIntent(intent, recipient.getProfileName().toString()); + addAddressToIntent(intent, recipient); + return intent; + } + + private static void addNameToIntent(Intent intent, String profileName) { + if (!TextUtils.isEmpty(profileName)) { + intent.putExtra(ContactsContract.Intents.Insert.NAME, profileName); + } + } + + private static void addAddressToIntent(Intent intent, Recipient recipient) { + if (recipient.getE164().isPresent()) { + intent.putExtra(ContactsContract.Intents.Insert.PHONE, recipient.requireE164()); + } else if (recipient.getEmail().isPresent()) { + intent.putExtra(ContactsContract.Intents.Insert.EMAIL, recipient.requireEmail()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java new file mode 100644 index 00000000..d43984dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientForeverObserver.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.recipients; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +public interface RecipientForeverObserver { + @MainThread + void onRecipientChanged(@NonNull Recipient recipient); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientFormattingException.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientFormattingException.java new file mode 100644 index 00000000..9b2731e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientFormattingException.java @@ -0,0 +1,35 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.recipients; + +public class RecipientFormattingException extends Exception { + public RecipientFormattingException() { + super(); + } + + public RecipientFormattingException(String message) { + super(message); + } + + public RecipientFormattingException(String message, Throwable nested) { + super(message, nested); + } + + public RecipientFormattingException(Throwable nested) { + super(nested); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java new file mode 100644 index 00000000..a674fba4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.recipients; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.DelimiterUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +public class RecipientId implements Parcelable, Comparable { + + private static final long UNKNOWN_ID = -1; + private static final char DELIMITER = ','; + + public static final RecipientId UNKNOWN = RecipientId.from(UNKNOWN_ID); + + private final long id; + + public static RecipientId from(long id) { + if (id == 0) { + throw new InvalidLongRecipientIdError(); + } + + return new RecipientId(id); + } + + public static RecipientId from(@NonNull String id) { + try { + return RecipientId.from(Long.parseLong(id)); + } catch (NumberFormatException e) { + throw new InvalidStringRecipientIdError(); + } + } + + @AnyThread + public static @NonNull RecipientId from(@NonNull SignalServiceAddress address) { + return from(address.getUuid().orNull(), address.getNumber().orNull(), false); + } + + /** + * Indicates that the pairing is from a high-trust source. + * See {@link Recipient#externalHighTrustPush(Context, SignalServiceAddress)} + */ + @AnyThread + public static @NonNull RecipientId fromHighTrust(@NonNull SignalServiceAddress address) { + return from(address.getUuid().orNull(), address.getNumber().orNull(), true); + } + + /** + * Always supply both {@param uuid} and {@param e164} if you have both. + */ + @AnyThread + @SuppressLint("WrongThread") + public static @NonNull RecipientId from(@Nullable UUID uuid, @Nullable String e164) { + return from(uuid, e164, false); + } + + @AnyThread + @SuppressLint("WrongThread") + private static @NonNull RecipientId from(@Nullable UUID uuid, @Nullable String e164, boolean highTrust) { + RecipientId recipientId = RecipientIdCache.INSTANCE.get(uuid, e164); + + if (recipientId == null) { + recipientId = Recipient.externalPush(ApplicationDependencies.getApplication(), uuid, e164, highTrust).getId(); + } + + return recipientId; + } + + @AnyThread + public static void clearCache() { + RecipientIdCache.INSTANCE.clear(); + } + + private RecipientId(long id) { + this.id = id; + } + + private RecipientId(Parcel in) { + id = in.readLong(); + } + + public static @NonNull String toSerializedList(@NonNull Collection ids) { + return Util.join(Stream.of(ids).map(RecipientId::serialize).toList(), String.valueOf(DELIMITER)); + } + + public static List fromSerializedList(@NonNull String serialized) { + String[] stringIds = DelimiterUtil.split(serialized, DELIMITER); + List out = new ArrayList<>(stringIds.length); + + for (String stringId : stringIds) { + RecipientId id = RecipientId.from(Long.parseLong(stringId)); + out.add(id); + } + + return out; + } + + public static boolean serializedListContains(@NonNull String serialized, @NonNull RecipientId recipientId) { + return Pattern.compile("\\b" + recipientId.serialize() + "\\b") + .matcher(serialized) + .find(); + } + + public boolean isUnknown() { + return id == UNKNOWN_ID; + } + + public @NonNull String serialize() { + return String.valueOf(id); + } + + public long toLong() { + return id; + } + + public @NonNull String toQueueKey() { + return toQueueKey(false); + } + + public @NonNull String toQueueKey(boolean forMedia) { + return "RecipientId::" + id + (forMedia ? "::MEDIA" : ""); + } + + @Override + public @NonNull String toString() { + return "RecipientId::" + id; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + RecipientId that = (RecipientId) o; + + return id == that.id; + } + + @Override + public int hashCode() { + return (int) (id ^ (id >>> 32)); + } + + @Override + public int compareTo(RecipientId o) { + return Long.compare(this.id, o.id); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + } + + public static final Creator CREATOR = new Creator() { + @Override + public RecipientId createFromParcel(Parcel in) { + return new RecipientId(in); + } + + @Override + public RecipientId[] newArray(int size) { + return new RecipientId[size]; + } + }; + + private static class InvalidLongRecipientIdError extends AssertionError {} + private static class InvalidStringRecipientIdError extends AssertionError {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java new file mode 100644 index 00000000..afcfc0eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.recipients; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Thread safe cache that allows faster looking up of {@link RecipientId}s without hitting the database. + */ +final class RecipientIdCache { + + private static final int INSTANCE_CACHE_LIMIT = 1000; + + static final RecipientIdCache INSTANCE = new RecipientIdCache(INSTANCE_CACHE_LIMIT); + + private static final String TAG = Log.tag(RecipientIdCache.class); + + private final Map ids; + + RecipientIdCache(int limit) { + ids = new LinkedHashMap(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > limit; + } + }; + } + + synchronized void put(@NonNull Recipient recipient) { + RecipientId recipientId = recipient.getId(); + Optional e164 = recipient.getE164(); + Optional uuid = recipient.getUuid(); + + if (e164.isPresent()) { + ids.put(e164.get(), recipientId); + } + + if (uuid.isPresent()) { + ids.put(uuid.get(), recipientId); + } + } + + synchronized @Nullable RecipientId get(@Nullable UUID uuid, @Nullable String e164) { + if (uuid != null && e164 != null) { + RecipientId recipientIdByUuid = ids.get(uuid); + if (recipientIdByUuid == null) return null; + + RecipientId recipientIdByE164 = ids.get(e164); + if (recipientIdByE164 == null) return null; + + if (recipientIdByUuid.equals(recipientIdByE164)) { + return recipientIdByUuid; + } else { + ids.remove(uuid); + ids.remove(e164); + Log.w(TAG, "Seen invalid RecipientIdCacheState"); + return null; + } + } else if (uuid != null) { + return ids.get(uuid); + } else if (e164 != null) { + return ids.get(e164); + } + + return null; + } + + synchronized void clear() { + ids.clear(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java new file mode 100644 index 00000000..a59a4b5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientModifiedListener.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.recipients; + + +import androidx.annotation.NonNull; + +public interface RecipientModifiedListener { + public void onModified(@NonNull Recipient recipient); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java new file mode 100644 index 00000000..9a3a0c46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -0,0 +1,304 @@ +package org.thoughtcrime.securesms.recipients; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; +import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +public class RecipientUtil { + + private static final String TAG = Log.tag(RecipientUtil.class); + + /** + * This method will do it's best to craft a fully-populated {@link SignalServiceAddress} based on + * the provided recipient. This includes performing a possible network request if no UUID is + * available. If the request to get a UUID fails, the exception is swallowed an an E164-only + * recipient is returned. + */ + @WorkerThread + public static @NonNull SignalServiceAddress toSignalServiceAddressBestEffort(@NonNull Context context, @NonNull Recipient recipient) { + try { + return toSignalServiceAddress(context, recipient); + } catch (IOException e) { + Log.w(TAG, "Failed to populate address!", e); + return new SignalServiceAddress(recipient.getUuid().orNull(), recipient.getE164().orNull()); + } + } + + /** + * This method will do it's best to craft a fully-populated {@link SignalServiceAddress} based on + * the provided recipient. This includes performing a possible network request if no UUID is + * available. If the request to get a UUID fails, an IOException is thrown. + */ + @WorkerThread + public static @NonNull SignalServiceAddress toSignalServiceAddress(@NonNull Context context, @NonNull Recipient recipient) + throws IOException + { + recipient = recipient.resolve(); + + if (!recipient.getUuid().isPresent() && !recipient.getE164().isPresent()) { + throw new AssertionError(recipient.getId() + " - No UUID or phone number!"); + } + + if (!recipient.getUuid().isPresent()) { + Log.i(TAG, recipient.getId() + " is missing a UUID..."); + RegisteredState state = DirectoryHelper.refreshDirectoryFor(context, recipient, false); + + recipient = Recipient.resolved(recipient.getId()); + Log.i(TAG, "Successfully performed a UUID fetch for " + recipient.getId() + ". Registered: " + state); + } + + return new SignalServiceAddress(Optional.fromNullable(recipient.getUuid().orNull()), Optional.fromNullable(recipient.resolve().getE164().orNull())); + } + + public static @NonNull List toSignalServiceAddresses(@NonNull Context context, @NonNull List recipients) + throws IOException + { + return toSignalServiceAddressesFromResolved(context, Recipient.resolvedList(recipients)); + } + + public static @NonNull List toSignalServiceAddressesFromResolved(@NonNull Context context, @NonNull List recipients) + throws IOException + { + ensureUuidsAreAvailable(context, recipients); + + return Stream.of(recipients) + .map(Recipient::resolve) + .map(r -> new SignalServiceAddress(r.getUuid().orNull(), r.getE164().orNull())) + .toList(); + } + + public static boolean ensureUuidsAreAvailable(@NonNull Context context, @NonNull Collection recipients) + throws IOException + { + List recipientsWithoutUuids = Stream.of(recipients) + .map(Recipient::resolve) + .filterNot(Recipient::hasUuid) + .toList(); + + if (recipientsWithoutUuids.size() > 0) { + DirectoryHelper.refreshDirectoryFor(context, recipientsWithoutUuids, false); + return true; + } else { + return false; + } + } + + public static boolean isBlockable(@NonNull Recipient recipient) { + Recipient resolved = recipient.resolve(); + return !resolved.isMmsGroup(); + } + + public static List getEligibleForSending(@NonNull List recipients) { + return Stream.of(recipients) + .filter(r -> r.getRegistered() != RegisteredState.NOT_REGISTERED) + .toList(); + } + + /** + * You can call this for non-groups and not have to handle any network errors. + */ + @WorkerThread + public static void blockNonGroup(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.isGroup()) { + throw new AssertionError(); + } + + try { + block(context, recipient); + } catch (GroupChangeException | IOException e) { + throw new AssertionError(e); + } + } + + /** + * You can call this for any type of recipient but must handle network errors that can occur from + * GV2. + *

+ * GV2 operations can also take longer due to the network. + */ + @WorkerThread + public static void block(@NonNull Context context, @NonNull Recipient recipient) + throws GroupChangeBusyException, IOException, GroupChangeFailedException + { + if (!isBlockable(recipient)) { + throw new AssertionError("Recipient is not blockable!"); + } + + recipient = recipient.resolve(); + + if (recipient.isGroup() && recipient.getGroupId().get().isPush()) { + GroupManager.leaveGroupFromBlockOrMessageRequest(context, recipient.getGroupId().get().requirePush()); + } + + DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), true); + + if (recipient.isSystemContact() || recipient.isProfileSharing() || isProfileSharedViaGroup(context, recipient)) { + ApplicationDependencies.getJobManager().add(new RotateProfileKeyJob()); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), false); + } + + ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + + @WorkerThread + public static void unblock(@NonNull Context context, @NonNull Recipient recipient) { + if (!isBlockable(recipient)) { + throw new AssertionError("Recipient is not blockable!"); + } + + DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), false); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); + ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + StorageSyncHelper.scheduleSyncForDataChange(); + + if (recipient.hasServiceIdentifier()) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(recipient.getId())); + } + } + + /** + * If true, the new message request UI does not need to be shown, and it's safe to send read + * receipts. + * + * Note that this does not imply that a user has explicitly accepted a message request -- it could + * also be the case that the thread in question is for a system contact or something of the like. + */ + @WorkerThread + public static boolean isMessageRequestAccepted(@NonNull Context context, long threadId) { + if (threadId < 0) { + return true; + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Recipient threadRecipient = threadDatabase.getRecipientForThreadId(threadId); + + if (threadRecipient == null) { + return true; + } + + return isMessageRequestAccepted(context, threadId, threadRecipient); + } + + /** + * See {@link #isMessageRequestAccepted(Context, long)}. + */ + @WorkerThread + public static boolean isMessageRequestAccepted(@NonNull Context context, @Nullable Recipient threadRecipient) { + if (threadRecipient == null) { + return true; + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(threadRecipient); + return isMessageRequestAccepted(context, threadId, threadRecipient); + } + + /** + * Like {@link #isMessageRequestAccepted(Context, long)} but with fewer checks around messages so it + * is more likely to return false. + */ + @WorkerThread + public static boolean isCallRequestAccepted(@NonNull Context context, @Nullable Recipient threadRecipient) { + if (threadRecipient == null) { + return true; + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(threadRecipient); + return isCallRequestAccepted(context, threadId, threadRecipient); + } + + /** + * @return True if a conversation existed before we enabled message requests, otherwise false. + */ + @WorkerThread + public static boolean isPreMessageRequestThread(@NonNull Context context, long threadId) { + long beforeTime = SignalStore.misc().getMessageRequestEnableTime(); + return DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId, beforeTime) > 0; + } + + @WorkerThread + public static void shareProfileIfFirstSecureMessage(@NonNull Context context, @NonNull Recipient recipient) { + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient.getId()); + + if (isPreMessageRequestThread(context, threadId)) { + return; + } + + boolean firstMessage = DatabaseFactory.getMmsSmsDatabase(context).getOutgoingSecureConversationCount(threadId) == 0; + + if (firstMessage) { + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); + } + } + + public static boolean isLegacyProfileSharingAccepted(@NonNull Recipient threadRecipient) { + return threadRecipient.isSelf() || + threadRecipient.isProfileSharing() || + threadRecipient.isSystemContact() || + !threadRecipient.isRegistered() || + threadRecipient.isForceSmsSelection(); + } + + @WorkerThread + private static boolean isMessageRequestAccepted(@NonNull Context context, long threadId, @NonNull Recipient threadRecipient) { + return threadRecipient.isSelf() || + threadRecipient.isProfileSharing() || + threadRecipient.isSystemContact() || + threadRecipient.isForceSmsSelection() || + !threadRecipient.isRegistered() || + hasSentMessageInThread(context, threadId) || + noSecureMessagesAndNoCallsInThread(context, threadId) || + isPreMessageRequestThread(context, threadId); + } + + @WorkerThread + private static boolean isCallRequestAccepted(@NonNull Context context, long threadId, @NonNull Recipient threadRecipient) { + return threadRecipient.isProfileSharing() || + threadRecipient.isSystemContact() || + hasSentMessageInThread(context, threadId) || + isPreMessageRequestThread(context, threadId); + } + + @WorkerThread + public static boolean hasSentMessageInThread(@NonNull Context context, long threadId) { + return DatabaseFactory.getMmsSmsDatabase(context).getOutgoingSecureConversationCount(threadId) != 0; + } + + @WorkerThread + private static boolean noSecureMessagesAndNoCallsInThread(@NonNull Context context, long threadId) { + return DatabaseFactory.getMmsSmsDatabase(context).getSecureConversationCount(threadId) == 0 && + !DatabaseFactory.getThreadDatabase(context).hasReceivedAnyCallsSince(threadId, 0); + } + + @WorkerThread + private static boolean isProfileSharedViaGroup(@NonNull Context context, @NonNull Recipient recipient) { + return Stream.of(DatabaseFactory.getGroupDatabase(context).getPushGroupsContainingMember(recipient.getId())) + .anyMatch(group -> Recipient.resolved(group.getRecipientId()).isProfileSharing()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientsFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientsFormatter.java new file mode 100644 index 00000000..041299c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientsFormatter.java @@ -0,0 +1,76 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.recipients; + +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +public class RecipientsFormatter { + + private static String parseBracketedNumber(String recipient) throws RecipientFormattingException { + int begin = recipient.indexOf('<'); + int end = recipient.indexOf('>'); + String value = recipient.substring(begin + 1, end); + + if (PhoneNumberUtils.isWellFormedSmsAddress(value)) + return value; + else + throw new RecipientFormattingException("Bracketed value: " + value + " is not valid."); + } + + private static String parseRecipient(String recipient) throws RecipientFormattingException { + recipient = recipient.trim(); + + if ((recipient.indexOf('<') != -1) && (recipient.indexOf('>') != -1)) + return parseBracketedNumber(recipient); + + if (PhoneNumberUtils.isWellFormedSmsAddress(recipient)) + return recipient; + + throw new RecipientFormattingException("Recipient: " + recipient + " is badly formatted."); + } + + public static List getRecipients(String rawText) throws RecipientFormattingException { + ArrayList results = new ArrayList(); + StringTokenizer tokenizer = new StringTokenizer(rawText, ","); + + while (tokenizer.hasMoreTokens()) { + results.add(parseRecipient(tokenizer.nextToken())); + } + + return results; + } + + public static String formatNameAndNumber(String name, String number) { + // Format like this: Mike Cleron <(650) 555-1234> + // Erick Tseng <(650) 555-1212> + // Tutankhamun + // (408) 555-1289 + String formattedNumber = PhoneNumberUtils.formatNumber(number); + if (!TextUtils.isEmpty(name) && !name.equals(number)) { + return name + " <" + formattedNumber + ">"; + } else { + return formattedNumber; + } + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java new file mode 100644 index 00000000..cf3c6d1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.recipients.ui; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import com.google.android.material.appbar.AppBarLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.lang.ref.WeakReference; + +public final class RecipientSettingsCoordinatorLayoutBehavior extends CoordinatorLayout.Behavior { + + private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); + + private final ViewReference avatarTargetRef = new ViewReference(R.id.avatar_target); + private final ViewReference nameRef = new ViewReference(R.id.name); + private final ViewReference nameTargetRef = new ViewReference(R.id.name_target); + private final Rect targetRect = new Rect(); + private final Rect childRect = new Rect(); + + public RecipientSettingsCoordinatorLayoutBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { + } + + @Override + public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { + return dependency instanceof AppBarLayout; + } + + @Override + public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { + AppBarLayout appBarLayout = (AppBarLayout) dependency; + int range = appBarLayout.getTotalScrollRange(); + float factor = INTERPOLATOR.getInterpolation(-appBarLayout.getY() / range); + + updateAvatarPositionAndScale(parent, child, factor); + updateNamePosition(parent, factor); + + return true; + } + + private void updateAvatarPositionAndScale(@NonNull CoordinatorLayout parent, @NonNull View child, float factor) { + View target = avatarTargetRef.require(parent); + + targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom()); + childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); + + float widthScale = 1f - (1f - (targetRect.width() / (float) childRect.width())) * factor; + float heightScale = 1f - (1f - (targetRect.height() / (float) childRect.height())) * factor; + + float superimposedLeft = childRect.left + (childRect.width() - targetRect.width()) / 2f; + float superimposedTop = childRect.top + (childRect.height() - targetRect.height()) / 2f; + + float xTranslation = (targetRect.left - superimposedLeft) * factor; + float yTranslation = (targetRect.top - superimposedTop) * factor; + + child.setScaleX(widthScale); + child.setScaleY(heightScale); + child.setTranslationX(xTranslation); + child.setTranslationY(yTranslation); + } + + private void updateNamePosition(@NonNull CoordinatorLayout parent, float factor) { + TextView child = (TextView) nameRef.require(parent); + View target = nameTargetRef.require(parent); + + targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom()); + childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); + + if (child.getMaxWidth() != targetRect.width()) { + child.setMaxWidth(targetRect.width()); + } + + float deltaTop = targetRect.top - childRect.top; + float deltaStart = getStart(parent, targetRect) - getStart(parent, childRect); + + float yTranslation = deltaTop * factor; + float xTranslation = deltaStart * factor; + + child.setTranslationY(yTranslation); + child.setTranslationX(xTranslation); + } + + private static int getStart(@NonNull CoordinatorLayout parent, @NonNull Rect rect) { + return ViewUtil.isLtr(parent) ? rect.left : rect.right; + } + + private static final class ViewReference { + + private WeakReference ref = new WeakReference<>(null); + + private final @IdRes int idRes; + + private ViewReference(@IdRes int idRes) { + this.idRes = idRes; + } + + private @NonNull View require(@NonNull View parent) { + View view = ref.get(); + + if (view == null) { + view = getChildOrThrow(parent, idRes); + ref = new WeakReference<>(view); + } + + return view; + } + + private static @NonNull View getChildOrThrow(@NonNull View parent, @IdRes int id) { + View child = parent.findViewById(id); + + if (child == null) { + throw new AssertionError("Can't find view with ID " + R.id.avatar_target); + } else { + return child; + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java new file mode 100644 index 00000000..957acfe7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -0,0 +1,280 @@ +package org.thoughtcrime.securesms.recipients.ui.bottomsheet; + +import android.app.Activity; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.widget.TextViewCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientExporter; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; + +/** + * A bottom sheet that shows some simple recipient details, as well as some actions (like calling, + * adding to contacts, etc). + */ +public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogFragment { + + public static final int REQUEST_CODE_ADD_CONTACT = 1111; + + private static final String ARGS_RECIPIENT_ID = "RECIPIENT_ID"; + private static final String ARGS_GROUP_ID = "GROUP_ID"; + + private RecipientDialogViewModel viewModel; + private AvatarImageView avatar; + private TextView fullName; + private TextView about; + private TextView usernameNumber; + private Button messageButton; + private Button secureCallButton; + private Button insecureCallButton; + private Button secureVideoCallButton; + private Button blockButton; + private Button unblockButton; + private Button addContactButton; + private Button addToGroupButton; + private Button viewSafetyNumberButton; + private Button makeGroupAdminButton; + private Button removeAdminButton; + private Button removeFromGroupButton; + private ProgressBar adminActionBusy; + private View noteToSelfDescription; + + public static BottomSheetDialogFragment create(@NonNull RecipientId recipientId, + @Nullable GroupId groupId) + { + Bundle args = new Bundle(); + RecipientBottomSheetDialogFragment fragment = new RecipientBottomSheetDialogFragment(); + + args.putString(ARGS_RECIPIENT_ID, recipientId.serialize()); + if (groupId != null) { + args.putString(ARGS_GROUP_ID, groupId.toString()); + } + + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.recipient_bottom_sheet, container, false); + + avatar = view.findViewById(R.id.rbs_recipient_avatar); + fullName = view.findViewById(R.id.rbs_full_name); + about = view.findViewById(R.id.rbs_about); + usernameNumber = view.findViewById(R.id.rbs_username_number); + messageButton = view.findViewById(R.id.rbs_message_button); + secureCallButton = view.findViewById(R.id.rbs_secure_call_button); + insecureCallButton = view.findViewById(R.id.rbs_insecure_call_button); + secureVideoCallButton = view.findViewById(R.id.rbs_video_call_button); + blockButton = view.findViewById(R.id.rbs_block_button); + unblockButton = view.findViewById(R.id.rbs_unblock_button); + addContactButton = view.findViewById(R.id.rbs_add_contact_button); + addToGroupButton = view.findViewById(R.id.rbs_add_to_group_button); + viewSafetyNumberButton = view.findViewById(R.id.rbs_view_safety_number_button); + makeGroupAdminButton = view.findViewById(R.id.rbs_make_group_admin_button); + removeAdminButton = view.findViewById(R.id.rbs_remove_group_admin_button); + removeFromGroupButton = view.findViewById(R.id.rbs_remove_from_group_button); + adminActionBusy = view.findViewById(R.id.rbs_admin_action_busy); + noteToSelfDescription = view.findViewById(R.id.rbs_note_to_self_description); + + return view; + } + + @Override + public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) { + super.onViewCreated(fragmentView, savedInstanceState); + + Bundle arguments = requireArguments(); + RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID))); + GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)); + + RecipientDialogViewModel.Factory factory = new RecipientDialogViewModel.Factory(requireContext().getApplicationContext(), recipientId, groupId); + + viewModel = ViewModelProviders.of(this, factory).get(RecipientDialogViewModel.class); + + viewModel.getRecipient().observe(getViewLifecycleOwner(), recipient -> { + avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getColor().toAvatarColor(requireContext())); + } + }); + avatar.setAvatar(recipient); + if (recipient.isSelf()) { + avatar.setOnClickListener(v -> { + dismiss(); + viewModel.onMessageClicked(requireActivity()); + }); + } + + String name = recipient.isSelf() ? requireContext().getString(R.string.note_to_self) + : recipient.getDisplayName(requireContext()); + fullName.setText(name); + fullName.setVisibility(TextUtils.isEmpty(name) ? View.GONE : View.VISIBLE); + if (recipient.isSystemContact() && !recipient.isSelf()) { + fullName.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_profile_circle_outline_16, 0); + fullName.setCompoundDrawablePadding(ViewUtil.dpToPx(4)); + TextViewCompat.setCompoundDrawableTintList(fullName, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_text_primary))); + } + + String aboutText = recipient.getCombinedAboutAndEmoji(); + if (!Util.isEmpty(aboutText)) { + about.setText(aboutText); + about.setVisibility(View.VISIBLE); + } else { + about.setVisibility(View.GONE); + } + + String usernameNumberString = recipient.hasAUserSetDisplayName(requireContext()) && !recipient.isSelf() + ? recipient.getSmsAddress().transform(PhoneNumberFormatter::prettyPrint).or("").trim() + : ""; + usernameNumber.setText(usernameNumberString); + usernameNumber.setVisibility(TextUtils.isEmpty(usernameNumberString) ? View.GONE : View.VISIBLE); + usernameNumber.setOnLongClickListener(v -> { + Util.copyToClipboard(v.getContext(), usernameNumber.getText().toString()); + ServiceUtil.getVibrator(v.getContext()).vibrate(250); + Toast.makeText(v.getContext(), R.string.RecipientBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + return true; + }); + + noteToSelfDescription.setVisibility(recipient.isSelf() ? View.VISIBLE : View.GONE); + + if (RecipientUtil.isBlockable(recipient)) { + boolean blocked = recipient.isBlocked(); + + blockButton .setVisibility(recipient.isSelf() || blocked ? View.GONE : View.VISIBLE); + unblockButton.setVisibility(recipient.isSelf() || !blocked ? View.GONE : View.VISIBLE); + } else { + blockButton .setVisibility(View.GONE); + unblockButton.setVisibility(View.GONE); + } + + messageButton.setVisibility(!recipient.isSelf() ? View.VISIBLE : View.GONE); + secureCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + insecureCallButton.setVisibility(!recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + secureVideoCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + + if (recipient.isSystemContact() || recipient.isGroup() || recipient.isSelf()) { + addContactButton.setVisibility(View.GONE); + } else { + addContactButton.setVisibility(View.VISIBLE); + addContactButton.setOnClickListener(v -> { + startActivityForResult(RecipientExporter.export(recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT); + }); + } + }); + + viewModel.getCanAddToAGroup().observe(getViewLifecycleOwner(), canAdd -> { + addToGroupButton.setText(groupId == null ? R.string.RecipientBottomSheet_add_to_a_group : R.string.RecipientBottomSheet_add_to_another_group); + addToGroupButton.setVisibility(canAdd ? View.VISIBLE : View.GONE); + }); + + viewModel.getAdminActionStatus().observe(getViewLifecycleOwner(), adminStatus -> { + makeGroupAdminButton.setVisibility(adminStatus.isCanMakeAdmin() ? View.VISIBLE : View.GONE); + removeAdminButton.setVisibility(adminStatus.isCanMakeNonAdmin() ? View.VISIBLE : View.GONE); + removeFromGroupButton.setVisibility(adminStatus.isCanRemove() ? View.VISIBLE : View.GONE); + }); + + viewModel.getIdentity().observe(getViewLifecycleOwner(), identityRecord -> { + viewSafetyNumberButton.setVisibility(identityRecord != null ? View.VISIBLE : View.GONE); + + if (identityRecord != null) { + viewSafetyNumberButton.setOnClickListener(view -> { + dismiss(); + viewModel.onViewSafetyNumberClicked(requireActivity(), identityRecord); + }); + } + }); + + avatar.setOnClickListener(view -> { + dismiss(); + viewModel.onAvatarClicked(requireActivity()); + }); + + messageButton.setOnClickListener(view -> { + dismiss(); + viewModel.onMessageClicked(requireActivity()); + }); + + secureCallButton.setOnClickListener(view -> viewModel.onSecureCallClicked(requireActivity())); + insecureCallButton.setOnClickListener(view -> viewModel.onInsecureCallClicked(requireActivity())); + secureVideoCallButton.setOnClickListener(view -> viewModel.onSecureVideoCallClicked(requireActivity())); + + blockButton.setOnClickListener(view -> viewModel.onBlockClicked(requireActivity())); + unblockButton.setOnClickListener(view -> viewModel.onUnblockClicked(requireActivity())); + + makeGroupAdminButton.setOnClickListener(view -> viewModel.onMakeGroupAdminClicked(requireActivity())); + removeAdminButton.setOnClickListener(view -> viewModel.onRemoveGroupAdminClicked(requireActivity())); + + removeFromGroupButton.setOnClickListener(view -> viewModel.onRemoveFromGroupClicked(requireActivity(), this::dismiss)); + + addToGroupButton.setOnClickListener(view -> { + dismiss(); + viewModel.onAddToGroupButton(requireActivity()); + }); + + viewModel.getAdminActionBusy().observe(getViewLifecycleOwner(), busy -> { + adminActionBusy.setVisibility(busy ? View.VISIBLE : View.GONE); + + makeGroupAdminButton.setEnabled(!busy); + removeAdminButton.setEnabled(!busy); + removeFromGroupButton.setEnabled(!busy); + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_ADD_CONTACT) { + viewModel.onAddedToContacts(); + } + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java new file mode 100644 index 00000000..2a116a27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.recipients.ui.bottomsheet; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class RecipientDialogRepository { + + private static final String TAG = Log.tag(RecipientDialogRepository.class); + + @NonNull private final Context context; + @NonNull private final RecipientId recipientId; + @Nullable private final GroupId groupId; + + RecipientDialogRepository(@NonNull Context context, + @NonNull RecipientId recipientId, + @Nullable GroupId groupId) + { + this.context = context; + this.recipientId = recipientId; + this.groupId = groupId; + } + + @NonNull RecipientId getRecipientId() { + return recipientId; + } + + @Nullable GroupId getGroupId() { + return groupId; + } + + void getIdentity(@NonNull Consumer callback) { + SignalExecutors.BOUNDED.execute( + () -> callback.accept(DatabaseFactory.getIdentityDatabase(context) + .getIdentity(recipientId) + .orNull())); + } + + void getRecipient(@NonNull RecipientCallback recipientCallback) { + SimpleTask.run(SignalExecutors.BOUNDED, + () -> Recipient.resolved(recipientId), + recipientCallback::onRecipient); + } + + void refreshRecipient() { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh user after adding to contacts."); + } + }); + } + + void removeMember(@NonNull Consumer onComplete, @NonNull GroupChangeErrorCallback error) { + SimpleTask.run(SignalExecutors.UNBOUNDED, + () -> { + try { + GroupManager.ejectFromGroup(context, Objects.requireNonNull(groupId).requireV2(), Recipient.resolved(recipientId)); + return true; + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + return false; + }, + onComplete::accept); + } + + void setMemberAdmin(boolean admin, @NonNull Consumer onComplete, @NonNull GroupChangeErrorCallback error) { + SimpleTask.run(SignalExecutors.UNBOUNDED, + () -> { + try { + GroupManager.setMemberAdmin(context, Objects.requireNonNull(groupId).requireV2(), recipientId, admin); + return true; + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + error.onError(GroupChangeFailureReason.fromException(e)); + } + return false; + }, + onComplete::accept); + } + + void getGroupMembership(@NonNull Consumer> onComplete) { + SimpleTask.run(SignalExecutors.UNBOUNDED, + () -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + List groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId); + ArrayList groupRecipients = new ArrayList<>(groupRecords.size()); + + for (GroupDatabase.GroupRecord groupRecord : groupRecords) { + groupRecipients.add(groupRecord.getRecipientId()); + } + + return groupRecipients; + }, + onComplete::accept); + } + + public void getActiveGroupCount(@NonNull Consumer onComplete) { + SignalExecutors.BOUNDED.execute(() -> onComplete.accept(DatabaseFactory.getGroupDatabase(context).getActiveGroupCount())); + } + + interface RecipientCallback { + void onRecipient(@NonNull Recipient recipient); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java new file mode 100644 index 00000000..ba3c44ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -0,0 +1,253 @@ +package org.thoughtcrime.securesms.recipients.ui.bottomsheet; + +import android.app.Activity; +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Objects; + +final class RecipientDialogViewModel extends ViewModel { + + private final Context context; + private final RecipientDialogRepository recipientDialogRepository; + private final LiveData recipient; + private final MutableLiveData identity; + private final LiveData adminActionStatus; + private final LiveData canAddToAGroup; + private final MutableLiveData adminActionBusy; + + private RecipientDialogViewModel(@NonNull Context context, + @NonNull RecipientDialogRepository recipientDialogRepository) + { + this.context = context; + this.recipientDialogRepository = recipientDialogRepository; + this.identity = new MutableLiveData<>(); + this.adminActionBusy = new MutableLiveData<>(false); + + boolean recipientIsSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId()); + + recipient = Recipient.live(recipientDialogRepository.getRecipientId()).getLiveData(); + + if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) { + LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId()); + + LiveData localIsAdmin = source.isSelfAdmin(); + LiveData recipientMemberLevel = Transformations.switchMap(recipient, source::getMemberLevel); + + adminActionStatus = LiveDataUtil.combineLatest(localIsAdmin, recipientMemberLevel, + (localAdmin, memberLevel) -> { + boolean inGroup = memberLevel.isInGroup(); + boolean recipientAdmin = memberLevel == GroupDatabase.MemberLevel.ADMINISTRATOR; + + return new AdminActionStatus(inGroup && localAdmin, + inGroup && localAdmin && !recipientAdmin, + inGroup && localAdmin && recipientAdmin); + }); + } else { + adminActionStatus = new MutableLiveData<>(new AdminActionStatus(false, false, false)); + } + + boolean isSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId()); + if (!isSelf) { + recipientDialogRepository.getIdentity(identity::postValue); + } + + MutableLiveData localGroupCount = new MutableLiveData<>(0); + + canAddToAGroup = LiveDataUtil.combineLatest(recipient, localGroupCount, + (r, count) -> count > 0 && r.isRegistered() && !r.isGroup() && !r.isSelf()); + + recipientDialogRepository.getActiveGroupCount(localGroupCount::postValue); + } + + LiveData getRecipient() { + return recipient; + } + + public LiveData getCanAddToAGroup() { + return canAddToAGroup; + } + + LiveData getAdminActionStatus() { + return adminActionStatus; + } + + LiveData getIdentity() { + return identity; + } + + LiveData getAdminActionBusy() { + return adminActionBusy; + } + + void onMessageClicked(@NonNull Activity activity) { + recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startConversation(activity, recipient, null)); + } + + void onSecureCallClicked(@NonNull FragmentActivity activity) { + recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startVoiceCall(activity, recipient)); + } + + void onInsecureCallClicked(@NonNull FragmentActivity activity) { + recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startInsecureCall(activity, recipient)); + } + + void onSecureVideoCallClicked(@NonNull FragmentActivity activity) { + recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startVideoCall(activity, recipient)); + } + + void onBlockClicked(@NonNull FragmentActivity activity) { + recipientDialogRepository.getRecipient(recipient -> BlockUnblockDialog.showBlockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.blockNonGroup(context, recipient))); + } + + void onUnblockClicked(@NonNull FragmentActivity activity) { + recipientDialogRepository.getRecipient(recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.unblock(context, recipient))); + } + + void onViewSafetyNumberClicked(@NonNull Activity activity, @NonNull IdentityDatabase.IdentityRecord identityRecord) { + activity.startActivity(VerifyIdentityActivity.newIntent(activity, identityRecord)); + } + + void onAvatarClicked(@NonNull Activity activity) { + activity.startActivity(ManageRecipientActivity.newIntent(activity, recipientDialogRepository.getRecipientId())); + } + + void onMakeGroupAdminClicked(@NonNull Activity activity) { + new AlertDialog.Builder(activity) + .setMessage(context.getString(R.string.RecipientBottomSheet_s_will_be_able_to_edit_group, Objects.requireNonNull(recipient.getValue()).getDisplayName(context))) + .setPositiveButton(R.string.RecipientBottomSheet_make_group_admin, + (dialog, which) -> { + adminActionBusy.setValue(true); + recipientDialogRepository.setMemberAdmin(true, result -> { + adminActionBusy.setValue(false); + if (!result) { + Toast.makeText(activity, R.string.ManageGroupActivity_failed_to_update_the_group, Toast.LENGTH_SHORT).show(); + } + }, + this::showErrorToast); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> {}) + .show(); + } + + void onRemoveGroupAdminClicked(@NonNull Activity activity) { + new AlertDialog.Builder(activity) + .setMessage(context.getString(R.string.RecipientBottomSheet_remove_s_as_group_admin, Objects.requireNonNull(recipient.getValue()).getDisplayName(context))) + .setPositiveButton(R.string.RecipientBottomSheet_remove_as_admin, + (dialog, which) -> { + adminActionBusy.setValue(true); + recipientDialogRepository.setMemberAdmin(false, result -> { + adminActionBusy.setValue(false); + if (!result) { + Toast.makeText(activity, R.string.ManageGroupActivity_failed_to_update_the_group, Toast.LENGTH_SHORT).show(); + } + }, + this::showErrorToast); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> {}) + .show(); + } + + void onRemoveFromGroupClicked(@NonNull Activity activity, @NonNull Runnable onSuccess) { + new AlertDialog.Builder(activity) + .setMessage(context.getString(R.string.RecipientBottomSheet_remove_s_from_the_group, Objects.requireNonNull(recipient.getValue()).getDisplayName(context))) + .setPositiveButton(R.string.RecipientBottomSheet_remove, + (dialog, which) -> { + adminActionBusy.setValue(true); + recipientDialogRepository.removeMember(result -> { + adminActionBusy.setValue(false); + if (result) { + onSuccess.run(); + } + }, + this::showErrorToast); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> {}) + .show(); + } + + void onAddedToContacts() { + recipientDialogRepository.refreshRecipient(); + } + + void onAddToGroupButton(@NonNull Activity activity) { + recipientDialogRepository.getGroupMembership(existingGroups -> activity.startActivity(AddToGroupsActivity.newIntent(activity, recipientDialogRepository.getRecipientId(), existingGroups))); + } + + @WorkerThread + private void showErrorToast(@NonNull GroupChangeFailureReason e) { + Util.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); + } + + static class AdminActionStatus { + private final boolean canRemove; + private final boolean canMakeAdmin; + private final boolean canMakeNonAdmin; + + AdminActionStatus(boolean canRemove, boolean canMakeAdmin, boolean canMakeNonAdmin) { + this.canRemove = canRemove; + this.canMakeAdmin = canMakeAdmin; + this.canMakeNonAdmin = canMakeNonAdmin; + } + + boolean isCanRemove() { + return canRemove; + } + + boolean isCanMakeAdmin() { + return canMakeAdmin; + } + + boolean isCanMakeNonAdmin() { + return canMakeNonAdmin; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final RecipientId recipientId; + private final GroupId groupId; + + Factory(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable GroupId groupId) { + this.context = context; + this.recipientId = recipientId; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new RecipientDialogViewModel(context, new RecipientDialogRepository(context, recipientId, groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java new file mode 100644 index 00000000..34e7a018 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.recipients.ui.managerecipient; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityOptionsCompat; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class ManageRecipientActivity extends PassphraseRequiredActivity { + + private static final String RECIPIENT_ID = "RECIPIENT_ID"; + private static final String FROM_CONVERSATION = "FROM_CONVERSATION"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull RecipientId recipientId) { + Intent intent = new Intent(context, ManageRecipientActivity.class); + intent.putExtra(RECIPIENT_ID, recipientId); + return intent; + } + + /** + * Makes the message button behave like back. + */ + public static Intent newIntentFromConversation(@NonNull Context context, @NonNull RecipientId recipientId) { + Intent intent = new Intent(context, ManageRecipientActivity.class); + intent.putExtra(RECIPIENT_ID, recipientId); + intent.putExtra(FROM_CONVERSATION, true); + return intent; + } + + public static @Nullable Bundle createTransitionBundle(@NonNull Context activityContext, @NonNull View from) { + if (activityContext instanceof Activity) { + return ActivityOptionsCompat.makeSceneTransitionAnimation((Activity) activityContext, from, "avatar").toBundle(); + } else { + return null; + } + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + getWindow().getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + setContentView(R.layout.recipient_manage_activity); + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, ManageRecipientFragment.newInstance(getIntent().getParcelableExtra(RECIPIENT_ID), getIntent().getBooleanExtra(FROM_CONVERSATION, false))) + .commitNow(); + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java new file mode 100644 index 00000000..02d80d82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java @@ -0,0 +1,438 @@ +package org.thoughtcrime.securesms.recipients.ui.managerecipient; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProviders; + +import com.takisoft.colorpicker.ColorPickerDialog; +import com.takisoft.colorpicker.ColorStateDrawable; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.AvatarPreviewActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.MuteDialog; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.color.MaterialColors; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.ThreadPhotoRailView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientExporter; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; + +import java.util.Locale; +import java.util.Objects; + +public class ManageRecipientFragment extends LoggingFragment { + private static final String RECIPIENT_ID = "RECIPIENT_ID"; + private static final String FROM_CONVERSATION = "FROM_CONVERSATION"; + + private static final int REQUEST_CODE_RETURN_FROM_MEDIA = 405; + private static final int REQUEST_CODE_ADD_CONTACT = 588; + private static final int REQUEST_CODE_VIEW_CONTACT = 610; + + private ManageRecipientViewModel viewModel; + private GroupMemberListView sharedGroupList; + private Toolbar toolbar; + private TextView title; + private TextView about; + private TextView subtitle; + private ViewGroup internalDetails; + private TextView internalDetailsText; + private View disableProfileSharingButton; + private View contactRow; + private TextView contactText; + private ImageView contactIcon; + private AvatarImageView avatar; + private ThreadPhotoRailView threadPhotoRailView; + private View mediaCard; + private ManageRecipientViewModel.CursorFactory cursorFactory; + private View sharedMediaRow; + private View disappearingMessagesCard; + private View disappearingMessagesRow; + private TextView disappearingMessages; + private View colorRow; + private ImageView colorChip; + private View blockUnblockCard; + private TextView block; + private TextView unblock; + private View groupMembershipCard; + private TextView addToAGroup; + private SwitchCompat muteNotificationsSwitch; + private View muteNotificationsRow; + private TextView muteNotificationsUntilLabel; + private View notificationsCard; + private TextView customNotificationsButton; + private View customNotificationsRow; + private View toggleAllGroups; + private View viewSafetyNumber; + private TextView groupsInCommonCount; + private View messageButton; + private View secureCallButton; + private View insecureCallButton; + private View secureVideoCallButton; + private View chatWallpaperButton; + + static ManageRecipientFragment newInstance(@NonNull RecipientId recipientId, boolean fromConversation) { + ManageRecipientFragment fragment = new ManageRecipientFragment(); + Bundle args = new Bundle(); + + args.putParcelable(RECIPIENT_ID, recipientId); + args.putBoolean(FROM_CONVERSATION, fromConversation); + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + View view = inflater.inflate(R.layout.recipient_manage_fragment, container, false); + + avatar = view.findViewById(R.id.recipient_avatar); + toolbar = view.findViewById(R.id.toolbar); + contactRow = view.findViewById(R.id.recipient_contact_row); + contactText = view.findViewById(R.id.recipient_contact_text); + contactIcon = view.findViewById(R.id.recipient_contact_icon); + title = view.findViewById(R.id.name); + about = view.findViewById(R.id.about); + subtitle = view.findViewById(R.id.username_number); + internalDetails = view.findViewById(R.id.recipient_internal_details); + internalDetailsText = view.findViewById(R.id.recipient_internal_details_text); + disableProfileSharingButton = view.findViewById(R.id.recipient_internal_details_disable_profile_sharing_button); + sharedGroupList = view.findViewById(R.id.shared_group_list); + groupsInCommonCount = view.findViewById(R.id.groups_in_common_count); + threadPhotoRailView = view.findViewById(R.id.recent_photos); + mediaCard = view.findViewById(R.id.recipient_media_card); + sharedMediaRow = view.findViewById(R.id.shared_media_row); + disappearingMessagesCard = view.findViewById(R.id.recipient_disappearing_messages_card); + disappearingMessagesRow = view.findViewById(R.id.disappearing_messages_row); + disappearingMessages = view.findViewById(R.id.disappearing_messages); + colorRow = view.findViewById(R.id.color_row); + colorChip = view.findViewById(R.id.color_chip); + blockUnblockCard = view.findViewById(R.id.recipient_block_and_leave_card); + block = view.findViewById(R.id.block); + unblock = view.findViewById(R.id.unblock); + viewSafetyNumber = view.findViewById(R.id.view_safety_number); + groupMembershipCard = view.findViewById(R.id.recipient_membership_card); + addToAGroup = view.findViewById(R.id.add_to_a_group); + muteNotificationsUntilLabel = view.findViewById(R.id.recipient_mute_notifications_until); + muteNotificationsSwitch = view.findViewById(R.id.recipient_mute_notifications_switch); + muteNotificationsRow = view.findViewById(R.id.recipient_mute_notifications_row); + notificationsCard = view.findViewById(R.id.recipient_notifications_card); + customNotificationsButton = view.findViewById(R.id.recipient_custom_notifications_button); + customNotificationsRow = view.findViewById(R.id.recipient_custom_notifications_row); + toggleAllGroups = view.findViewById(R.id.toggle_all_groups); + messageButton = view.findViewById(R.id.recipient_message); + secureCallButton = view.findViewById(R.id.recipient_voice_call); + insecureCallButton = view.findViewById(R.id.recipient_insecure_voice_call); + secureVideoCallButton = view.findViewById(R.id.recipient_video_call); + chatWallpaperButton = view.findViewById(R.id.chat_wallpaper); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + RecipientId recipientId = Objects.requireNonNull(requireArguments().getParcelable(RECIPIENT_ID)); + boolean fromConversation = requireArguments().getBoolean(FROM_CONVERSATION, false); + ManageRecipientViewModel.Factory factory = new ManageRecipientViewModel.Factory(recipientId); + + viewModel = ViewModelProviders.of(requireActivity(), factory).get(ManageRecipientViewModel.class); + + viewModel.getCanCollapseMemberList().observe(getViewLifecycleOwner(), canCollapseMemberList -> { + if (canCollapseMemberList) { + toggleAllGroups.setVisibility(View.VISIBLE); + toggleAllGroups.setOnClickListener(v -> viewModel.revealCollapsedMembers()); + } else { + toggleAllGroups.setVisibility(View.GONE); + } + }); + + viewModel.getIdentity().observe(getViewLifecycleOwner(), identityRecord -> { + viewSafetyNumber.setVisibility(identityRecord != null ? View.VISIBLE : View.GONE); + + if (identityRecord != null) { + viewSafetyNumber.setOnClickListener(view -> viewModel.onViewSafetyNumberClicked(requireActivity(), identityRecord)); + } + }); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); + toolbar.inflateMenu(R.menu.manage_recipient_fragment); + + if (recipientId.equals(Recipient.self().getId())) { + notificationsCard.setVisibility(View.GONE); + groupMembershipCard.setVisibility(View.GONE); + blockUnblockCard.setVisibility(View.GONE); + contactRow.setVisibility(View.GONE); + } else { + viewModel.getVisibleSharedGroups().observe(getViewLifecycleOwner(), members -> sharedGroupList.setMembers(members)); + viewModel.getSharedGroupsCountSummary().observe(getViewLifecycleOwner(), members -> groupsInCommonCount.setText(members)); + addToAGroup.setOnClickListener(v -> viewModel.onAddToGroupButton(requireActivity())); + sharedGroupList.setRecipientClickListener(recipient -> viewModel.onGroupClicked(requireActivity(), recipient)); + sharedGroupList.setOverScrollMode(View.OVER_SCROLL_NEVER); + } + + viewModel.getTitle().observe(getViewLifecycleOwner(), title::setText); + viewModel.getSubtitle().observe(getViewLifecycleOwner(), text -> { + subtitle.setText(text); + subtitle.setVisibility(TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE); + subtitle.setOnLongClickListener(null); + title.setOnLongClickListener(null); + setCopyToClipboardOnLongPress(TextUtils.isEmpty(text) ? title : subtitle); + }); + viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); + viewModel.getRecipient().observe(getViewLifecycleOwner(), this::presentRecipient); + viewModel.getMediaCursor().observe(getViewLifecycleOwner(), this::presentMediaCursor); + viewModel.getMuteState().observe(getViewLifecycleOwner(), this::presentMuteState); + viewModel.getCanAddToAGroup().observe(getViewLifecycleOwner(), canAdd -> addToAGroup.setVisibility(canAdd ? View.VISIBLE : View.GONE)); + + if (SignalStore.internalValues().recipientDetails()) { + viewModel.getInternalDetails().observe(getViewLifecycleOwner(), internalDetailsText::setText); + disableProfileSharingButton.setOnClickListener(v -> { + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipientId, false)); + }); + internalDetails.setVisibility(View.VISIBLE); + } else { + internalDetails.setVisibility(View.GONE); + } + + disappearingMessagesRow.setOnClickListener(v -> viewModel.handleExpirationSelection(requireContext())); + block.setOnClickListener(v -> viewModel.onBlockClicked(requireActivity())); + unblock.setOnClickListener(v -> viewModel.onUnblockClicked(requireActivity())); + + muteNotificationsRow.setOnClickListener(v -> { + if (muteNotificationsSwitch.isEnabled()) { + muteNotificationsSwitch.toggle(); + } + }); + + customNotificationsRow.setVisibility(View.VISIBLE); + customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(recipientId) + .show(requireFragmentManager(), "CUSTOM_NOTIFICATIONS")); + + //noinspection CodeBlock2Expr + if (NotificationChannels.supported()) { + viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { + customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageRecipientActivity_on + : R.string.ManageRecipientActivity_off); + }); + } + + viewModel.getCanBlock().observe(getViewLifecycleOwner(), + canBlock -> block.setVisibility(canBlock ? View.VISIBLE : View.GONE)); + + viewModel.getCanUnblock().observe(getViewLifecycleOwner(), + canUnblock -> unblock.setVisibility(canUnblock ? View.VISIBLE : View.GONE)); + + messageButton.setOnClickListener(v -> { + if (fromConversation) { + requireActivity().onBackPressed(); + } else { + viewModel.onMessage(requireActivity()); + } + }); + secureCallButton.setOnClickListener(v -> viewModel.onSecureCall(requireActivity())); + insecureCallButton.setOnClickListener(v -> viewModel.onInsecureCall(requireActivity())); + secureVideoCallButton.setOnClickListener(v -> viewModel.onSecureVideoCall(requireActivity())); + chatWallpaperButton.setOnClickListener(v -> startActivity(ChatWallpaperActivity.createIntent(requireContext(), recipientId))); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_RETURN_FROM_MEDIA) { + applyMediaCursorFactory(); + } else if (requestCode == REQUEST_CODE_ADD_CONTACT) { + viewModel.onAddedToContacts(); + } else if (requestCode == REQUEST_CODE_VIEW_CONTACT) { + viewModel.onFinishedViewingContact(); + } + } + + private void presentRecipient(@NonNull Recipient recipient) { + if (recipient.isSystemContact()) { + contactText.setText(R.string.ManageRecipientActivity_this_person_is_in_your_contacts); + contactIcon.setVisibility(View.VISIBLE); + contactRow.setOnClickListener(v -> { + startActivityForResult(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()), REQUEST_CODE_VIEW_CONTACT); + }); + } else { + contactText.setText(R.string.ManageRecipientActivity_add_to_system_contacts); + contactIcon.setVisibility(View.GONE); + contactRow.setOnClickListener(v -> { + startActivityForResult(RecipientExporter.export(recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT); + }); + } + + String aboutText = recipient.getCombinedAboutAndEmoji(); + about.setText(aboutText); + about.setVisibility(Util.isEmpty(aboutText) ? View.GONE : View.VISIBLE); + + disappearingMessagesCard.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); + addToAGroup.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); + + MaterialColor recipientColor = recipient.getColor(); + avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new FallbackPhoto80dp(R.drawable.ic_profile_80, recipientColor.toAvatarColor(requireContext())); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new FallbackPhoto80dp(R.drawable.ic_note_80, recipientColor.toAvatarColor(requireContext())); + } + }); + avatar.setAvatar(recipient); + avatar.setOnClickListener(v -> { + FragmentActivity activity = requireActivity(); + activity.startActivity(AvatarPreviewActivity.intentFromRecipientId(activity, recipient.getId()), + AvatarPreviewActivity.createTransitionBundle(activity, avatar)); + }); + + @ColorInt int color = recipientColor.toActionBarColor(requireContext()); + Drawable[] colorDrawable = new Drawable[]{ContextCompat.getDrawable(requireContext(), R.drawable.colorpickerpreference_pref_swatch)}; + colorChip.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); + colorRow.setOnClickListener(v -> handleColorSelection(color)); + + secureCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + insecureCallButton.setVisibility(!recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + secureVideoCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + } + + private void presentMediaCursor(ManageRecipientViewModel.MediaCursor mediaCursor) { + if (mediaCursor == null) return; + sharedMediaRow.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(requireContext(), mediaCursor.getThreadId()))); + + setMediaCursorFactory(mediaCursor.getMediaCursorFactory()); + + threadPhotoRailView.setListener(mediaRecord -> + startActivityForResult(MediaPreviewActivity.intentFromMediaRecord(requireContext(), + mediaRecord, + ViewUtil.isLtr(threadPhotoRailView)), + REQUEST_CODE_RETURN_FROM_MEDIA)); + } + + private void presentMuteState(@NonNull ManageRecipientViewModel.MuteState muteState) { + if (muteNotificationsSwitch.isChecked() != muteState.isMuted()) { + muteNotificationsSwitch.setOnCheckedChangeListener(null); + muteNotificationsSwitch.setChecked(muteState.isMuted()); + } + + muteNotificationsSwitch.setEnabled(true); + muteNotificationsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + MuteDialog.show(requireContext(), viewModel::setMuteUntil, () -> muteNotificationsSwitch.setChecked(false)); + } else { + viewModel.clearMuteUntil(); + } + }); + muteNotificationsUntilLabel.setVisibility(muteState.isMuted() ? View.VISIBLE : View.GONE); + + if (muteState.isMuted()) { + muteNotificationsUntilLabel.setText(getString(R.string.ManageRecipientActivity_until_s, + DateUtils.getTimeString(requireContext(), + Locale.getDefault(), + muteState.getMutedUntil()))); + } + } + + private void handleColorSelection(@ColorInt int currentColor) { + @ColorInt int[] colors = MaterialColors.CONVERSATION_PALETTE.asConversationColorArray(requireContext()); + + ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(requireContext()) + .setSelectedColor(currentColor) + .setColors(colors) + .setSize(ColorPickerDialog.SIZE_SMALL) + .setSortColors(false) + .setColumns(3) + .build(); + + ColorPickerDialog dialog = new ColorPickerDialog(requireActivity(), color -> viewModel.onSelectColor(color), params); + dialog.setTitle(R.string.ManageRecipientActivity_chat_color); + dialog.show(); + } + + public boolean onMenuItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.action_edit) { + startActivity(EditProfileActivity.getIntentForUserProfileEdit(requireActivity())); + return true; + } + + return false; + } + + private void setMediaCursorFactory(@Nullable ManageRecipientViewModel.CursorFactory cursorFactory) { + if (this.cursorFactory != cursorFactory) { + this.cursorFactory = cursorFactory; + applyMediaCursorFactory(); + } + } + + private void applyMediaCursorFactory() { + Context context = getContext(); + if (context == null) return; + if (cursorFactory != null) { + Cursor cursor = cursorFactory.create(); + getViewLifecycleOwner().getLifecycle().addObserver(new LifecycleCursorWrapper(cursor)); + + threadPhotoRailView.setCursor(GlideApp.with(context), cursor); + mediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE); + } else { + threadPhotoRailView.setCursor(GlideApp.with(context), null); + mediaCard.setVisibility(View.GONE); + } + } + + private static void setCopyToClipboardOnLongPress(@NonNull TextView textView) { + textView.setOnLongClickListener(v -> { + Util.copyToClipboard(v.getContext(), textView.getText().toString()); + ServiceUtil.getVibrator(v.getContext()).vibrate(250); + Toast.makeText(v.getContext(), R.string.RecipientBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + return true; + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java new file mode 100644 index 00000000..c031f138 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.recipients.ui.managerecipient; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.color.MaterialColors; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +final class ManageRecipientRepository { + + private static final String TAG = Log.tag(ManageRecipientRepository.class); + + private final Context context; + private final RecipientId recipientId; + + ManageRecipientRepository(@NonNull Context context, @NonNull RecipientId recipientId) { + this.context = context; + this.recipientId = recipientId; + } + + public RecipientId getRecipientId() { + return recipientId; + } + + void getThreadId(@NonNull Consumer onGetThreadId) { + SignalExecutors.BOUNDED.execute(() -> onGetThreadId.accept(getThreadId())); + } + + @WorkerThread + private long getThreadId() { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Recipient groupRecipient = Recipient.resolved(recipientId); + + return threadDatabase.getThreadIdFor(groupRecipient); + } + + void getIdentity(@NonNull Consumer callback) { + SignalExecutors.BOUNDED.execute(() -> callback.accept(DatabaseFactory.getIdentityDatabase(context) + .getIdentity(recipientId) + .orNull())); + } + + void setExpiration(int newExpirationTime) { + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime); + OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L); + MessageSender.send(context, outgoingMessage, getThreadId(), false, null); + }); + } + + void getGroupMembership(@NonNull Consumer> onComplete) { + SignalExecutors.BOUNDED.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + List groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId); + ArrayList groupRecipients = new ArrayList<>(groupRecords.size()); + + for (GroupDatabase.GroupRecord groupRecord : groupRecords) { + groupRecipients.add(groupRecord.getRecipientId()); + } + + onComplete.accept(groupRecipients); + }); + } + + public void getRecipient(@NonNull Consumer recipientCallback) { + SignalExecutors.BOUNDED.execute(() -> recipientCallback.accept(Recipient.resolved(recipientId))); + } + + void setMuteUntil(long until) { + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)); + } + + void setColor(int color) { + SignalExecutors.BOUNDED.execute(() -> { + MaterialColor selectedColor = MaterialColors.CONVERSATION_PALETTE.getByColor(context, color); + if (selectedColor != null) { + DatabaseFactory.getRecipientDatabase(context).setColor(recipientId, selectedColor); + ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(recipientId)); + } + }); + } + + void refreshRecipient() { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh user after adding to contacts."); + } + }); + } + + @WorkerThread + @NonNull List getSharedGroups(@NonNull RecipientId recipientId) { + return Stream.of(DatabaseFactory.getGroupDatabase(context) + .getPushGroupsContainingMember(recipientId)) + .filter(g -> g.getMembers().contains(Recipient.self().getId())) + .map(GroupDatabase.GroupRecord::getRecipientId) + .map(Recipient::resolved) + .sortBy(gr -> gr.getDisplayName(context)) + .toList(); + } + + void getActiveGroupCount(@NonNull Consumer onComplete) { + SignalExecutors.BOUNDED.execute(() -> onComplete.accept(DatabaseFactory.getGroupDatabase(context).getActiveGroupCount())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java new file mode 100644 index 00000000..dcea1518 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java @@ -0,0 +1,372 @@ +package org.thoughtcrime.securesms.recipients.ui.managerecipient; + +import android.app.Activity; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.thoughtcrime.securesms.ExpirationDialog; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.loaders.MediaLoader; +import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.List; +import java.util.UUID; + +public final class ManageRecipientViewModel extends ViewModel { + + private static final int MAX_UNCOLLAPSED_GROUPS = 6; + private static final int SHOW_COLLAPSED_GROUPS = 5; + + private final Context context; + private final ManageRecipientRepository manageRecipientRepository; + private final LiveData title; + private final LiveData subtitle; + private final LiveData internalDetails; + private final LiveData disappearingMessageTimer; + private final MutableLiveData identity; + private final LiveData recipient; + private final MutableLiveData mediaCursor; + private final LiveData muteState; + private final LiveData hasCustomNotifications; + private final LiveData canCollapseMemberList; + private final DefaultValueLiveData groupListCollapseState; + private final LiveData canBlock; + private final LiveData canUnblock; + private final LiveData> visibleSharedGroups; + private final LiveData sharedGroupsCountSummary; + private final LiveData canAddToAGroup; + + private ManageRecipientViewModel(@NonNull Context context, @NonNull ManageRecipientRepository manageRecipientRepository) { + this.context = context; + this.manageRecipientRepository = manageRecipientRepository; + this.recipient = Recipient.live(manageRecipientRepository.getRecipientId()).getLiveData(); + this.title = Transformations.map(recipient, r -> getDisplayTitle(r, context) ); + this.subtitle = Transformations.map(recipient, r -> getDisplaySubtitle(r, context)); + this.identity = new MutableLiveData<>(); + this.mediaCursor = new MutableLiveData<>(null); + this.groupListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); + this.disappearingMessageTimer = Transformations.map(this.recipient, r -> ExpirationUtil.getExpirationDisplayValue(context, r.getExpireMessages())); + this.muteState = Transformations.map(this.recipient, r -> new MuteState(r.getMuteUntil(), r.isMuted())); + this.hasCustomNotifications = Transformations.map(this.recipient, r -> r.getNotificationChannel() != null || !NotificationChannels.supported()); + this.canBlock = Transformations.map(this.recipient, r -> RecipientUtil.isBlockable(r) && !r.isBlocked()); + this.canUnblock = Transformations.map(this.recipient, Recipient::isBlocked); + this.internalDetails = Transformations.map(this.recipient, this::populateInternalDetails); + + manageRecipientRepository.getThreadId(this::onThreadIdLoaded); + + LiveData> allSharedGroups = LiveDataUtil.mapAsync(this.recipient, r -> manageRecipientRepository.getSharedGroups(r.getId())); + + this.sharedGroupsCountSummary = Transformations.map(allSharedGroups, list -> { + int size = list.size(); + return size == 0 ? context.getString(R.string.ManageRecipientActivity_no_groups_in_common) + : context.getResources().getQuantityString(R.plurals.ManageRecipientActivity_d_groups_in_common, size, size); + }); + + this.canCollapseMemberList = LiveDataUtil.combineLatest(this.groupListCollapseState, + Transformations.map(allSharedGroups, m -> m.size() > MAX_UNCOLLAPSED_GROUPS), + (state, hasEnoughMembers) -> state != CollapseState.OPEN && hasEnoughMembers); + this.visibleSharedGroups = Transformations.map(LiveDataUtil.combineLatest(allSharedGroups, + this.groupListCollapseState, + ManageRecipientViewModel::filterSharedGroupList), + recipients -> Stream.of(recipients).map(r -> new GroupMemberEntry.FullMember(r, false)).toList()); + + + boolean isSelf = manageRecipientRepository.getRecipientId().equals(Recipient.self().getId()); + if (!isSelf) { + manageRecipientRepository.getIdentity(identity::postValue); + } + + MutableLiveData localGroupCount = new MutableLiveData<>(0); + + this.canAddToAGroup = LiveDataUtil.combineLatest(recipient, + localGroupCount, + (r, count) -> count > 0 && r.isRegistered() && !r.isGroup() && !r.isSelf()); + + manageRecipientRepository.getActiveGroupCount(localGroupCount::postValue); + } + + private static @NonNull String getDisplayTitle(@NonNull Recipient recipient, @NonNull Context context) { + if (recipient.isSelf()) { + return context.getString(R.string.note_to_self); + } else { + return recipient.getDisplayName(context); + } + } + + private static @NonNull String getDisplaySubtitle(@NonNull Recipient recipient, @NonNull Context context) { + if (!recipient.isSelf() && recipient.hasAUserSetDisplayName(context)) { + return recipient.getSmsAddress().transform(PhoneNumberFormatter::prettyPrint).or("").trim(); + } else { + return ""; + } + } + + @WorkerThread + private void onThreadIdLoaded(long threadId) { + mediaCursor.postValue(new MediaCursor(threadId, + () -> new ThreadMediaLoader(context, threadId, MediaLoader.MediaType.GALLERY, MediaDatabase.Sorting.Newest).getCursor())); + } + + LiveData getTitle() { + return title; + } + + LiveData getSubtitle() { + return subtitle; + } + + LiveData getInternalDetails() { + return internalDetails; + } + + LiveData getRecipient() { + return recipient; + } + + LiveData getCanAddToAGroup() { + return canAddToAGroup; + } + + LiveData getMediaCursor() { + return mediaCursor; + } + + LiveData getMuteState() { + return muteState; + } + + LiveData getDisappearingMessageTimer() { + return disappearingMessageTimer; + } + + LiveData hasCustomNotifications() { + return hasCustomNotifications; + } + + LiveData getCanCollapseMemberList() { + return canCollapseMemberList; + } + + LiveData getCanBlock() { + return canBlock; + } + + LiveData getCanUnblock() { + return canUnblock; + } + + void handleExpirationSelection(@NonNull Context context) { + withRecipient(recipient -> + ExpirationDialog.show(context, + recipient.getExpireMessages(), + manageRecipientRepository::setExpiration)); + } + + void setMuteUntil(long muteUntil) { + manageRecipientRepository.setMuteUntil(muteUntil); + } + + void clearMuteUntil() { + manageRecipientRepository.setMuteUntil(0); + } + + void revealCollapsedMembers() { + groupListCollapseState.setValue(CollapseState.OPEN); + } + + void onAddToGroupButton(@NonNull Activity activity) { + manageRecipientRepository.getGroupMembership(existingGroups -> Util.runOnMain(() -> activity.startActivity(AddToGroupsActivity.newIntent(activity, manageRecipientRepository.getRecipientId(), existingGroups)))); + } + + private void withRecipient(@NonNull Consumer mainThreadRecipientCallback) { + manageRecipientRepository.getRecipient(recipient -> Util.runOnMain(() -> mainThreadRecipientCallback.accept(recipient))); + } + + private static @NonNull List filterSharedGroupList(@NonNull List groups, + @NonNull CollapseState collapseState) + { + if (collapseState == CollapseState.COLLAPSED && groups.size() > MAX_UNCOLLAPSED_GROUPS) { + return groups.subList(0, SHOW_COLLAPSED_GROUPS); + } else { + return groups; + } + } + + LiveData getIdentity() { + return identity; + } + + void onBlockClicked(@NonNull FragmentActivity activity) { + withRecipient(recipient -> BlockUnblockDialog.showBlockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.blockNonGroup(context, recipient))); + } + + void onUnblockClicked(@NonNull FragmentActivity activity) { + withRecipient(recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.unblock(context, recipient))); + } + + void onViewSafetyNumberClicked(@NonNull Activity activity, @NonNull IdentityDatabase.IdentityRecord identityRecord) { + activity.startActivity(VerifyIdentityActivity.newIntent(activity, identityRecord)); + } + + LiveData> getVisibleSharedGroups() { + return visibleSharedGroups; + } + + LiveData getSharedGroupsCountSummary() { + return sharedGroupsCountSummary; + } + + void onSelectColor(int color) { + manageRecipientRepository.setColor(color); + } + + void onGroupClicked(@NonNull Activity activity, @NonNull Recipient recipient) { + CommunicationActions.startConversation(activity, recipient, null); + activity.finish(); + } + + void onMessage(@NonNull FragmentActivity activity) { + withRecipient(r -> { + CommunicationActions.startConversation(activity, r, null); + activity.finish(); + }); + } + + void onSecureCall(@NonNull FragmentActivity activity) { + withRecipient(r -> CommunicationActions.startVoiceCall(activity, r)); + } + + void onInsecureCall(@NonNull FragmentActivity activity) { + withRecipient(r -> CommunicationActions.startInsecureCall(activity, r)); + } + + void onSecureVideoCall(@NonNull FragmentActivity activity) { + withRecipient(r -> CommunicationActions.startVideoCall(activity, r)); + } + + void onAddedToContacts() { + manageRecipientRepository.refreshRecipient(); + } + + void onFinishedViewingContact() { + manageRecipientRepository.refreshRecipient(); + } + + private @NonNull String populateInternalDetails(@NonNull Recipient recipient) { + if (!SignalStore.internalValues().recipientDetails()) { + return ""; + } + + String profileKeyBase64 = recipient.getProfileKey() != null ? Base64.encodeBytes(recipient.getProfileKey()) : "None"; + String profileKeyHex = recipient.getProfileKey() != null ? Hex.toStringCondensed(recipient.getProfileKey()) : "None"; + return String.format("-- Profile Name --\n[%s] [%s]\n\n" + + "-- Profile Sharing --\n%s\n\n" + + "-- Profile Key (Base64) --\n%s\n\n" + + "-- Profile Key (Hex) --\n%s\n\n" + + "-- Sealed Sender Mode --\n%s\n\n" + + "-- UUID --\n%s\n\n" + + "-- RecipientId --\n%s", + recipient.getProfileName().getGivenName(), recipient.getProfileName().getFamilyName(), + recipient.isProfileSharing(), + profileKeyBase64, + profileKeyHex, + recipient.getUnidentifiedAccessMode(), + recipient.getUuid().transform(UUID::toString).or("None"), + recipient.getId().serialize()); + } + + static final class MediaCursor { + private final long threadId; + @NonNull private final CursorFactory mediaCursorFactory; + + private MediaCursor(long threadId, + @NonNull CursorFactory mediaCursorFactory) + { + this.threadId = threadId; + this.mediaCursorFactory = mediaCursorFactory; + } + + long getThreadId() { + return threadId; + } + + @NonNull CursorFactory getMediaCursorFactory() { + return mediaCursorFactory; + } + } + + static final class MuteState { + private final long mutedUntil; + private final boolean isMuted; + + MuteState(long mutedUntil, boolean isMuted) { + this.mutedUntil = mutedUntil; + this.isMuted = isMuted; + } + + long getMutedUntil() { + return mutedUntil; + } + + public boolean isMuted() { + return isMuted; + } + } + + private enum CollapseState { + OPEN, + COLLAPSED + } + + interface CursorFactory { + Cursor create(); + } + + public static class Factory implements ViewModelProvider.Factory { + private final Context context; + private final RecipientId recipientId; + + public Factory(@NonNull RecipientId recipientId) { + this.context = ApplicationDependencies.getApplication(); + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new ManageRecipientViewModel(context, new ManageRecipientRepository(context, recipientId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsDialogFragment.java new file mode 100644 index 00000000..32363acf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsDialogFragment.java @@ -0,0 +1,268 @@ +package org.thoughtcrime.securesms.recipients.ui.notifications; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.function.Consumer; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Objects; + +public class CustomNotificationsDialogFragment extends DialogFragment { + + private static final short MESSAGE_RINGTONE_PICKER_REQUEST_CODE = 13562; + private static final short CALL_RINGTONE_PICKER_REQUEST_CODE = 23621; + + private static final String ARG_RECIPIENT_ID = "recipient_id"; + + private View customNotificationsRow; + private SwitchCompat customNotificationsSwitch; + private View soundRow; + private View soundLabel; + private TextView soundSelector; + private View messageVibrateRow; + private View messageVibrateLabel; + private TextView messageVibrateSelector; + private SwitchCompat messageVibrateSwitch; + private View callHeading; + private View ringtoneRow; + private TextView ringtoneSelector; + private View callVibrateRow; + private TextView callVibrateSelector; + + private CustomNotificationsViewModel viewModel; + + public static DialogFragment create(@NonNull RecipientId recipientId) { + DialogFragment fragment = new CustomNotificationsDialogFragment(); + Bundle args = new Bundle(); + + args.putParcelable(ARG_RECIPIENT_ID, recipientId); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_Animated); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.custom_notifications_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (resultCode == Activity.RESULT_OK && data != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + if (requestCode == MESSAGE_RINGTONE_PICKER_REQUEST_CODE) { + viewModel.setMessageSound(uri); + } else if (requestCode == CALL_RINGTONE_PICKER_REQUEST_CODE) { + viewModel.setCallSound(uri); + } + } + } + + private void initializeViewModel() { + Bundle arguments = requireArguments(); + RecipientId recipientId = Objects.requireNonNull(arguments.getParcelable(ARG_RECIPIENT_ID)); + CustomNotificationsRepository repository = new CustomNotificationsRepository(requireContext(), recipientId); + CustomNotificationsViewModel.Factory factory = new CustomNotificationsViewModel.Factory(recipientId, repository); + + viewModel = ViewModelProviders.of(this, factory).get(CustomNotificationsViewModel.class); + } + + private void initializeViews(@NonNull View view) { + customNotificationsRow = view.findViewById(R.id.custom_notifications_row); + customNotificationsSwitch = view.findViewById(R.id.custom_notifications_enable_switch); + soundRow = view.findViewById(R.id.custom_notifications_sound_row); + soundLabel = view.findViewById(R.id.custom_notifications_sound_label); + soundSelector = view.findViewById(R.id.custom_notifications_sound_selection); + messageVibrateSwitch = view.findViewById(R.id.custom_notifications_vibrate_switch); + messageVibrateRow = view.findViewById(R.id.custom_notifications_message_vibrate_row); + messageVibrateLabel = view.findViewById(R.id.custom_notifications_message_vibrate_label); + messageVibrateSelector = view.findViewById(R.id.custom_notifications_message_vibrate_selector); + callHeading = view.findViewById(R.id.custom_notifications_call_settings_section_header); + ringtoneRow = view.findViewById(R.id.custom_notifications_ringtone_row); + ringtoneSelector = view.findViewById(R.id.custom_notifications_ringtone_selection); + callVibrateRow = view.findViewById(R.id.custom_notifications_call_vibrate_row); + callVibrateSelector = view.findViewById(R.id.custom_notifications_call_vibrate_selectior); + + Toolbar toolbar = view.findViewById(R.id.custom_notifications_toolbar); + + toolbar.setNavigationOnClickListener(v -> dismissAllowingStateLoss()); + + CompoundButton.OnCheckedChangeListener onCustomNotificationsSwitchCheckChangedListener = (buttonView, isChecked) -> { + viewModel.setHasCustomNotifications(isChecked); + }; + + viewModel.isInitialLoadComplete().observe(getViewLifecycleOwner(), customNotificationsSwitch::setEnabled); + + if (NotificationChannels.supported()) { + viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { + if (customNotificationsSwitch.isChecked() != hasCustomNotifications) { + customNotificationsSwitch.setOnCheckedChangeListener(null); + customNotificationsSwitch.setChecked(hasCustomNotifications); + } + + customNotificationsSwitch.setOnCheckedChangeListener(onCustomNotificationsSwitchCheckChangedListener); + customNotificationsRow.setOnClickListener(v -> customNotificationsSwitch.toggle()); + + soundRow.setEnabled(hasCustomNotifications); + soundLabel.setEnabled(hasCustomNotifications); + messageVibrateRow.setEnabled(hasCustomNotifications); + messageVibrateLabel.setEnabled(hasCustomNotifications); + soundSelector.setVisibility(hasCustomNotifications ? View.VISIBLE : View.GONE); + messageVibrateSwitch.setVisibility(hasCustomNotifications ? View.VISIBLE : View.GONE); + }); + + messageVibrateSelector.setVisibility(View.GONE); + messageVibrateSwitch.setVisibility(View.VISIBLE); + + messageVibrateRow.setOnClickListener(v -> messageVibrateSwitch.toggle()); + + CompoundButton.OnCheckedChangeListener onVibrateSwitchCheckChangedListener = (buttonView, isChecked) -> viewModel.setMessageVibrate(RecipientDatabase.VibrateState.fromBoolean(isChecked)); + + viewModel.getMessageVibrateToggle().observe(getViewLifecycleOwner(), vibrateEnabled -> { + if (messageVibrateSwitch.isChecked() != vibrateEnabled) { + messageVibrateSwitch.setOnCheckedChangeListener(null); + messageVibrateSwitch.setChecked(vibrateEnabled); + } + + messageVibrateSwitch.setOnCheckedChangeListener(onVibrateSwitchCheckChangedListener); + }); + } else { + customNotificationsRow.setVisibility(View.GONE); + + messageVibrateSwitch.setVisibility(View.GONE); + messageVibrateSelector.setVisibility(View.VISIBLE); + + soundRow.setEnabled(true); + soundLabel.setEnabled(true); + messageVibrateRow.setEnabled(true); + messageVibrateLabel.setEnabled(true); + soundSelector.setVisibility(View.VISIBLE); + + viewModel.getMessageVibrateState().observe(getViewLifecycleOwner(), vibrateState -> presentVibrateState(vibrateState, this.messageVibrateRow, this.messageVibrateSelector, (w) -> viewModel.setMessageVibrate(w))); + } + + viewModel.getNotificationSound().observe(getViewLifecycleOwner(), sound -> { + soundSelector.setText(getRingtoneSummary(requireContext(), sound, Settings.System.DEFAULT_NOTIFICATION_URI)); + soundSelector.setTag(sound); + soundRow.setOnClickListener(v -> launchSoundSelector(sound, false)); + }); + + viewModel.getShowCallingOptions().observe(getViewLifecycleOwner(), showCalling -> { + callHeading.setVisibility(showCalling ? View.VISIBLE : View.GONE); + ringtoneRow.setVisibility(showCalling ? View.VISIBLE : View.GONE); + callVibrateRow.setVisibility(showCalling ? View.VISIBLE : View.GONE); + }); + + viewModel.getRingtone().observe(getViewLifecycleOwner(), sound -> { + ringtoneSelector.setText(getRingtoneSummary(requireContext(), sound, Settings.System.DEFAULT_RINGTONE_URI)); + ringtoneSelector.setTag(sound); + ringtoneRow.setOnClickListener(v -> launchSoundSelector(sound, true)); + }); + + viewModel.getCallingVibrateState().observe(getViewLifecycleOwner(), vibrateState -> presentVibrateState(vibrateState, this.callVibrateRow, this.callVibrateSelector, (w) -> viewModel.setCallingVibrate(w))); + } + + private void presentVibrateState(@NonNull RecipientDatabase.VibrateState vibrateState, + @NonNull View vibrateRow, + @NonNull TextView vibrateSelector, + @NonNull Consumer onSelect) + { + vibrateSelector.setText(getVibrateSummary(requireContext(), vibrateState)); + vibrateRow.setOnClickListener(v -> new AlertDialog.Builder(requireContext()) + .setTitle(R.string.CustomNotificationsDialogFragment__vibrate) + .setSingleChoiceItems(R.array.recipient_vibrate_entries, vibrateState.ordinal(), ((dialog, which) -> { + onSelect.accept(RecipientDatabase.VibrateState.fromId(which)); + dialog.dismiss(); + })) + .setNegativeButton(android.R.string.cancel, null) + .show()); + } + + private @NonNull String getRingtoneSummary(@NonNull Context context, @Nullable Uri ringtone, @Nullable Uri defaultNotificationUri) { + if (ringtone == null || ringtone.equals(defaultNotificationUri)) { + return context.getString(R.string.CustomNotificationsDialogFragment__default); + } else if (ringtone.toString().isEmpty()) { + return context.getString(R.string.preferences__silent); + } else { + Ringtone tone = RingtoneManager.getRingtone(getActivity(), ringtone); + + if (tone != null) { + return tone.getTitle(context); + } + } + + return context.getString(R.string.CustomNotificationsDialogFragment__default); + } + + private void launchSoundSelector(@Nullable Uri current, boolean calls) { + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + + if (current == null) current = calls ? Settings.System.DEFAULT_RINGTONE_URI : Settings.System.DEFAULT_NOTIFICATION_URI; + else if (current.toString().isEmpty()) current = null; + + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, calls ? RingtoneManager.TYPE_RINGTONE : RingtoneManager.TYPE_NOTIFICATION); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultSound(calls)); + + startActivityForResult(intent, calls ? CALL_RINGTONE_PICKER_REQUEST_CODE : MESSAGE_RINGTONE_PICKER_REQUEST_CODE); + } + + private Uri defaultSound(boolean calls) { + Uri defaultValue; + + if (calls) defaultValue = TextSecurePreferences.getCallNotificationRingtone(requireContext()); + else defaultValue = TextSecurePreferences.getNotificationRingtone(requireContext()); + return defaultValue; + } + + private static @NonNull String getVibrateSummary(@NonNull Context context, @NonNull RecipientDatabase.VibrateState vibrateState) { + switch (vibrateState) { + case DEFAULT : return context.getString(R.string.CustomNotificationsDialogFragment__default); + case ENABLED : return context.getString(R.string.CustomNotificationsDialogFragment__enabled); + case DISABLED : return context.getString(R.string.CustomNotificationsDialogFragment__disabled); + default : throw new AssertionError(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsRepository.java new file mode 100644 index 00000000..9925bb28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsRepository.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.recipients.ui.notifications; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +class CustomNotificationsRepository { + + private final Context context; + private final RecipientId recipientId; + + CustomNotificationsRepository(@NonNull Context context, @NonNull RecipientId recipientId) { + this.context = context; + this.recipientId = recipientId; + } + + void onLoad(@NonNull Runnable onLoaded) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + + if (NotificationChannels.supported() && recipient.getNotificationChannel() != null) { + recipientDatabase.setMessageRingtone(recipient.getId(), NotificationChannels.getMessageRingtone(context, recipient)); + recipientDatabase.setMessageVibrate(recipient.getId(), RecipientDatabase.VibrateState.fromBoolean(NotificationChannels.getMessageVibrate(context, recipient))); + + NotificationChannels.ensureCustomChannelConsistency(context); + } + + onLoaded.run(); + }); + } + + void setHasCustomNotifications(final boolean hasCustomNotifications) { + SignalExecutors.SERIAL.execute(() -> { + if (hasCustomNotifications) { + createCustomNotificationChannel(); + } else { + deleteCustomNotificationChannel(); + } + }); + } + + void setMessageVibrate(final RecipientDatabase.VibrateState vibrateState) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + + DatabaseFactory.getRecipientDatabase(context).setMessageVibrate(recipient.getId(), vibrateState); + NotificationChannels.updateMessageVibrate(context, recipient, vibrateState); + }); + } + + void setCallingVibrate(final RecipientDatabase.VibrateState vibrateState) { + SignalExecutors.SERIAL.execute(() -> DatabaseFactory.getRecipientDatabase(context).setCallVibrate(recipientId, vibrateState)); + } + + void setMessageSound(@Nullable Uri sound) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + Uri defaultValue = TextSecurePreferences.getNotificationRingtone(context); + Uri newValue; + + if (defaultValue.equals(sound)) newValue = null; + else if (sound == null) newValue = Uri.EMPTY; + else newValue = sound; + + DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(recipient.getId(), newValue); + NotificationChannels.updateMessageRingtone(context, recipient, newValue); + }); + } + + void setCallSound(@Nullable Uri sound) { + SignalExecutors.SERIAL.execute(() -> { + Uri defaultValue = TextSecurePreferences.getCallNotificationRingtone(context); + Uri newValue; + + if (defaultValue.equals(sound)) newValue = null; + else if (sound == null) newValue = Uri.EMPTY; + else newValue = sound; + + DatabaseFactory.getRecipientDatabase(context).setCallRingtone(recipientId, newValue); + }); + } + + @WorkerThread + private void createCustomNotificationChannel() { + Recipient recipient = getRecipient(); + String channelId = NotificationChannels.createChannelFor(context, recipient); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), channelId); + } + + @WorkerThread + private void deleteCustomNotificationChannel() { + Recipient recipient = getRecipient(); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), null); + NotificationChannels.deleteChannelFor(context, recipient); + } + + @WorkerThread + private @NonNull Recipient getRecipient() { + return Recipient.resolved(recipientId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsViewModel.java new file mode 100644 index 00000000..90c0caa6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/notifications/CustomNotificationsViewModel.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.recipients.ui.notifications; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public final class CustomNotificationsViewModel extends ViewModel { + + private final LiveData hasCustomNotifications; + private final LiveData messageVibrateState; + private final LiveData notificationSound; + private final CustomNotificationsRepository repository; + private final MutableLiveData isInitialLoadComplete = new MutableLiveData<>(); + private final LiveData showCallingOptions; + private final LiveData ringtone; + private final LiveData callingVibrateState; + private final LiveData messageVibrateToggle; + + private CustomNotificationsViewModel(@NonNull RecipientId recipientId, @NonNull CustomNotificationsRepository repository) { + LiveData recipient = Recipient.live(recipientId).getLiveData(); + + this.repository = repository; + this.hasCustomNotifications = Transformations.map(recipient, r -> r.getNotificationChannel() != null || !NotificationChannels.supported()); + this.callingVibrateState = Transformations.map(recipient, Recipient::getCallVibrate); + this.messageVibrateState = Transformations.map(recipient, Recipient::getMessageVibrate); + this.notificationSound = Transformations.map(recipient, Recipient::getMessageRingtone); + this.showCallingOptions = Transformations.map(recipient, r -> !r.isGroup() && r.isRegistered()); + this.ringtone = Transformations.map(recipient, Recipient::getCallRingtone); + this.messageVibrateToggle = Transformations.map(messageVibrateState, vibrateState -> { + switch (vibrateState) { + case DISABLED: return false; + case ENABLED : return true; + case DEFAULT : return TextSecurePreferences.isNotificationVibrateEnabled(ApplicationDependencies.getApplication()); + default : throw new AssertionError(); + } + }); + + repository.onLoad(() -> isInitialLoadComplete.postValue(true)); + } + + LiveData isInitialLoadComplete() { + return isInitialLoadComplete; + } + + LiveData hasCustomNotifications() { + return hasCustomNotifications; + } + + LiveData getNotificationSound() { + return notificationSound; + } + + LiveData getMessageVibrateState() { + return messageVibrateState; + } + + LiveData getMessageVibrateToggle() { + return messageVibrateToggle; + } + + void setHasCustomNotifications(boolean hasCustomNotifications) { + repository.setHasCustomNotifications(hasCustomNotifications); + } + + void setMessageVibrate(@NonNull RecipientDatabase.VibrateState vibrateState) { + repository.setMessageVibrate(vibrateState); + } + + void setMessageSound(@Nullable Uri sound) { + repository.setMessageSound(sound); + } + + void setCallSound(@Nullable Uri sound) { + repository.setCallSound(sound); + } + + LiveData getShowCallingOptions() { + return showCallingOptions; + } + + LiveData getRingtone() { + return ringtone; + } + + LiveData getCallingVibrateState() { + return callingVibrateState; + } + + void setCallingVibrate(@NonNull RecipientDatabase.VibrateState vibrateState) { + repository.setCallingVibrate(vibrateState); + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + private final CustomNotificationsRepository repository; + + public Factory(@NonNull RecipientId recipientId, @NonNull CustomNotificationsRepository repository) { + this.recipientId = recipientId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new CustomNotificationsViewModel(recipientId, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java new file mode 100644 index 00000000..bb82061b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ShareCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr.GroupLinkShareQrDialogFragment; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Objects; + +public final class GroupLinkBottomSheetDialogFragment extends BottomSheetDialogFragment { + + public static final String ARG_GROUP_ID = "group_id"; + + public static void show(@NonNull FragmentManager manager, @NonNull GroupId.V2 groupId) { + GroupLinkBottomSheetDialogFragment fragment = new GroupLinkBottomSheetDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + + fragment.setArguments(args); + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_link_share_bottom_sheet, container, false); + + View shareViaSignalButton = view.findViewById(R.id.group_link_bottom_sheet_share_via_signal_button); + View copyButton = view.findViewById(R.id.group_link_bottom_sheet_copy_button); + View viewQrButton = view.findViewById(R.id.group_link_bottom_sheet_qr_code_button); + View shareBySystemButton = view.findViewById(R.id.group_link_bottom_sheet_share_via_system_button); + TextView hint = view.findViewById(R.id.group_link_bottom_sheet_hint); + + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(ARG_GROUP_ID))).requireV2(); + + LiveGroup liveGroup = new LiveGroup(groupId); + + liveGroup.getGroupLink().observe(getViewLifecycleOwner(), groupLink -> { + if (!groupLink.isEnabled()) { + Toast.makeText(requireContext(), R.string.GroupLinkBottomSheet_the_link_is_not_currently_active, Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + + hint.setText(groupLink.isRequiresApproval() ? R.string.GroupLinkBottomSheet_share_hint_requiring_approval + : R.string.GroupLinkBottomSheet_share_hint_not_requiring_approval); + hint.setVisibility(View.VISIBLE); + + shareViaSignalButton.setOnClickListener(v -> { + Context context = requireContext(); + Intent intent = new Intent(context, ShareActivity.class); + intent.putExtra(Intent.EXTRA_TEXT, groupLink.getUrl()); + context.startActivity(intent); + + dismiss(); + }); + + copyButton.setOnClickListener(v -> { + Context context = requireContext(); + Util.copyToClipboard(context, groupLink.getUrl()); + Toast.makeText(context, R.string.GroupLinkBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + dismiss(); + }); + + viewQrButton.setOnClickListener(v -> { + GroupLinkShareQrDialogFragment.show(requireFragmentManager(), groupId); + dismiss(); + }); + + shareBySystemButton.setOnClickListener(v -> { + ShareCompat.IntentBuilder.from(requireActivity()) + .setType("text/plain") + .setText(groupLink.getUrl()) + .startChooser(); + + dismiss(); + }); + }); + + return view; + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java new file mode 100644 index 00000000..48d20b1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +public final class ShareableGroupLinkDialogFragment extends DialogFragment { + + private static final String ARG_GROUP_ID = "group_id"; + + private ShareableGroupLinkViewModel viewModel; + private GroupId.V2 groupId; + private SimpleProgressDialog.DismissibleDialog dialog; + + public static DialogFragment create(@NonNull GroupId.V2 groupId) { + DialogFragment fragment = new ShareableGroupLinkDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_Animated); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.shareable_group_link_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + private void initializeViewModel() { + //noinspection ConstantConditions + groupId = GroupId.parseOrThrow(requireArguments().getString(ARG_GROUP_ID)).requireV2(); + + ShareableGroupLinkRepository repository = new ShareableGroupLinkRepository(requireContext(), groupId); + ShareableGroupLinkViewModel.Factory factory = new ShareableGroupLinkViewModel.Factory(groupId, repository); + + viewModel = ViewModelProviders.of(this, factory).get(ShareableGroupLinkViewModel.class); + } + + private void initializeViews(@NonNull View view) { + SwitchCompat shareableGroupLinkSwitch = view.findViewById(R.id.shareable_group_link_enable_switch); + TextView shareableGroupLinkDisplay = view.findViewById(R.id.shareable_group_link_display); + View shareableGroupLinkDisplayRow = view.findViewById(R.id.shareable_group_link_display_row); + SwitchCompat approveNewMembersSwitch = view.findViewById(R.id.shareable_group_link_approve_new_members_switch); + View shareableGroupLinkRow = view.findViewById(R.id.shareable_group_link_row); + View shareRow = view.findViewById(R.id.shareable_group_link_share_row); + View resetLinkRow = view.findViewById(R.id.shareable_group_link_reset_link_row); + View approveNewMembersRow = view.findViewById(R.id.shareable_group_link_approve_new_members_row); + View membersSectionHeader = view.findViewById(R.id.shareable_group_link_member_requests_section_header); + View descriptionRow = view.findViewById(R.id.shareable_group_link_display_row2); + + Toolbar toolbar = view.findViewById(R.id.shareable_group_link_toolbar); + + toolbar.setNavigationOnClickListener(v -> dismissAllowingStateLoss()); + + viewModel.getGroupLink().observe(getViewLifecycleOwner(), groupLink -> { + shareableGroupLinkSwitch.setChecked(groupLink.isEnabled()); + approveNewMembersSwitch.setChecked(groupLink.isRequiresApproval()); + shareableGroupLinkDisplay.setText(formatForFullWidthWrapping(groupLink.getUrl())); + + shareableGroupLinkDisplayRow.setVisibility(groupLink.isEnabled() ? View.VISIBLE : View.GONE); + ViewUtil.setEnabledRecursive(shareRow, groupLink.isEnabled()); + ViewUtil.setEnabledRecursive(resetLinkRow, groupLink.isEnabled()); + ViewUtil.setEnabledRecursive(membersSectionHeader, groupLink.isEnabled()); + ViewUtil.setEnabledRecursive(approveNewMembersRow, groupLink.isEnabled()); + ViewUtil.setEnabledRecursive(descriptionRow, groupLink.isEnabled()); + }); + + shareRow.setOnClickListener(v -> GroupLinkBottomSheetDialogFragment.show(requireFragmentManager(), groupId)); + + viewModel.getCanEdit().observe(getViewLifecycleOwner(), canEdit -> { + if (canEdit) { + shareableGroupLinkRow.setOnClickListener(v -> viewModel.onToggleGroupLink()); + approveNewMembersRow.setOnClickListener(v -> viewModel.onToggleApproveMembers()); + resetLinkRow.setOnClickListener(v -> onResetGroupLink()); + } else { + shareableGroupLinkRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_enable_or_disable_the_sharable_group_link)); + approveNewMembersRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_enable_or_disable_the_option_to_approve_new_members)); + resetLinkRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_reset_the_sharable_group_link)); + } + }); + + viewModel.getToasts().observe(getViewLifecycleOwner(), this::toast); + + viewModel.getBusy().observe(getViewLifecycleOwner(), busy -> { + if (busy) { + if (dialog == null) { + dialog = SimpleProgressDialog.showDelayed(requireContext()); + } + } else { + if (dialog != null) { + dialog.dismiss(); + dialog = null; + } + } + }); + } + + private void onResetGroupLink() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link) + .setPositiveButton(R.string.ShareableGroupLinkDialogFragment__reset_link, (dialog, which) -> viewModel.onResetLink()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + protected void toast(@StringRes int message) { + Toast.makeText(requireContext(), getString(message), Toast.LENGTH_SHORT).show(); + } + + /** + * Inserts zero width space characters between each character in the original ensuring it takes + * the full width of the TextView. + */ + private static CharSequence formatForFullWidthWrapping(@NonNull String url) { + char[] chars = new char[url.length() * 2]; + + for (int i = 0; i < url.length(); i++) { + chars[i * 2] = url.charAt(i); + chars[i * 2 + 1] = '\u200B'; + } + + return new String(chars); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java new file mode 100644 index 00000000..61a0a542 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.storageservice.protos.groups.AccessControl; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.util.AsynchronousCallback; + +import java.io.IOException; + +final class ShareableGroupLinkRepository { + + private final Context context; + private final GroupId.V2 groupId; + + ShareableGroupLinkRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + void cycleGroupLinkPassword(@NonNull AsynchronousCallback.WorkerThread callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.cycleGroupLinkPassword(context, groupId); + callback.onComplete(null); + } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void toggleGroupLinkEnabled(@NonNull AsynchronousCallback.WorkerThread callback) { + setGroupLinkEnabledState(toggleGroupLinkState(true, false), callback); + } + + void toggleGroupLinkApprovalRequired(@NonNull AsynchronousCallback.WorkerThread callback) { + setGroupLinkEnabledState(toggleGroupLinkState(false, true), callback); + } + + private void setGroupLinkEnabledState(@NonNull GroupManager.GroupLinkState state, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.setGroupLinkEnabledState(context, groupId, state); + callback.onComplete(null); + } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + @WorkerThread + private GroupManager.GroupLinkState toggleGroupLinkState(boolean toggleEnabled, boolean toggleApprovalNeeded) { + AccessControl.AccessRequired currentState = DatabaseFactory.getGroupDatabase(context) + .getGroup(groupId) + .get() + .requireV2GroupProperties() + .getDecryptedGroup() + .getAccessControl() + .getAddFromInviteLink(); + + boolean enabled; + boolean approvalNeeded; + + switch (currentState) { + case UNKNOWN: + case UNSATISFIABLE: + case UNRECOGNIZED: + case MEMBER: + enabled = false; + approvalNeeded = false; + break; + case ANY: + enabled = true; + approvalNeeded = false; + break; + case ADMINISTRATOR: + enabled = true; + approvalNeeded = true; + break; + default: throw new AssertionError(); + } + + if (toggleApprovalNeeded) { + approvalNeeded = !approvalNeeded; + } + + if (toggleEnabled) { + enabled = !enabled; + } + + if (approvalNeeded && enabled) { + return GroupManager.GroupLinkState.ENABLED_WITH_APPROVAL; + } else { + if (enabled) { + return GroupManager.GroupLinkState.ENABLED; + } + } + + return GroupManager.GroupLinkState.DISABLED; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java new file mode 100644 index 00000000..dcc73a11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +final class ShareableGroupLinkViewModel extends ViewModel { + + private final ShareableGroupLinkRepository repository; + private final LiveData groupLink; + private final SingleLiveEvent toasts; + private final SingleLiveEvent busy; + private final LiveData canEdit; + + private ShareableGroupLinkViewModel(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { + LiveGroup liveGroup = new LiveGroup(groupId); + + this.repository = repository; + this.groupLink = liveGroup.getGroupLink(); + this.canEdit = liveGroup.isSelfAdmin(); + this.toasts = new SingleLiveEvent<>(); + this.busy = new SingleLiveEvent<>(); + } + + LiveData getGroupLink() { + return groupLink; + } + + LiveData getToasts() { + return toasts; + } + + LiveData getBusy() { + return busy; + } + + LiveData getCanEdit() { + return canEdit; + } + + void onToggleGroupLink() { + busy.setValue(true); + repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(GroupErrors.getUserDisplayMessage(error)); + } + }); + } + + void onToggleApproveMembers() { + busy.setValue(true); + repository.toggleGroupLinkApprovalRequired(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(GroupErrors.getUserDisplayMessage(error)); + } + }); + } + + void onResetLink() { + busy.setValue(true); + repository.cycleGroupLinkPassword(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(GroupErrors.getUserDisplayMessage(error)); + } + }); + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + private final ShareableGroupLinkRepository repository; + + public Factory(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { + this.groupId = groupId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ShareableGroupLinkViewModel(groupId, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java new file mode 100644 index 00000000..137e8ba0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ShareCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.qr.QrView; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Objects; + +public class GroupLinkShareQrDialogFragment extends DialogFragment { + + private static final String TAG = Log.tag(GroupLinkShareQrDialogFragment.class); + + private static final String ARG_GROUP_ID = "group_id"; + + private GroupLinkShareQrViewModel viewModel; + private QrView qrImageView; + private View shareCodeButton; + + public static void show(@NonNull FragmentManager manager, @NonNull GroupId.V2 groupId) { + DialogFragment fragment = new GroupLinkShareQrDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme + : R.style.TextSecure_LightTheme); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.group_link_share_qr_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + private void initializeViewModel() { + Bundle arguments = requireArguments(); + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(arguments.getString(ARG_GROUP_ID))).requireV2(); + GroupLinkShareQrViewModel.Factory factory = new GroupLinkShareQrViewModel.Factory(groupId); + + viewModel = ViewModelProviders.of(this, factory).get(GroupLinkShareQrViewModel.class); + } + + private void initializeViews(@NonNull View view) { + Toolbar toolbar = view.findViewById(R.id.group_link_share_qr_toolbar); + + qrImageView = view.findViewById(R.id.group_link_share_qr_image); + shareCodeButton = view.findViewById(R.id.group_link_share_code_button); + + toolbar.setNavigationOnClickListener(v -> dismissAllowingStateLoss()); + + viewModel.getQrUrl().observe(getViewLifecycleOwner(), this::presentUrl); + } + + private void presentUrl(@Nullable String url) { + qrImageView.setQrText(url); + + // Restricted to API26 because of MemoryFileUtil not supporting lower API levels well + if (Build.VERSION.SDK_INT >= 26) { + shareCodeButton.setVisibility(View.VISIBLE); + + shareCodeButton.setOnClickListener(v -> { + Uri shareUri; + + try { + shareUri = createTemporaryPng(url); + } catch (IOException e) { + Log.w(TAG, e); + return; + } + + Intent intent = ShareCompat.IntentBuilder.from(requireActivity()) + .setType("image/png") + .setStream(shareUri) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + requireContext().startActivity(intent); + }); + } else { + shareCodeButton.setVisibility(View.GONE); + } + } + + private static Uri createTemporaryPng(@Nullable String url) throws IOException { + Bitmap qrBitmap = QrCode.create(url, Color.BLACK, Color.WHITE); + + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + qrBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream); + byteArrayOutputStream.flush(); + + byte[] bytes = byteArrayOutputStream.toByteArray(); + + return BlobProvider.getInstance() + .forData(bytes) + .withMimeType("image/png") + .withFileName("SignalGroupQr.png") + .createForSingleSessionInMemory(); + } finally { + qrBitmap.recycle(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java new file mode 100644 index 00000000..7984f230 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; + +public final class GroupLinkShareQrViewModel extends ViewModel { + + private final LiveData qrData; + + private GroupLinkShareQrViewModel(@NonNull GroupId.V2 groupId) { + LiveGroup liveGroup = new LiveGroup(groupId); + + this.qrData = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::getUrl); + } + + LiveData getQrUrl() { + return qrData; + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + + public Factory(@NonNull GroupId.V2 groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new GroupLinkShareQrViewModel(groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java new file mode 100644 index 00000000..4a99b2a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.registration; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.io.IOException; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public final class PushChallengeRequest { + + private static final String TAG = Log.tag(PushChallengeRequest.class); + + /** + * Requests a push challenge and waits for the response. + *

+ * Blocks the current thread for up to {@param timeoutMs} milliseconds. + * + * @param accountManager Account manager to request the push from. + * @param fcmToken Optional FCM token. If not present will return absent. + * @param e164number Local number. + * @param timeoutMs Timeout in milliseconds + * @return Either returns a challenge, or absent. + */ + @WorkerThread + public static Optional getPushChallengeBlocking(@NonNull SignalServiceAccountManager accountManager, + @NonNull Optional fcmToken, + @NonNull String e164number, + long timeoutMs) + { + if (!fcmToken.isPresent()) { + Log.w(TAG, "Push challenge not requested, as no FCM token was present"); + return Optional.absent(); + } + + long startTime = System.currentTimeMillis(); + Log.i(TAG, "Requesting a push challenge"); + + Request request = new Request(accountManager, fcmToken.get(), e164number, timeoutMs); + + Optional challenge = request.requestAndReceiveChallengeBlocking(); + + long duration = System.currentTimeMillis() - startTime; + + if (challenge.isPresent()) { + Log.i(TAG, String.format(Locale.US, "Received a push challenge \"%s\" in %d ms", challenge.get(), duration)); + } else { + Log.w(TAG, String.format(Locale.US, "Did not received a push challenge in %d ms", duration)); + } + return challenge; + } + + public static void postChallengeResponse(@NonNull String challenge) { + EventBus.getDefault().post(new PushChallengeEvent(challenge)); + } + + public static class Request { + + private final CountDownLatch latch; + private final AtomicReference challenge; + private final SignalServiceAccountManager accountManager; + private final String fcmToken; + private final String e164number; + private final long timeoutMs; + + private Request(@NonNull SignalServiceAccountManager accountManager, + @NonNull String fcmToken, + @NonNull String e164number, + long timeoutMs) + { + this.latch = new CountDownLatch(1); + this.challenge = new AtomicReference<>(); + this.accountManager = accountManager; + this.fcmToken = fcmToken; + this.e164number = e164number; + this.timeoutMs = timeoutMs; + } + + @WorkerThread + private Optional requestAndReceiveChallengeBlocking() { + EventBus eventBus = EventBus.getDefault(); + + eventBus.register(this); + try { + accountManager.requestPushChallenge(fcmToken, e164number); + + latch.await(timeoutMs, TimeUnit.MILLISECONDS); + + return Optional.fromNullable(challenge.get()); + } catch (InterruptedException | IOException e) { + Log.w(TAG, "Error getting push challenge", e); + return Optional.absent(); + } finally { + eventBus.unregister(this); + } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + public void onChallengeEvent(@NonNull PushChallengeEvent pushChallengeEvent) { + challenge.set(pushChallengeEvent.challenge); + latch.countDown(); + } + } + + static class PushChallengeEvent { + private final String challenge; + + PushChallengeEvent(String challenge) { + this.challenge = challenge; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ReceivedSmsEvent.java b/app/src/main/java/org/thoughtcrime/securesms/registration/ReceivedSmsEvent.java new file mode 100644 index 00000000..6f88ca36 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ReceivedSmsEvent.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.registration; + +import androidx.annotation.NonNull; + +public final class ReceivedSmsEvent { + + private final @NonNull String code; + + public ReceivedSmsEvent(@NonNull String code) { + this.code = code; + } + + public @NonNull String getCode() { + return code; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java new file mode 100644 index 00000000..bc6a2d82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationNavigationActivity.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.registration; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import com.google.android.gms.auth.api.phone.SmsRetriever; +import com.google.android.gms.common.api.CommonStatusCodes; +import com.google.android.gms.common.api.Status; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.service.VerificationCodeParser; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.whispersystems.libsignal.util.guava.Optional; + +public final class RegistrationNavigationActivity extends AppCompatActivity { + + private static final String TAG = Log.tag(RegistrationNavigationActivity.class); + + public static final String RE_REGISTRATION_EXTRA = "re_registration"; + + private SmsRetrieverReceiver smsRetrieverReceiver; + + /** + */ + public static Intent newIntentForNewRegistration(@NonNull Context context, @Nullable Intent originalIntent) { + Intent intent = new Intent(context, RegistrationNavigationActivity.class); + intent.putExtra(RE_REGISTRATION_EXTRA, false); + + if (intent != null) { + intent.setData(originalIntent.getData()); + } + + return intent; + } + + public static Intent newIntentForReRegistration(@NonNull Context context) { + Intent intent = new Intent(context, RegistrationNavigationActivity.class); + intent.putExtra(RE_REGISTRATION_EXTRA, true); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_NO); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_registration_navigation); + initializeChallengeListener(); + + if (getIntent() != null && getIntent().getData() != null) { + CommunicationActions.handlePotentialProxyLinkUrl(this, getIntent().getDataString()); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + if (intent.getData() != null) { + CommunicationActions.handlePotentialProxyLinkUrl(this, intent.getDataString()); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + shutdownChallengeListener(); + } + + private void initializeChallengeListener() { + smsRetrieverReceiver = new SmsRetrieverReceiver(); + + registerReceiver(smsRetrieverReceiver, new IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)); + } + + private void shutdownChallengeListener() { + if (smsRetrieverReceiver != null) { + unregisterReceiver(smsRetrieverReceiver); + smsRetrieverReceiver = null; + } + } + + private class SmsRetrieverReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "SmsRetrieverReceiver received a broadcast..."); + + if (SmsRetriever.SMS_RETRIEVED_ACTION.equals(intent.getAction())) { + Bundle extras = intent.getExtras(); + Status status = (Status) extras.get(SmsRetriever.EXTRA_STATUS); + + switch (status.getStatusCode()) { + case CommonStatusCodes.SUCCESS: + Optional code = VerificationCodeParser.parse((String) extras.get(SmsRetriever.EXTRA_SMS_MESSAGE)); + if (code.isPresent()) { + Log.i(TAG, "Received verification code."); + handleVerificationCodeReceived(code.get()); + } else { + Log.w(TAG, "Could not parse verification code."); + } + break; + case CommonStatusCodes.TIMEOUT: + Log.w(TAG, "Hit a timeout waiting for the SMS to arrive."); + break; + } + } else { + Log.w(TAG, "SmsRetrieverReceiver received the wrong action?"); + } + } + } + + private void handleVerificationCodeReceived(@NonNull String code) { + EventBus.getDefault().post(new ReceivedSmsEvent(code)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java new file mode 100644 index 00000000..e68119ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.registration; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public final class RegistrationUtil { + + private static final String TAG = Log.tag(RegistrationUtil.class); + + private RegistrationUtil() {} + + /** + * There's several events where a registration may or may not be considered complete based on what + * path a user has taken. This will only truly mark registration as complete if all of the + * requirements are met. + */ + public static void maybeMarkRegistrationComplete(@NonNull Context context) { + if (!SignalStore.registrationValues().isRegistrationComplete() && + TextSecurePreferences.isPushRegistered(context) && + !Recipient.self().getProfileName().isEmpty() && + (SignalStore.kbsValues().hasPin() || SignalStore.kbsValues().hasOptedOut())) + { + Log.i(TAG, "Marking registration completed.", new Throwable()); + SignalStore.registrationValues().setRegistrationComplete(); + ApplicationDependencies.getJobManager().startChain(new StorageSyncJob()) + .then(new DirectoryRefreshJob(false)) + .enqueue(); + } else if (!SignalStore.registrationValues().isRegistrationComplete()) { + Log.i(TAG, "Registration is not yet complete.", new Throwable()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java new file mode 100644 index 00000000..3628b13a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/AccountLockedFragment.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +import java.util.concurrent.TimeUnit; + +public class AccountLockedFragment extends BaseRegistrationFragment { + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.account_locked_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + TextView description = view.findViewById(R.id.account_locked_description); + + getModel().getLockedTimeRemaining().observe(getViewLifecycleOwner(), + t -> description.setText(getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t))) + ); + + view.findViewById(R.id.account_locked_next).setOnClickListener(v -> onNext()); + view.findViewById(R.id.account_locked_learn_more).setOnClickListener(v -> learnMore()); + + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + onNext(); + } + }); + } + + private void learnMore() { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url))); + startActivity(intent); + } + + private static long durationToDays(Long duration) { + return duration != null ? getLockoutDays(duration) : 7; + } + + private static int getLockoutDays(long timeRemainingMs) { + return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1; + } + + private void onNext() { + requireActivity().finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationFragment.java new file mode 100644 index 00000000..b2cc6dc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseRegistrationFragment.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.SavedStateViewModelFactory; +import androidx.lifecycle.ViewModelProviders; + +import com.dd.CircularProgressButton; + +import org.signal.core.util.TranslationDetection; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.SpanUtil; + +import java.util.Locale; + +import static org.thoughtcrime.securesms.registration.RegistrationNavigationActivity.RE_REGISTRATION_EXTRA; + +abstract class BaseRegistrationFragment extends LoggingFragment { + + private static final String TAG = Log.tag(BaseRegistrationFragment.class); + + private RegistrationViewModel model; + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + this.model = getRegistrationViewModel(requireActivity()); + } + + protected @NonNull RegistrationViewModel getModel() { + return model; + } + + protected boolean isReregister() { + Activity activity = getActivity(); + + if (activity == null) { + return false; + } + + return activity.getIntent().getBooleanExtra(RE_REGISTRATION_EXTRA, false); + } + + protected static RegistrationViewModel getRegistrationViewModel(@NonNull FragmentActivity activity) { + SavedStateViewModelFactory savedStateViewModelFactory = new SavedStateViewModelFactory(activity.getApplication(), activity); + + return ViewModelProviders.of(activity, savedStateViewModelFactory).get(RegistrationViewModel.class); + } + + protected static void setSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setClickable(false); + button.setIndeterminateProgressMode(true); + button.setProgress(50); + } + } + + protected static void cancelSpinning(@Nullable CircularProgressButton button) { + if (button != null) { + button.setProgress(0); + button.setIndeterminateProgressMode(false); + button.setClickable(true); + } + } + + protected static void hideKeyboard(@NonNull Context context, @NonNull View view) { + InputMethodManager imm = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + /** + * Sets view up to allow log submitting after multiple taps. + */ + protected static void setDebugLogSubmitMultiTapView(@Nullable View view) { + if (view == null) return; + + view.setOnClickListener(new View.OnClickListener() { + + private static final int DEBUG_TAP_TARGET = 8; + private static final int DEBUG_TAP_ANNOUNCE = 4; + + private int debugTapCounter; + + @Override + public void onClick(View v) { + Context context = v.getContext(); + + debugTapCounter++; + + if (debugTapCounter >= DEBUG_TAP_TARGET) { + context.startActivity(new Intent(context, SubmitDebugLogActivity.class)); + } else if (debugTapCounter >= DEBUG_TAP_ANNOUNCE) { + int remaining = DEBUG_TAP_TARGET - debugTapCounter; + + Toast.makeText(context, context.getResources().getQuantityString(R.plurals.RegistrationActivity_debug_log_hint, remaining, remaining), Toast.LENGTH_SHORT).show(); + } + } + }); + } + + /** + * Presents a prompt for the user to confirm their number as long as it can be shown in one of their device languages. + */ + protected final void showConfirmNumberDialogIfTranslated(@NonNull Context context, + @StringRes int firstMessageLine, + @NonNull String e164number, + @NonNull Runnable onConfirmed, + @NonNull Runnable onEditNumber) + { + TranslationDetection translationDetection = new TranslationDetection(context); + + if (translationDetection.textExistsInUsersLanguage(firstMessageLine) && + translationDetection.textExistsInUsersLanguage(R.string.RegistrationActivity_is_your_phone_number_above_correct) && + translationDetection.textExistsInUsersLanguage(R.string.RegistrationActivity_edit_number)) + { + CharSequence message = new SpannableStringBuilder().append(context.getString(firstMessageLine)) + .append("\n\n") + .append(SpanUtil.bold(PhoneNumberFormatter.prettyPrint(e164number))) + .append("\n\n") + .append(context.getString(R.string.RegistrationActivity_is_your_phone_number_above_correct)); + + Log.i(TAG, "Showing confirm number dialog (" + context.getString(firstMessageLine) + ")"); + new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton(android.R.string.ok, + (a, b) -> { + Log.i(TAG, "Number confirmed"); + onConfirmed.run(); + }) + .setNegativeButton(R.string.RegistrationActivity_edit_number, + (a, b) -> { + Log.i(TAG, "User requested edit number from confirm dialog"); + onEditNumber.run(); + }) + .show(); + } else { + Log.i(TAG, "Confirm number dialog not translated in " + Locale.getDefault() + " skipping"); + onConfirmed.run(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java new file mode 100644 index 00000000..94164ed2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CaptchaFragment.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.R; + +/** + * Fragment that displays a Captcha in a WebView. + */ +public final class CaptchaFragment extends BaseRegistrationFragment { + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_captcha, container, false); + } + + @Override + @SuppressLint("SetJavaScriptEnabled") + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + WebView webView = view.findViewById(R.id.registration_captcha_web_view); + + webView.getSettings().setJavaScriptEnabled(true); + webView.clearCache(true); + + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url != null && url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) { + handleToken(url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length())); + return true; + } + return false; + } + }); + + webView.loadUrl(RegistrationConstants.SIGNAL_CAPTCHA_URL); + } + + private void handleToken(@NonNull String token) { + getModel().onCaptchaResponse(token); + + Navigation.findNavController(requireView()).navigate(CaptchaFragmentDirections.actionCaptchaComplete()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java new file mode 100644 index 00000000..0f915a28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.text.HtmlCompat; +import androidx.navigation.Navigation; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.documents.Document; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.BackupUtil; + +public class ChooseBackupFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(ChooseBackupFragment.class); + + private static final short OPEN_FILE_REQUEST_CODE = 3862; + + private View chooseBackupButton; + private TextView learnMore; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.fragment_registration_choose_backup, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + chooseBackupButton = view.findViewById(R.id.choose_backup_fragment_button); + chooseBackupButton.setOnClickListener(this::onChooseBackupSelected); + + learnMore = view.findViewById(R.id.choose_backup_fragment_learn_more); + learnMore.setText(HtmlCompat.fromHtml(String.format("%s", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0)); + learnMore.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + Log.i(TAG, "User Selection is not required. Skipping."); + Navigation.findNavController(requireView()).navigate(ChooseBackupFragmentDirections.actionSkip()); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == OPEN_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { + ChooseBackupFragmentDirections.ActionRestore restore = ChooseBackupFragmentDirections.actionRestore(); + + restore.setUri(data.getData()); + + Navigation.findNavController(requireView()).navigate(restore); + } + } + + @RequiresApi(21) + private void onChooseBackupSelected(@NonNull View view) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + + intent.setType("application/octet-stream"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); + + if (Build.VERSION.SDK_INT >= 26) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().getLatestSignalBackupDirectory()); + } + + startActivityForResult(intent, OPEN_FILE_REQUEST_CODE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java new file mode 100644 index 00000000..42bb5e7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/CountryPickerFragment.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.SimpleAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.ListFragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.loaders.CountryListLoader; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; + +import java.util.ArrayList; +import java.util.Map; + +public final class CountryPickerFragment extends ListFragment implements LoaderManager.LoaderCallbacks>> { + + private EditText countryFilter; + private RegistrationViewModel model; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + return inflater.inflate(R.layout.fragment_registration_country_picker, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + model = BaseRegistrationFragment.getRegistrationViewModel(requireActivity()); + + countryFilter = view.findViewById(R.id.country_search); + + countryFilter.addTextChangedListener(new FilterWatcher()); + LoaderManager.getInstance(this).initLoader(0, null, this).forceLoad(); + } + + @Override + public void onListItemClick(@NonNull ListView listView, @NonNull View view, int position, long id) { + Map item = (Map) getListAdapter().getItem(position); + + int countryCode = Integer.parseInt(item.get("country_code").replace("+", "")); + String countryName = item.get("country_name"); + + model.onCountrySelected(countryName, countryCode); + + Navigation.findNavController(view).navigate(CountryPickerFragmentDirections.actionCountrySelected()); + } + + @Override + public @NonNull Loader>> onCreateLoader(int id, @Nullable Bundle args) { + return new CountryListLoader(getActivity()); + } + + @Override + public void onLoadFinished(@NonNull Loader>> loader, + @NonNull ArrayList> results) + { + ((TextView) getListView().getEmptyView()).setText( + R.string.country_selection_fragment__no_matching_countries); + String[] from = { "country_name", "country_code" }; + int[] to = { R.id.country_name, R.id.country_code }; + + setListAdapter(new SimpleAdapter(getActivity(), results, R.layout.country_list_item, from, to)); + + applyFilter(countryFilter.getText()); + } + + private void applyFilter(@NonNull CharSequence text) { + SimpleAdapter listAdapter = (SimpleAdapter) getListAdapter(); + + if (listAdapter != null) { + listAdapter.getFilter().filter(text); + } + } + + @Override + public void onLoaderReset(@NonNull Loader>> loader) { + setListAdapter(null); + } + + private class FilterWatcher implements TextWatcher { + + @Override + public void afterTextChanged(Editable s) { + applyFilter(s); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java new file mode 100644 index 00000000..469498d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterCodeFragment.java @@ -0,0 +1,382 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.animation.Animator; +import android.os.Bundle; +import android.telephony.PhoneStateListener; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.registration.CallMeCountDownView; +import org.thoughtcrime.securesms.components.registration.VerificationCodeView; +import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard; +import org.thoughtcrime.securesms.pin.PinRestoreRepository; +import org.thoughtcrime.securesms.registration.ReceivedSmsEvent; +import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest; +import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest; +import org.thoughtcrime.securesms.registration.service.RegistrationService; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SupportEmailUtil; +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class EnterCodeFragment extends BaseRegistrationFragment + implements SignalStrengthPhoneStateListener.Callback +{ + + private static final String TAG = Log.tag(EnterCodeFragment.class); + + private ScrollView scrollView; + private TextView header; + private VerificationCodeView verificationCodeView; + private VerificationPinKeyboard keyboard; + private CallMeCountDownView callMeCountDown; + private View wrongNumber; + private View noCodeReceivedHelp; + private View serviceWarning; + private boolean autoCompleting; + + private PhoneStateListener signalStrengthListener; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_enter_code, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header)); + + scrollView = view.findViewById(R.id.scroll_view); + header = view.findViewById(R.id.verify_header); + verificationCodeView = view.findViewById(R.id.code); + keyboard = view.findViewById(R.id.keyboard); + callMeCountDown = view.findViewById(R.id.call_me_count_down); + wrongNumber = view.findViewById(R.id.wrong_number); + noCodeReceivedHelp = view.findViewById(R.id.no_code); + serviceWarning = view.findViewById(R.id.cell_service_warning); + + signalStrengthListener = new SignalStrengthPhoneStateListener(this, this); + + connectKeyboard(verificationCodeView, keyboard); + hideKeyboard(requireContext(), view); + + setOnCodeFullyEnteredListener(verificationCodeView); + + wrongNumber.setOnClickListener(v -> onWrongNumber()); + + callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest()); + + callMeCountDown.setListener((v, remaining) -> { + if (remaining <= 30) { + scrollView.smoothScrollTo(0, v.getBottom()); + callMeCountDown.setListener(null); + } + }); + + noCodeReceivedHelp.setOnClickListener(v -> sendEmailToSupport()); + + RegistrationViewModel model = getModel(); + model.getSuccessfulCodeRequestAttempts().observe(getViewLifecycleOwner(), (attempts) -> { + if (attempts >= 3) { + noCodeReceivedHelp.setVisibility(View.VISIBLE); + scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, noCodeReceivedHelp.getBottom()), 15000); + } + }); + + model.onStartEnterCode(); + } + + private void onWrongNumber() { + Navigation.findNavController(requireView()) + .navigate(EnterCodeFragmentDirections.actionWrongNumber()); + } + + private void setOnCodeFullyEnteredListener(VerificationCodeView verificationCodeView) { + verificationCodeView.setOnCompleteListener(code -> { + RegistrationViewModel model = getModel(); + + model.onVerificationCodeEntered(code); + callMeCountDown.setVisibility(View.INVISIBLE); + wrongNumber.setVisibility(View.INVISIBLE); + keyboard.displayProgress(); + + RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret()); + + registrationService.verifyAccount(requireActivity(), model.getFcmToken(), code, null, null, + new CodeVerificationRequest.VerifyCallback() { + + @Override + public void onSuccessfulRegistration() { + keyboard.displaySuccess().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + handleSuccessfulRegistration(); + } + }); + } + + @Override + public void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining) { + model.setLockedTimeRemaining(timeRemaining); + keyboard.displayLocked().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean r) { + Navigation.findNavController(requireView()) + .navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining, true)); + } + }); + } + + @Override + public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull PinRestoreRepository.TokenData tokenData, @NonNull String kbsStorageCredentials) { + model.setLockedTimeRemaining(timeRemaining); + model.setKeyBackupTokenData(tokenData); + keyboard.displayLocked().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean r) { + Navigation.findNavController(requireView()) + .navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining, false)); + } + }); + } + + @Override + public void onIncorrectKbsRegistrationLockPin(@NonNull PinRestoreRepository.TokenData tokenData) { + throw new AssertionError("Unexpected, user has made no pin guesses"); + } + + @Override + public void onRateLimited() { + keyboard.displayFailure().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean r) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.RegistrationActivity_too_many_attempts) + .setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + callMeCountDown.setVisibility(View.VISIBLE); + wrongNumber.setVisibility(View.VISIBLE); + verificationCodeView.clear(); + keyboard.displayKeyboard(); + }) + .show(); + } + }); + } + + @Override + public void onKbsAccountLocked(@Nullable Long timeRemaining) { + if (timeRemaining != null) { + model.setLockedTimeRemaining(timeRemaining); + } + Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked()); + } + + @Override + public void onError() { + Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show(); + keyboard.displayFailure().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + callMeCountDown.setVisibility(View.VISIBLE); + wrongNumber.setVisibility(View.VISIBLE); + verificationCodeView.clear(); + keyboard.displayKeyboard(); + } + }); + } + }); + }); + } + + private void handleSuccessfulRegistration() { + Navigation.findNavController(requireView()).navigate(EnterCodeFragmentDirections.actionSuccessfulRegistration()); + } + + @Override + public void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + super.onStop(); + EventBus.getDefault().unregister(this); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onVerificationCodeReceived(@NonNull ReceivedSmsEvent event) { + verificationCodeView.clear(); + + List parsedCode = convertVerificationCodeToDigits(event.getCode()); + + autoCompleting = true; + + final int size = parsedCode.size(); + + for (int i = 0; i < size; i++) { + final int index = i; + verificationCodeView.postDelayed(() -> { + verificationCodeView.append(parsedCode.get(index)); + if (index == size - 1) { + autoCompleting = false; + } + }, i * 200); + } + } + + private static List convertVerificationCodeToDigits(@Nullable String code) { + if (code == null || code.length() != 6) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(code.length()); + + try { + for (int i = 0; i < code.length(); i++) { + result.add(Integer.parseInt(Character.toString(code.charAt(i)))); + } + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to convert code into digits.", e); + return Collections.emptyList(); + } + + return result; + } + + private void handlePhoneCallRequest() { + showConfirmNumberDialogIfTranslated(requireContext(), + R.string.RegistrationActivity_you_will_receive_a_call_to_verify_this_number, + getModel().getNumber().getE164Number(), + this::handlePhoneCallRequestAfterConfirm, + this::onWrongNumber); + } + + private void handlePhoneCallRequestAfterConfirm() { + RegistrationViewModel model = getModel(); + String captcha = model.getCaptchaToken(); + model.clearCaptchaResponse(); + + model.onCallRequested(); + + NavController navController = Navigation.findNavController(callMeCountDown); + + RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret()); + + registrationService.requestVerificationCode(requireActivity(), RegistrationCodeRequest.Mode.PHONE_CALL, captcha, + new RegistrationCodeRequest.SmsVerificationCodeCallback() { + + @Override + public void onNeedCaptcha() { + navController.navigate(EnterCodeFragmentDirections.actionRequestCaptcha()); + } + + @Override + public void requestSent(@Nullable String fcmToken) { + model.setFcmToken(fcmToken); + model.markASuccessfulAttempt(); + } + + @Override + public void onRateLimited() { + Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show(); + } + + @Override + public void onError() { + Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show(); + } + }); + } + + private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) { + keyboard.setOnKeyPressListener(key -> { + if (!autoCompleting) { + if (key >= 0) { + verificationCodeView.append(key); + } else { + verificationCodeView.delete(); + } + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + RegistrationViewModel model = getModel(); + model.getLiveNumber().observe(getViewLifecycleOwner(), (s) -> header.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, s.getFullFormattedNumber()))); + + model.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> callMeCountDown.startCountDownTo(callAtTime)); + } + + private void sendEmailToSupport() { + String body = SupportEmailUtil.generateSupportEmailBody(requireContext(), + R.string.RegistrationActivity_code_support_subject, + null, + null); + CommunicationActions.openEmail(requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getString(R.string.RegistrationActivity_code_support_subject), + body); + } + + @Override + public void onNoCellSignalPresent() { + if (serviceWarning.getVisibility() == View.VISIBLE) { + return; + } + serviceWarning.setVisibility(View.VISIBLE); + serviceWarning.animate() + .alpha(1) + .setListener(null) + .start(); + + scrollView.postDelayed(() -> { + if (serviceWarning.getVisibility() == View.VISIBLE) { + scrollView.smoothScrollTo(0, serviceWarning.getBottom()); + } + }, 1000); + } + + @Override + public void onCellSignalPresent() { + if (serviceWarning.getVisibility() != View.VISIBLE) { + return; + } + serviceWarning.animate() + .alpha(0) + .setListener(new Animator.AnimatorListener() { + @Override public void onAnimationEnd(Animator animation) { + serviceWarning.setVisibility(View.GONE); + } + @Override public void onAnimationStart(Animator animation) {} + @Override public void onAnimationCancel(Animator animation) {} + @Override public void onAnimationRepeat(Animator animation) {} + }) + .start(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java new file mode 100644 index 00000000..210a7a62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java @@ -0,0 +1,482 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; +import com.google.android.gms.auth.api.phone.SmsRetriever; +import com.google.android.gms.auth.api.phone.SmsRetrieverClient; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.tasks.Task; +import com.google.i18n.phonenumbers.AsYouTypeFormatter; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.tm.androidcopysdk.AndroidCopySDK; +import com.tm.androidcopysdk.utils.PrefManager; + +import org.archiver.ArchivePreferenceConstants; +import org.archiver.ArchiveUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.LabeledEditText; +import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest; +import org.thoughtcrime.securesms.registration.service.RegistrationService; +import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.Dialogs; +import org.thoughtcrime.securesms.util.PlayServicesUtil; + +public final class EnterPhoneNumberFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(EnterPhoneNumberFragment.class); + + private LabeledEditText countryCode; + private LabeledEditText number; + private ArrayAdapter countrySpinnerAdapter; + private AsYouTypeFormatter countryFormatter; + private CircularProgressButton register; + private Spinner countrySpinner; + private View cancel; + private ScrollView scrollView; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_enter_phone_number, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header)); + + countryCode = view.findViewById(R.id.country_code); + number = view.findViewById(R.id.number); + countrySpinner = view.findViewById(R.id.country_spinner); + cancel = view.findViewById(R.id.cancel_button); + scrollView = view.findViewById(R.id.scroll_view); + register = view.findViewById(R.id.registerButton); + + initializeSpinner(countrySpinner); + + setUpNumberInput(); + + register.setOnClickListener(v -> handleRegister(requireContext())); + + if (isReregister()) { + cancel.setVisibility(View.VISIBLE); + cancel.setOnClickListener(v -> Navigation.findNavController(v).navigateUp()); + } else { + cancel.setVisibility(View.GONE); + } + + RegistrationViewModel model = getModel(); + NumberViewState number = model.getNumber(); + + initNumber(number); + + countryCode.getInput().addTextChangedListener(new CountryCodeChangedListener()); + + if (model.hasCaptchaToken()) { + handleRegister(requireContext()); + } + + countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(null); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + inflater.inflate(R.menu.enter_phone_number, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.phone_menu_use_proxy) { + Navigation.findNavController(requireView()).navigate(EnterPhoneNumberFragmentDirections.actionEditProxy()); + return true; + } else { + return false; + } + } + + private void setUpNumberInput() { + EditText numberInput = number.getInput(); + + numberInput.addTextChangedListener(new NumberChangedListener()); + + number.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + scrollView.postDelayed(() -> scrollView.smoothScrollTo(0, register.getBottom()), 250); + } + }); + + numberInput.setImeOptions(EditorInfo.IME_ACTION_DONE); + numberInput.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + hideKeyboard(requireContext(), v); + handleRegister(requireContext()); + return true; + } + return false; + }); + } + + private void handleRegister(@NonNull Context context) { + if (TextUtils.isEmpty(countryCode.getText())) { + Toast.makeText(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code), Toast.LENGTH_LONG).show(); + return; + } + + if (TextUtils.isEmpty(this.number.getText())) { + Toast.makeText(context, getString(R.string.RegistrationActivity_you_must_specify_your_phone_number), Toast.LENGTH_LONG).show(); + return; + } + + final NumberViewState number = getModel().getNumber(); + final String e164number = number.getE164Number(); + + if (!number.isValid()) { + Dialogs.showAlertDialog(context, + getString(R.string.RegistrationActivity_invalid_number), + String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), e164number)); + return; + } + + PlayServicesUtil.PlayServicesStatus fcmStatus = PlayServicesUtil.getPlayServicesStatus(context); + + if (fcmStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS) { + PrefManager.setStringPref(context, ArchivePreferenceConstants.PREF_KEY_DEVICE_PHONE_NUMBER, e164number); + + AndroidCopySDK.getInstance(context).savePhoneNumber(ArchiveUtil.Companion.getPhoneNumberInTestMode(context)); + + confirmNumberPrompt(context, e164number, () -> handleRequestVerification(context, e164number, true)); + } else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.MISSING) { + confirmNumberPrompt(context, e164number, () -> handlePromptForNoPlayServices(context, e164number)); + } else if (fcmStatus == PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE) { + GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0).show(); + } else { + Dialogs.showAlertDialog(context, getString(R.string.RegistrationActivity_play_services_error), + getString(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)); + } + } + + private void handleRequestVerification(@NonNull Context context, @NonNull String e164number, boolean fcmSupported) { + setSpinning(register); + disableAllEntries(); + + if (fcmSupported) { + SmsRetrieverClient client = SmsRetriever.getClient(context); + Task task = client.startSmsRetriever(); + + task.addOnSuccessListener(none -> { + Log.i(TAG, "Successfully registered SMS listener."); + requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_WITH_LISTENER); + }); + + task.addOnFailureListener(e -> { + Log.w(TAG, "Failed to register SMS listener.", e); + requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_WITHOUT_LISTENER); + }); + } else { + Log.i(TAG, "FCM is not supported, using no SMS listener"); + requestVerificationCode(e164number, RegistrationCodeRequest.Mode.SMS_WITHOUT_LISTENER); + } + } + + private void disableAllEntries() { + countryCode.setEnabled(false); + number.setEnabled(false); + countrySpinner.setEnabled(false); + cancel.setVisibility(View.GONE); + } + + private void enableAllEntries() { + countryCode.setEnabled(true); + number.setEnabled(true); + countrySpinner.setEnabled(true); + if (isReregister()) { + cancel.setVisibility(View.VISIBLE); + } + } + + private void requestVerificationCode(String e164number, @NonNull RegistrationCodeRequest.Mode mode) { + RegistrationViewModel model = getModel(); + String captcha = model.getCaptchaToken(); + model.clearCaptchaResponse(); + + NavController navController = Navigation.findNavController(register); + + if (!model.getRequestLimiter().canRequest(mode, e164number, System.currentTimeMillis())) { + Log.i(TAG, "Local rate limited"); + navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); + cancelSpinning(register); + enableAllEntries(); + return; + } + + RegistrationService registrationService = RegistrationService.getInstance(e164number, model.getRegistrationSecret()); + + registrationService.requestVerificationCode(requireActivity(), mode, captcha, + new RegistrationCodeRequest.SmsVerificationCodeCallback() { + + @Override + public void onNeedCaptcha() { + if (getContext() == null) { + Log.i(TAG, "Got onNeedCaptcha response, but fragment is no longer attached."); + return; + } + navController.navigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha()); + cancelSpinning(register); + enableAllEntries(); + model.getRequestLimiter().onUnsuccessfulRequest(); + model.updateLimiter(); + } + + @Override + public void requestSent(@Nullable String fcmToken) { + if (getContext() == null) { + Log.i(TAG, "Got requestSent response, but fragment is no longer attached."); + return; + } + model.setFcmToken(fcmToken); + model.markASuccessfulAttempt(); + navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); + cancelSpinning(register); + enableAllEntries(); + model.getRequestLimiter().onSuccessfulRequest(mode, e164number, System.currentTimeMillis()); + model.updateLimiter(); + } + + @Override + public void onRateLimited() { + Toast.makeText(register.getContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show(); + cancelSpinning(register); + enableAllEntries(); + model.getRequestLimiter().onUnsuccessfulRequest(); + model.updateLimiter(); + } + + @Override + public void onError() { + Toast.makeText(register.getContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show(); + cancelSpinning(register); + enableAllEntries(); + model.getRequestLimiter().onUnsuccessfulRequest(); + model.updateLimiter(); + } + }); + } + + private void initializeSpinner(Spinner countrySpinner) { + countrySpinnerAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item); + countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + setCountryDisplay(getString(R.string.RegistrationActivity_select_your_country)); + + countrySpinner.setAdapter(countrySpinnerAdapter); + countrySpinner.setOnTouchListener((view, event) -> { + if (event.getAction() == MotionEvent.ACTION_UP) { + pickCountry(view); + } + return true; + }); + countrySpinner.setOnKeyListener((view, keyCode, event) -> { + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) { + pickCountry(view); + return true; + } + return false; + }); + } + + private void pickCountry(@NonNull View view) { + Navigation.findNavController(view).navigate(R.id.action_pickCountry); + } + + private void initNumber(@NonNull NumberViewState numberViewState) { + int countryCode = numberViewState.getCountryCode(); + String number = numberViewState.getNationalNumber(); + String regionDisplayName = numberViewState.getCountryDisplayName(); + + this.countryCode.setText(String.valueOf(countryCode)); + + setCountryDisplay(regionDisplayName); + + String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode); + setCountryFormatter(regionCode); + + if (!TextUtils.isEmpty(number)) { + this.number.setText(String.valueOf(number)); + } + } + + private void setCountryDisplay(String regionDisplayName) { + countrySpinnerAdapter.clear(); + if (regionDisplayName == null) { + countrySpinnerAdapter.add(getString(R.string.RegistrationActivity_select_your_country)); + } else { + countrySpinnerAdapter.add(regionDisplayName); + } + } + + private class CountryCodeChangedListener implements TextWatcher { + @Override + public void afterTextChanged(Editable s) { + if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) { + setCountryDisplay(null); + countryFormatter = null; + return; + } + + int countryCode = Integer.parseInt(s.toString()); + String regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode); + + setCountryFormatter(regionCode); + + if (!TextUtils.isEmpty(regionCode) && !regionCode.equals("ZZ")) { + number.requestFocus(); + + int numberLength = number.getText().length(); + number.getInput().setSelection(numberLength, numberLength); + } + + RegistrationViewModel model = getModel(); + + model.onCountrySelected(null, countryCode); + setCountryDisplay(model.getNumber().getCountryDisplayName()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } + + private class NumberChangedListener implements TextWatcher { + + @Override + public void afterTextChanged(Editable s) { + String number = reformatText(s); + + if (number == null) return; + + RegistrationViewModel model = getModel(); + + model.setNationalNumber(number); + + setCountryDisplay(model.getNumber().getCountryDisplayName()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + } + + private String reformatText(Editable s) { + if (countryFormatter == null) { + return null; + } + + if (TextUtils.isEmpty(s)) { + return null; + } + + countryFormatter.clear(); + + String formattedNumber = null; + StringBuilder justDigits = new StringBuilder(); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isDigit(c)) { + formattedNumber = countryFormatter.inputDigit(c); + justDigits.append(c); + } + } + + if (formattedNumber != null && !s.toString().equals(formattedNumber)) { + s.replace(0, s.length(), formattedNumber); + } + + if (justDigits.length() == 0) { + return null; + } + + return justDigits.toString(); + } + + private void setCountryFormatter(@Nullable String regionCode) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null; + + reformatText(number.getText()); + } + + private void handlePromptForNoPlayServices(@NonNull Context context, @NonNull String e164number) { + new AlertDialog.Builder(context) + .setTitle(R.string.RegistrationActivity_missing_google_play_services) + .setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services) + .setPositiveButton(R.string.RegistrationActivity_i_understand, (dialog1, which) -> handleRequestVerification(context, e164number, false)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + protected final void confirmNumberPrompt(@NonNull Context context, + @NonNull String e164number, + @NonNull Runnable onConfirmed) + { + showConfirmNumberDialogIfTranslated(context, + R.string.RegistrationActivity_a_verification_code_will_be_sent_to, + e164number, + () -> { + hideKeyboard(context, number.getInput()); + onConfirmed.run(); + }, + () -> number.focusAndMoveCursorToEndAndOpenKeyboard()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationCompleteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationCompleteFragment.java new file mode 100644 index 00000000..17fc98a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationCompleteFragment.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.navigation.ActivityNavigator; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.pin.PinRestoreActivity; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; + +public final class RegistrationCompleteFragment extends BaseRegistrationFragment { + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_blank, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + FragmentActivity activity = requireActivity(); + + if (SignalStore.storageServiceValues().needsAccountRestore()) { + activity.startActivity(new Intent(activity, PinRestoreActivity.class)); + } else if (!isReregister()) { + final Intent main = MainActivity.clearTop(activity); + final Intent profile = EditProfileActivity.getIntentForUserProfile(activity); + + Intent kbs = CreateKbsPinActivity.getIntentForPinCreate(requireContext()); + activity.startActivity(chainIntents(chainIntents(profile, kbs), main)); + } + + activity.finish(); + ActivityNavigator.applyPopAnimationsToPendingTransition(activity); + } + + private static Intent chainIntents(@NonNull Intent sourceIntent, @Nullable Intent nextIntent) { + if (nextIntent != null) sourceIntent.putExtra("next_intent", nextIntent); + return sourceIntent; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationConstants.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationConstants.java new file mode 100644 index 00000000..5d9497fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationConstants.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.registration.fragments; + +final class RegistrationConstants { + + private RegistrationConstants() { + } + + static final String TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"; + static final String SIGNAL_CAPTCHA_URL = "https://signalcaptchas.org/registration/generate.html"; + static final String SIGNAL_CAPTCHA_SCHEME = "signalcaptcha://"; + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java new file mode 100644 index 00000000..2596cf19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java @@ -0,0 +1,343 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.content.res.Resources; +import android.os.Bundle; +import android.text.InputType; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; +import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData; +import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest; +import org.thoughtcrime.securesms.registration.service.RegistrationService; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.SupportEmailUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.concurrent.TimeUnit; + +public final class RegistrationLockFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(RegistrationLockFragment.class); + + /** Applies to both V1 and V2 pins, because some V2 pins may have been migrated from V1. */ + private static final int MINIMUM_PIN_LENGTH = 4; + + private EditText pinEntry; + private View forgotPin; + private CircularProgressButton pinButton; + private TextView errorLabel; + private TextView keyboardToggle; + private long timeRemaining; + private boolean isV1RegistrationLock; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_lock, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title)); + + pinEntry = view.findViewById(R.id.kbs_lock_pin_input); + pinButton = view.findViewById(R.id.kbs_lock_pin_confirm); + errorLabel = view.findViewById(R.id.kbs_lock_pin_input_label); + keyboardToggle = view.findViewById(R.id.kbs_lock_keyboard_toggle); + forgotPin = view.findViewById(R.id.kbs_lock_forgot_pin); + + RegistrationLockFragmentArgs args = RegistrationLockFragmentArgs.fromBundle(requireArguments()); + + timeRemaining = args.getTimeRemaining(); + isV1RegistrationLock = args.getIsV1RegistrationLock(); + + if (isV1RegistrationLock) { + keyboardToggle.setVisibility(View.GONE); + } + + forgotPin.setVisibility(View.GONE); + forgotPin.setOnClickListener(v -> handleForgottenPin(timeRemaining)); + + pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE); + pinEntry.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + hideKeyboard(requireContext(), v); + handlePinEntry(); + return true; + } + return false; + }); + + enableAndFocusPinEntry(); + + pinButton.setOnClickListener((v) -> { + hideKeyboard(requireContext(), pinEntry); + handlePinEntry(); + }); + + keyboardToggle.setOnClickListener((v) -> { + PinKeyboardType keyboardType = getPinEntryKeyboardType(); + + updateKeyboard(keyboardType.getOther()); + keyboardToggle.setText(resolveKeyboardToggleText(keyboardType)); + }); + + PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther(); + keyboardToggle.setText(resolveKeyboardToggleText(keyboardType)); + + getModel().getLockedTimeRemaining() + .observe(getViewLifecycleOwner(), t -> timeRemaining = t); + + TokenData keyBackupCurrentToken = getModel().getKeyBackupCurrentToken(); + + if (keyBackupCurrentToken != null) { + int triesRemaining = keyBackupCurrentToken.getTriesRemaining(); + if (triesRemaining <= 3) { + int daysRemaining = getLockoutDays(timeRemaining); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__not_many_tries_left) + .setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport()) + .show(); + } + + if (triesRemaining < 5) { + errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)); + } + } + } + + private String getTriesRemainingDialogMessage(int triesRemaining, int daysRemaining) { + Resources resources = requireContext().getResources(); + String tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining); + String days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining); + + return tries + " " + days; + } + + private PinKeyboardType getPinEntryKeyboardType() { + boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER; + + return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC; + } + + private void handlePinEntry() { + pinEntry.setEnabled(false); + + final String pin = pinEntry.getText().toString(); + + int trimmedLength = pin.replace(" ", "").length(); + if (trimmedLength == 0) { + Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show(); + enableAndFocusPinEntry(); + return; + } + + if (trimmedLength < MINIMUM_PIN_LENGTH) { + Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show(); + enableAndFocusPinEntry(); + return; + } + + RegistrationViewModel model = getModel(); + RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret()); + TokenData tokenData = model.getKeyBackupCurrentToken(); + + setSpinning(pinButton); + + registrationService.verifyAccount(requireActivity(), + model.getFcmToken(), + model.getTextCodeEntered(), + pin, + tokenData, + + new CodeVerificationRequest.VerifyCallback() { + + @Override + public void onSuccessfulRegistration() { + handleSuccessfulPinEntry(); + } + + @Override + public void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining) { + getModel().setLockedTimeRemaining(timeRemaining); + + cancelSpinning(pinButton); + pinEntry.getText().clear(); + enableAndFocusPinEntry(); + + errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin); + } + + @Override + public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenData kbsTokenData, @NonNull String kbsStorageCredentials) { + throw new AssertionError("Not expected after a pin guess"); + } + + @Override + public void onIncorrectKbsRegistrationLockPin(@NonNull TokenData tokenData) { + cancelSpinning(pinButton); + pinEntry.getText().clear(); + enableAndFocusPinEntry(); + + model.setKeyBackupTokenData(tokenData); + + int triesRemaining = tokenData.getTriesRemaining(); + + if (triesRemaining == 0) { + Log.w(TAG, "Account locked. User out of attempts on KBS."); + onAccountLocked(); + return; + } + + if (triesRemaining == 3) { + int daysRemaining = getLockoutDays(timeRemaining); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__incorrect_pin) + .setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + if (triesRemaining > 5) { + errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again); + } else { + errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)); + forgotPin.setVisibility(View.VISIBLE); + } + } + + @Override + public void onRateLimited() { + cancelSpinning(pinButton); + enableAndFocusPinEntry(); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.RegistrationActivity_too_many_attempts) + .setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + @Override + public void onKbsAccountLocked(@Nullable Long timeRemaining) { + if (timeRemaining != null) { + model.setLockedTimeRemaining(timeRemaining); + } + + onAccountLocked(); + } + + @Override + public void onError() { + cancelSpinning(pinButton); + enableAndFocusPinEntry(); + + Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show(); + } + }); + } + + private void handleForgottenPin(long timeRemainingMs) { + int lockoutDays = getLockoutDays(timeRemainingMs); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__forgot_your_pin) + .setMessage(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support, (dialog, which) -> sendEmailToSupport()) + .show(); + } + + private static int getLockoutDays(long timeRemainingMs) { + return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1; + } + + private void onAccountLocked() { + Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked()); + } + + private void updateKeyboard(@NonNull PinKeyboardType keyboard) { + boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC; + + pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD + : InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD); + + pinEntry.getText().clear(); + } + + private @StringRes static int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) { + if (keyboard == PinKeyboardType.ALPHA_NUMERIC) { + return R.string.RegistrationLockFragment__enter_alphanumeric_pin; + } else { + return R.string.RegistrationLockFragment__enter_numeric_pin; + } + } + + private void enableAndFocusPinEntry() { + pinEntry.setEnabled(true); + pinEntry.setFocusable(true); + + if (pinEntry.requestFocus()) { + ServiceUtil.getInputMethodManager(pinEntry.getContext()).showSoftInput(pinEntry, 0); + } + } + + private void handleSuccessfulPinEntry() { + SignalStore.pinValues().setKeyboardType(getPinEntryKeyboardType()); + + long startTime = System.currentTimeMillis(); + SimpleTask.run(() -> { + SignalStore.onboarding().clearAll(); + return ApplicationDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN); + }, result -> { + long elapsedTime = System.currentTimeMillis() - startTime; + + if (result.isPresent()) { + Log.i(TAG, "Storage Service account restore completed: " + result.get().name() + ". (Took " + elapsedTime + " ms)"); + } else { + Log.i(TAG, "Storage Service account restore failed to complete in the allotted time. (" + elapsedTime + " ms elapsed)"); + } + cancelSpinning(pinButton); + Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration()); + }); + } + + private void sendEmailToSupport() { + int subject = isV1RegistrationLock ? R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v1_pin + : R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin; + + String body = SupportEmailUtil.generateSupportEmailBody(requireContext(), + subject, + null, + null); + CommunicationActions.openEmail(requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getString(subject), + body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java new file mode 100644 index 00000000..768d5a41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java @@ -0,0 +1,425 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; +import android.text.style.ReplacementSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AlertDialog; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.AppInitialization; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.FullBackupImporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.service.LocalBackupListener; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; +import java.util.Locale; + +public final class RestoreBackupFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(RestoreBackupFragment.class); + private static final short OPEN_DOCUMENT_TREE_RESULT_CODE = 13782; + + private TextView restoreBackupSize; + private TextView restoreBackupTime; + private TextView restoreBackupProgress; + private CircularProgressButton restoreButton; + private View skipRestoreButton; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_registration_restore_backup, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header)); + + Log.i(TAG, "Backup restore."); + + restoreBackupSize = view.findViewById(R.id.backup_size_text); + restoreBackupTime = view.findViewById(R.id.backup_created_text); + restoreBackupProgress = view.findViewById(R.id.backup_progress_text); + restoreButton = view.findViewById(R.id.restore_button); + skipRestoreButton = view.findViewById(R.id.skip_restore_button); + + skipRestoreButton.setOnClickListener((v) -> { + Log.i(TAG, "User skipped backup restore."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionSkip()); + }); + + if (isReregister()) { + Log.i(TAG, "Skipping backup restore during re-register."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionSkipNoReturn()); + return; + } + + if (TextSecurePreferences.isBackupEnabled(requireContext())) { + Log.i(TAG, "Backups enabled, so a backup must have been previously restored."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionSkipNoReturn()); + return; + } + + RestoreBackupFragmentArgs args = RestoreBackupFragmentArgs.fromBundle(requireArguments()); + if (BackupUtil.isUserSelectionRequired(requireContext()) && args.getUri() != null) { + Log.i(TAG, "Restoring backup from passed uri"); + initializeBackupForUri(view, args.getUri()); + + return; + } + + if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { + initializeBackupDetection(view); + } else { + Log.i(TAG, "Skipping backup detection. We don't have the permission."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionSkipNoReturn()); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == OPEN_DOCUMENT_TREE_RESULT_CODE && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) { + Uri backupDirectoryUri = data.getData(); + int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri); + requireContext().getContentResolver() + .takePersistableUriPermission(backupDirectoryUri, takeFlags); + + enableBackups(requireContext()); + + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + } + } + + @RequiresApi(29) + private void initializeBackupForUri(@NonNull View view, @NonNull Uri uri) { + getFromUri(requireContext(), uri, backup -> handleBackupInfo(view, backup)); + } + + @SuppressLint("StaticFieldLeak") + private void initializeBackupDetection(@NonNull View view) { + searchForBackup(backup -> handleBackupInfo(view, backup)); + } + + private void handleBackupInfo(@NonNull View view, @Nullable BackupUtil.BackupInfo backup) { + Context context = getContext(); + if (context == null) { + Log.i(TAG, "No context on fragment, must have navigated away."); + return; + } + + if (backup == null) { + Log.i(TAG, "Skipping backup detection. No backup found, or permission revoked since."); + Navigation.findNavController(view) + .navigate(RestoreBackupFragmentDirections.actionNoBackupFound()); + } else { + restoreBackupSize.setText(getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backup.getSize()))); + restoreBackupTime.setText(getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), backup.getTimestamp()))); + + restoreButton.setOnClickListener((v) -> handleRestore(v.getContext(), backup)); + } + } + + interface OnBackupSearchResultListener { + + @MainThread + void run(@Nullable BackupUtil.BackupInfo backup); + } + + static void searchForBackup(@NonNull OnBackupSearchResultListener listener) { + new AsyncTask() { + @Override + protected @Nullable + BackupUtil.BackupInfo doInBackground(Void... voids) { + try { + return BackupUtil.getLatestBackup(); + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return null; + } + } + + @Override + protected void onPostExecute(@Nullable BackupUtil.BackupInfo backup) { + listener.run(backup); + } + }.execute(); + } + + @RequiresApi(29) + static void getFromUri(@NonNull Context context, + @NonNull Uri backupUri, + @NonNull OnBackupSearchResultListener listener) + { + SimpleTask.run(() -> BackupUtil.getBackupInfoFromSingleUri(context, backupUri), + listener::run); + } + + private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) { + View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null); + EditText prompt = view.findViewById(R.id.restore_passphrase_input); + + prompt.addTextChangedListener(new PassphraseAsYouTypeFormatter()); + + new AlertDialog.Builder(context) + .setTitle(R.string.RegistrationActivity_enter_backup_passphrase) + .setView(view) + .setPositiveButton(R.string.RegistrationActivity_restore, (dialog, which) -> { + InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(prompt.getWindowToken(), 0); + + setSpinning(restoreButton); + skipRestoreButton.setVisibility(View.INVISIBLE); + + String passphrase = prompt.getText().toString(); + + restoreAsynchronously(context, backup, passphrase); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + Log.i(TAG, "Prompt for backup passphrase shown to user."); + } + + @SuppressLint("StaticFieldLeak") + private void restoreAsynchronously(@NonNull Context context, + @NonNull BackupUtil.BackupInfo backup, + @NonNull String passphrase) + { + new AsyncTask() { + @Override + protected BackupImportResult doInBackground(Void... voids) { + try { + Log.i(TAG, "Starting backup restore."); + + SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context); + + BackupPassphrase.set(context, passphrase); + FullBackupImporter.importFile(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + database, + backup.getUri(), + passphrase); + + DatabaseFactory.upgradeRestored(context, database); + NotificationChannels.restoreContactNotificationChannels(context); + + enableBackups(context); + + AppInitialization.onPostBackupRestore(context); + + Log.i(TAG, "Backup restore complete."); + return BackupImportResult.SUCCESS; + } catch (FullBackupImporter.DatabaseDowngradeException e) { + Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e); + return BackupImportResult.FAILURE_VERSION_DOWNGRADE; + } catch (IOException e) { + Log.w(TAG, e); + return BackupImportResult.FAILURE_UNKNOWN; + } + } + + @Override + protected void onPostExecute(@NonNull BackupImportResult result) { + cancelSpinning(restoreButton); + skipRestoreButton.setVisibility(View.VISIBLE); + + restoreBackupProgress.setText(""); + + switch (result) { + case SUCCESS: + Log.i(TAG, "Successful backup restore."); + break; + case FAILURE_VERSION_DOWNGRADE: + Toast.makeText(context, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show(); + break; + case FAILURE_UNKNOWN: + Toast.makeText(context, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show(); + break; + } + } + }.execute(); + } + + @Override + public void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + super.onStop(); + EventBus.getDefault().unregister(this); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEvent(@NonNull FullBackupBase.BackupEvent event) { + int count = event.getCount(); + + if (count == 0) { + restoreBackupProgress.setText(R.string.RegistrationActivity_checking); + } else { + restoreBackupProgress.setText(getString(R.string.RegistrationActivity_d_messages_so_far, count)); + } + + setSpinning(restoreButton); + skipRestoreButton.setVisibility(View.INVISIBLE); + + if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + if (BackupUtil.isUserSelectionRequired(requireContext()) && !BackupUtil.canUserAccessBackupDirectory(requireContext())) { + displayConfirmationDialog(requireContext()); + } else { + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + } + } + } + + private void enableBackups(@NonNull Context context) { + if (BackupUtil.canUserAccessBackupDirectory(context)) { + LocalBackupListener.setNextBackupTimeToIntervalFromNow(context); + TextSecurePreferences.setBackupEnabled(context, true); + LocalBackupListener.schedule(context); + } + } + + @RequiresApi(29) + private void displayConfirmationDialog(@NonNull Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.RestoreBackupFragment__restore_complete) + .setMessage(R.string.RestoreBackupFragment__to_continue_using_backups_please_choose_a_folder) + .setPositiveButton(R.string.RestoreBackupFragment__choose_folder, (dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_READ_URI_PERMISSION); + + startActivityForResult(intent, OPEN_DOCUMENT_TREE_RESULT_CODE); + }) + .setNegativeButton(R.string.RestoreBackupFragment__not_now, (dialog, which) -> { + BackupPassphrase.set(context, null); + dialog.dismiss(); + + Navigation.findNavController(requireView()) + .navigate(RestoreBackupFragmentDirections.actionBackupRestored()); + }) + .setCancelable(false) + .show(); + } + + private enum BackupImportResult { + SUCCESS, + FAILURE_VERSION_DOWNGRADE, + FAILURE_UNKNOWN + } + + public static class PassphraseAsYouTypeFormatter implements TextWatcher { + + private static final int GROUP_SIZE = 5; + + @Override + public void afterTextChanged(Editable editable) { + removeSpans(editable); + + addSpans(editable); + } + + private static void removeSpans(Editable editable) { + SpaceSpan[] paddingSpans = editable.getSpans(0, editable.length(), SpaceSpan.class); + + for (SpaceSpan span : paddingSpans) { + editable.removeSpan(span); + } + } + + private static void addSpans(Editable editable) { + final int length = editable.length(); + + for (int i = GROUP_SIZE; i < length; i += GROUP_SIZE) { + editable.setSpan(new SpaceSpan(), i - 1, i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (editable.length() > BackupUtil.PASSPHRASE_LENGTH) { + editable.delete(BackupUtil.PASSPHRASE_LENGTH, editable.length()); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } + + /** + * A {@link ReplacementSpan} adds a small space after a single character. + * Based on https://stackoverflow.com/a/51949578 + */ + private static class SpaceSpan extends ReplacementSpan { + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + return (int) (paint.measureText(text, start, end) * 1.7f); + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + canvas.drawText(text.subSequence(start, end).toString(), x, y, paint); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java new file mode 100644 index 00000000..fded5f0a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.content.Context; +import android.os.Build; +import android.telephony.PhoneStateListener; +import android.telephony.SignalStrength; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.Debouncer; + +final class SignalStrengthPhoneStateListener extends PhoneStateListener + implements DefaultLifecycleObserver +{ + private static final String TAG = Log.tag(SignalStrengthPhoneStateListener.class); + + private final Callback callback; + private final Debouncer debouncer = new Debouncer(1000); + + SignalStrengthPhoneStateListener(@NonNull LifecycleOwner lifecycleOwner, @NonNull Callback callback) { + this.callback = callback; + + lifecycleOwner.getLifecycle().addObserver(this); + } + + @Override + public void onSignalStrengthsChanged(SignalStrength signalStrength) { + if (signalStrength == null) return; + + if (isLowLevel(signalStrength)) { + Log.w(TAG, "No cell signal detected"); + debouncer.publish(callback::onNoCellSignalPresent); + } else { + Log.i(TAG, "Cell signal detected"); + debouncer.clear(); + callback.onCellSignalPresent(); + } + } + + private boolean isLowLevel(@NonNull SignalStrength signalStrength) { + if (Build.VERSION.SDK_INT >= 23) { + return signalStrength.getLevel() == 0; + } else { + //noinspection deprecation: False lint warning, deprecated by 29, but this else block is for < 23 + return signalStrength.getGsmSignalStrength() == 0; + } + } + + interface Callback { + void onNoCellSignalPresent(); + + void onCellSignalPresent(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + TelephonyManager telephonyManager = (TelephonyManager) ApplicationDependencies.getApplication().getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(this, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS); + Log.i(TAG, "Listening to cell phone signal strength changes"); + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + TelephonyManager telephonyManager = (TelephonyManager) ApplicationDependencies.getApplication().getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(this, PhoneStateListener.LISTEN_NONE); + Log.i(TAG, "Stopped listening to cell phone signal strength changes"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java new file mode 100644 index 00000000..8b8ca00b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.registration.fragments; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.navigation.ActivityNavigator; +import androidx.navigation.Navigation; + +import com.dd.CircularProgressButton; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +public final class WelcomeFragment extends BaseRegistrationFragment { + + private static final String TAG = Log.tag(WelcomeFragment.class); + + private static final String[] PERMISSIONS = { Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.READ_PHONE_STATE }; + @RequiresApi(26) + private static final String[] PERMISSIONS_API_26 = { Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_PHONE_NUMBERS }; + @RequiresApi(26) + private static final String[] PERMISSIONS_API_29 = { Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_PHONE_NUMBERS }; + private static final @StringRes int RATIONALE = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_and_media_in_order_to_connect_with_friends; + private static final @StringRes int RATIONALE_API_29 = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_in_order_to_connect_with_friends; + private static final int[] HEADERS = { R.drawable.ic_contacts_white_48dp, R.drawable.ic_folder_white_48dp }; + private static final int[] HEADERS_API_29 = { R.drawable.ic_contacts_white_48dp }; + + private CircularProgressButton continueButton; + private View restoreFromBackup; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(isReregister() ? R.layout.fragment_registration_blank + : R.layout.fragment_registration_welcome, + container, + false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + if (isReregister()) { + RegistrationViewModel model = getModel(); + + if (model.hasRestoreFlowBeenShown()) { + Log.i(TAG, "We've come back to the home fragment on a restore, user must be backing out"); + if (!Navigation.findNavController(view).popBackStack()) { + FragmentActivity activity = requireActivity(); + activity.finish(); + ActivityNavigator.applyPopAnimationsToPendingTransition(activity); + } + return; + } + + initializeNumber(); + + Log.i(TAG, "Skipping restore because this is a reregistration."); + model.setWelcomeSkippedOnRestore(); + Navigation.findNavController(view) + .navigate(WelcomeFragmentDirections.actionSkipRestore()); + + } else { + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.image)); + setDebugLogSubmitMultiTapView(view.findViewById(R.id.title)); + + continueButton = view.findViewById(R.id.welcome_continue_button); + continueButton.setOnClickListener(this::continueClicked); + + restoreFromBackup = view.findViewById(R.id.welcome_restore_backup); + restoreFromBackup.setOnClickListener(this::restoreFromBackupClicked); + + TextView welcomeTermsButton = view.findViewById(R.id.welcome_terms_button); + welcomeTermsButton.setOnClickListener(v -> onTermsClicked()); + + if (canUserSelectBackup()) { + restoreFromBackup.setVisibility(View.VISIBLE); + welcomeTermsButton.setTextColor(ContextCompat.getColor(requireActivity(), R.color.core_grey_60)); + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + private void continueClicked(@NonNull View view) { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + + Permissions.with(this) + .request(getContinuePermissions(isUserSelectionRequired)) + .ifNecessary() + .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) + .onAnyResult(() -> gatherInformationAndContinue(continueButton)) + .execute(); + } + + private void restoreFromBackupClicked(@NonNull View view) { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + + Permissions.with(this) + .request(getContinuePermissions(isUserSelectionRequired)) + .ifNecessary() + .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) + .onAnyResult(() -> gatherInformationAndChooseBackup(continueButton)) + .execute(); + } + + private void gatherInformationAndContinue(@NonNull View view) { + setSpinning(continueButton); + + RestoreBackupFragment.searchForBackup(backup -> { + Context context = getContext(); + if (context == null) { + Log.i(TAG, "No context on fragment, must have navigated away."); + return; + } + + TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true); + + initializeNumber(); + + cancelSpinning(continueButton); + + if (backup == null) { + Log.i(TAG, "Skipping backup. No backup found, or no permission to look."); + Navigation.findNavController(view) + .navigate(WelcomeFragmentDirections.actionSkipRestore()); + } else { + Navigation.findNavController(view) + .navigate(WelcomeFragmentDirections.actionRestore()); + } + }); + } + + private void gatherInformationAndChooseBackup(@NonNull View view) { + TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true); + + initializeNumber(); + + Navigation.findNavController(view) + .navigate(WelcomeFragmentDirections.actionChooseBackup()); + } + + @SuppressLint("MissingPermission") + private void initializeNumber() { + Optional localNumber = Optional.absent(); + + if (Permissions.hasAll(requireContext(), Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) { + localNumber = Util.getDeviceNumber(requireContext()); + } else { + Log.i(TAG, "No phone permission"); + } + + if (localNumber.isPresent()) { + Log.i(TAG, "Phone number detected"); + Phonenumber.PhoneNumber phoneNumber = localNumber.get(); + String nationalNumber = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL); + + getModel().onNumberDetected(phoneNumber.getCountryCode(), nationalNumber); + } else { + Log.i(TAG, "No number detected"); + Optional simCountryIso = Util.getSimCountryIso(requireContext()); + + if (simCountryIso.isPresent() && !TextUtils.isEmpty(simCountryIso.get())) { + getModel().onNumberDetected(PhoneNumberUtil.getInstance().getCountryCodeForRegion(simCountryIso.get()), ""); + } + } + } + + private void onTermsClicked() { + CommunicationActions.openBrowserLink(requireContext(), RegistrationConstants.TERMS_AND_CONDITIONS_URL); + } + + private boolean canUserSelectBackup() { + return BackupUtil.isUserSelectionRequired(requireContext()) && + !isReregister() && + !TextSecurePreferences.isBackupEnabled(requireContext()); + } + + @SuppressLint("NewApi") + private static String[] getContinuePermissions(boolean isUserSelectionRequired) { + if (isUserSelectionRequired) { + return PERMISSIONS_API_29; + } else if (Build.VERSION.SDK_INT >= 26) { + return PERMISSIONS_API_26; + } else { + return PERMISSIONS; + } + } + + private static @StringRes int getContinueRationale(boolean isUserSelectionRequired) { + return isUserSelectionRequired ? RATIONALE_API_29 : RATIONALE; + } + + private static int[] getContinueHeaders(boolean isUserSelectionRequired) { + return isUserSelectionRequired ? HEADERS_API_29 : HEADERS; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java new file mode 100644 index 00000000..9c0ccf56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java @@ -0,0 +1,314 @@ +package org.thoughtcrime.securesms.registration.service; + +import android.content.Context; +import android.os.AsyncTask; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.AppCapabilities; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.PreKeyUtil; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.crypto.SessionUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.RotateCertificateJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.pin.PinRestoreRepository; +import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData; +import org.thoughtcrime.securesms.pin.PinState; +import org.thoughtcrime.securesms.push.AccountManagerFactory; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.DirectoryRefreshListener; +import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.util.KeyHelper; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.KbsPinData; +import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.push.exceptions.RateLimitException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.LockedException; +import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +public final class CodeVerificationRequest { + + private static final String TAG = Log.tag(CodeVerificationRequest.class); + + private enum Result { + SUCCESS, + PIN_LOCKED, + KBS_WRONG_PIN, + RATE_LIMITED, + KBS_ACCOUNT_LOCKED, + ERROR + } + + /** + * Asynchronously verify the account via the code. + * + * @param fcmToken The FCM token for the device. + * @param code The code that was delivered to the user. + * @param pin The users registration pin. + * @param callback Exactly one method on this callback will be called. + * @param kbsTokenData By keeping the token, on failure, a newly returned token will be reused in subsequent pin + * attempts, preventing certain attacks, we can also track the attempts making missing replies easier to spot. + */ + static void verifyAccount(@NonNull Context context, + @NonNull Credentials credentials, + @Nullable String fcmToken, + @NonNull String code, + @Nullable String pin, + @Nullable TokenData kbsTokenData, + @NonNull VerifyCallback callback) + { + new AsyncTask() { + + private volatile LockedException lockedException; + private volatile TokenData tokenData; + + @Override + protected Result doInBackground(Void... voids) { + final boolean pinSupplied = pin != null; + final boolean tryKbs = tokenData != null; + + try { + this.tokenData = kbsTokenData; + verifyAccount(context, credentials, code, pin, tokenData, fcmToken); + return Result.SUCCESS; + } catch (KeyBackupSystemNoDataException e) { + Log.w(TAG, "No data found on KBS"); + return Result.KBS_ACCOUNT_LOCKED; + } catch (KeyBackupSystemWrongPinException e) { + tokenData = TokenData.withResponse(tokenData, e.getTokenResponse()); + return Result.KBS_WRONG_PIN; + } catch (LockedException e) { + if (pinSupplied && tryKbs) { + throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!"); + } + + Log.w(TAG, e); + lockedException = e; + if (e.getBasicStorageCredentials() != null) { + try { + tokenData = getToken(e.getBasicStorageCredentials()); + if (tokenData == null || tokenData.getTriesRemaining() == 0) { + return Result.KBS_ACCOUNT_LOCKED; + } + } catch (IOException ex) { + Log.w(TAG, e); + return Result.ERROR; + } + } + return Result.PIN_LOCKED; + } catch (RateLimitException e) { + Log.w(TAG, e); + return Result.RATE_LIMITED; + } catch (IOException e) { + Log.w(TAG, e); + return Result.ERROR; + } + } + + @Override + protected void onPostExecute(Result result) { + switch (result) { + case SUCCESS: + handleSuccessfulRegistration(context); + callback.onSuccessfulRegistration(); + break; + case PIN_LOCKED: + if (tokenData != null) { + if (lockedException.getBasicStorageCredentials() == null) { + throw new AssertionError("KBS Token set, but no storage credentials supplied."); + } + Log.w(TAG, "Reg Locked: V2 pin needed for registration"); + callback.onKbsRegistrationLockPinRequired(lockedException.getTimeRemaining(), tokenData, lockedException.getBasicStorageCredentials()); + } else { + Log.w(TAG, "Reg Locked: V1 pin needed for registration"); + callback.onV1RegistrationLockPinRequiredOrIncorrect(lockedException.getTimeRemaining()); + } + break; + case RATE_LIMITED: + callback.onRateLimited(); + break; + case ERROR: + callback.onError(); + break; + case KBS_WRONG_PIN: + Log.w(TAG, "KBS Pin was wrong"); + callback.onIncorrectKbsRegistrationLockPin(tokenData); + break; + case KBS_ACCOUNT_LOCKED: + Log.w(TAG, "KBS Account is locked"); + callback.onKbsAccountLocked(lockedException != null ? lockedException.getTimeRemaining() : null); + break; + } + } + }.executeOnExecutor(SignalExecutors.UNBOUNDED); + } + + private static TokenData getToken(@Nullable String basicStorageCredentials) throws IOException { + if (basicStorageCredentials == null) return null; + return new PinRestoreRepository().getTokenSync(basicStorageCredentials); + } + + private static void handleSuccessfulRegistration(@NonNull Context context) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + jobManager.add(new DirectoryRefreshJob(false)); + jobManager.add(new RotateCertificateJob()); + + DirectoryRefreshListener.schedule(context); + RotateSignedPreKeyListener.schedule(context); + } + + private static void verifyAccount(@NonNull Context context, + @NonNull Credentials credentials, + @NonNull String code, + @Nullable String pin, + @Nullable TokenData kbsTokenData, + @Nullable String fcmToken) + throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException + { + boolean isV2RegistrationLock = kbsTokenData != null; + int registrationId = KeyHelper.generateRegistrationId(false); + boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context); + ProfileKey profileKey = findExistingProfileKey(context, credentials.getE164number()); + + if (profileKey == null) { + profileKey = ProfileKeyUtil.createNew(); + Log.i(TAG, "No profile key found, created a new one"); + } + + byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(profileKey); + + TextSecurePreferences.setLocalRegistrationId(context, registrationId); + SessionUtil.archiveAllSessions(context); + + SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword()); + KbsPinData kbsData = isV2RegistrationLock ? PinState.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse()) : null; + String registrationLockV2 = kbsData != null ? kbsData.getMasterKey().deriveRegistrationLock() : null; + String registrationLockV1 = isV2RegistrationLock ? null : pin; + boolean hasFcm = fcmToken != null; + + Log.i(TAG, "Calling verifyAccountWithCode(): reglockV1? " + !TextUtils.isEmpty(registrationLockV1) + ", reglockV2? " + !TextUtils.isEmpty(registrationLockV2)); + + VerifyAccountResponse response = accountManager.verifyAccountWithCode(code, + null, + registrationId, + !hasFcm, + registrationLockV1, + registrationLockV2, + unidentifiedAccessKey, + universalUnidentifiedAccess, + AppCapabilities.getCapabilities(true), + SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode().isDiscoverable()); + + UUID uuid = UuidUtil.parseOrThrow(response.getUuid()); + boolean hasPin = response.isStorageCapable(); + + IdentityKeyPair identityKey = IdentityKeyUtil.getIdentityKeyPair(context); + List records = PreKeyUtil.generatePreKeys(context); + SignedPreKeyRecord signedPreKey = PreKeyUtil.generateSignedPreKey(context, identityKey, true); + + accountManager = AccountManagerFactory.createAuthenticated(context, uuid, credentials.getE164number(), credentials.getPassword()); + accountManager.setPreKeys(identityKey.getPublicKey(), signedPreKey, records); + + if (hasFcm) { + accountManager.setGcmId(Optional.fromNullable(fcmToken)); + } + + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientId selfId = recipientDatabase.getOrInsertFromE164(credentials.getE164number()); + + recipientDatabase.setProfileSharing(selfId, true); + recipientDatabase.markRegisteredOrThrow(selfId, uuid); + + TextSecurePreferences.setLocalNumber(context, credentials.getE164number()); + TextSecurePreferences.setLocalUuid(context, uuid); + recipientDatabase.setProfileKey(selfId, profileKey); + ApplicationDependencies.getRecipientCache().clearSelf(); + + TextSecurePreferences.setFcmToken(context, fcmToken); + TextSecurePreferences.setFcmDisabled(context, !hasFcm); + TextSecurePreferences.setWebsocketRegistered(context, true); + + DatabaseFactory.getIdentityDatabase(context) + .saveIdentity(selfId, + identityKey.getPublicKey(), IdentityDatabase.VerifiedStatus.VERIFIED, + true, System.currentTimeMillis(), true); + + TextSecurePreferences.setPushRegistered(context, true); + TextSecurePreferences.setPushServerPassword(context, credentials.getPassword()); + TextSecurePreferences.setSignedPreKeyRegistered(context, true); + TextSecurePreferences.setPromptedPushRegistration(context, true); + TextSecurePreferences.setUnauthorizedReceived(context, false); + + PinState.onRegistration(context, kbsData, pin, hasPin); + } + + private static @Nullable ProfileKey findExistingProfileKey(@NonNull Context context, @NonNull String e164number) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + Optional recipient = recipientDatabase.getByE164(e164number); + + if (recipient.isPresent()) { + return ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).getProfileKey()); + } + + return null; + } + + public interface VerifyCallback { + + void onSuccessfulRegistration(); + + /** + * The account is locked with a V1 (non-KBS) pin. + * + * @param timeRemaining Time until pin expires and number can be reused. + */ + void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining); + + /** + * The account is locked with a V2 (KBS) pin. Called before any user pin guesses. + */ + void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenData kbsTokenData, @NonNull String kbsStorageCredentials); + + /** + * The account is locked with a V2 (KBS) pin. Called after a user pin guess. + *

+ * i.e. an attempt has likely been used. + */ + void onIncorrectKbsRegistrationLockPin(@NonNull TokenData kbsTokenResponse); + + /** + * V2 (KBS) pin is set, but there is no data on KBS. + * + * @param timeRemaining Non-null if known. + */ + void onKbsAccountLocked(@Nullable Long timeRemaining); + + void onRateLimited(); + + void onError(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/Credentials.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/Credentials.java new file mode 100644 index 00000000..4501816b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/Credentials.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.registration.service; + +import androidx.annotation.NonNull; + +public final class Credentials { + + private final String e164number; + private final String password; + + public Credentials(@NonNull String e164number, @NonNull String password) { + this.e164number = e164number; + this.password = password; + } + + public @NonNull String getE164number() { + return e164number; + } + + public @NonNull String getPassword() { + return password; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/KeyBackupSystemWrongPinException.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/KeyBackupSystemWrongPinException.java new file mode 100644 index 00000000..4c0c52ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/KeyBackupSystemWrongPinException.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.registration.service; + +import androidx.annotation.NonNull; + +import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; + +public final class KeyBackupSystemWrongPinException extends Exception { + + private final TokenResponse tokenResponse; + + public KeyBackupSystemWrongPinException(@NonNull TokenResponse tokenResponse){ + this.tokenResponse = tokenResponse; + } + + public @NonNull TokenResponse getTokenResponse() { + return tokenResponse; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationCodeRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationCodeRequest.java new file mode 100644 index 00000000..ae01e7ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationCodeRequest.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.registration.service; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.gcm.FcmUtil; +import org.thoughtcrime.securesms.push.AccountManagerFactory; +import org.thoughtcrime.securesms.registration.PushChallengeRequest; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; +import org.whispersystems.signalservice.api.push.exceptions.RateLimitException; + +import java.io.IOException; +import java.util.Locale; + +public final class RegistrationCodeRequest { + + private static final long PUSH_REQUEST_TIMEOUT_MS = 5000L; + + private static final String TAG = Log.tag(RegistrationCodeRequest.class); + + /** + * Request a verification code to be sent according to the specified {@param mode}. + * + * The request will fire asynchronously, and exactly one of the methods on the {@param callback} + * will be called. + */ + @SuppressLint("StaticFieldLeak") + static void requestSmsVerificationCode(@NonNull Context context, @NonNull Credentials credentials, @Nullable String captchaToken, @NonNull Mode mode, @NonNull SmsVerificationCodeCallback callback) { + Log.d(TAG, "SMS Verification requested"); + + new AsyncTask() { + @Override + protected @NonNull + VerificationRequestResult doInBackground(Void... voids) { + try { + markAsVerifying(context); + + Optional fcmToken = FcmUtil.getToken(); + + SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword()); + + Optional pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, fcmToken, credentials.getE164number(), PUSH_REQUEST_TIMEOUT_MS); + + if (mode == Mode.PHONE_CALL) { + accountManager.requestVoiceVerificationCode(Locale.getDefault(), Optional.fromNullable(captchaToken), pushChallenge); + } else { + accountManager.requestSmsVerificationCode(mode.isSmsRetrieverSupported(), Optional.fromNullable(captchaToken), pushChallenge); + } + + return new VerificationRequestResult(fcmToken.orNull(), Optional.absent()); + } catch (IOException e) { + org.signal.core.util.logging.Log.w(TAG, "Error during account registration", e); + return new VerificationRequestResult(null, Optional.of(e)); + } + } + + protected void onPostExecute(@NonNull VerificationRequestResult result) { + if (isCaptchaRequired(result)) { + callback.onNeedCaptcha(); + } else if (isRateLimited(result)) { + callback.onRateLimited(); + } else if (result.exception.isPresent()) { + callback.onError(); + } else { + callback.requestSent(result.fcmToken); + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private static void markAsVerifying(Context context) { + TextSecurePreferences.setPushRegistered(context, false); + } + + private static boolean isCaptchaRequired(@NonNull VerificationRequestResult result) { + return result.exception.isPresent() && result.exception.get() instanceof CaptchaRequiredException; + } + + private static boolean isRateLimited(@NonNull VerificationRequestResult result) { + return result.exception.isPresent() && result.exception.get() instanceof RateLimitException; + } + + private static class VerificationRequestResult { + private final @Nullable String fcmToken; + private final Optional exception; + + private VerificationRequestResult(@Nullable String fcmToken, Optional exception) { + this.fcmToken = fcmToken; + this.exception = exception; + } + } + + /** + * The mode by which a code is being requested. + */ + public enum Mode { + + /** + * Device is requesting an SMS and supports SMS retrieval. + * + * The SMS sent will be formatted for automatic SMS retrieval. + */ + SMS_WITH_LISTENER(true), + + /** + * Device is requesting an SMS and does not support SMS retrieval. + * + * The SMS sent will be not be specially formatted for automatic SMS retrieval. + */ + SMS_WITHOUT_LISTENER(false), + + /** + * Device is requesting a phone call. + */ + PHONE_CALL(false); + + private final boolean smsRetrieverSupported; + + Mode(boolean smsRetrieverSupported) { + this.smsRetrieverSupported = smsRetrieverSupported; + } + + public boolean isSmsRetrieverSupported() { + return smsRetrieverSupported; + } + } + + public interface SmsVerificationCodeCallback { + + void onNeedCaptcha(); + + void requestSent(@Nullable String fcmToken); + + void onRateLimited(); + + void onError(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationService.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationService.java new file mode 100644 index 00000000..efba5883 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/RegistrationService.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.registration.service; + +import android.app.Activity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.pin.PinRestoreRepository; + +public final class RegistrationService { + + private final Credentials credentials; + + private RegistrationService(@NonNull Credentials credentials) { + this.credentials = credentials; + } + + public static RegistrationService getInstance(@NonNull String e164number, @NonNull String password) { + return new RegistrationService(new Credentials(e164number, password)); + } + + /** + * See {@link RegistrationCodeRequest}. + */ + public void requestVerificationCode(@NonNull Activity activity, + @NonNull RegistrationCodeRequest.Mode mode, + @Nullable String captchaToken, + @NonNull RegistrationCodeRequest.SmsVerificationCodeCallback callback) + { + RegistrationCodeRequest.requestSmsVerificationCode(activity, credentials, captchaToken, mode, callback); + } + + /** + * See {@link CodeVerificationRequest}. + */ + public void verifyAccount(@NonNull Activity activity, + @Nullable String fcmToken, + @NonNull String code, + @Nullable String pin, + @Nullable PinRestoreRepository.TokenData tokenData, + @NonNull CodeVerificationRequest.VerifyCallback callback) + { + CodeVerificationRequest.verifyAccount(activity, credentials, fcmToken, code, pin, tokenData, callback); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java new file mode 100644 index 00000000..c6b233b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public final class LocalCodeRequestRateLimiter implements Parcelable { + + private final long timePeriod; + private final Map dataMap; + + public LocalCodeRequestRateLimiter(long timePeriod) { + this.timePeriod = timePeriod; + this.dataMap = new HashMap<>(); + } + + @MainThread + public boolean canRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) { + Data data = dataMap.get(mode); + + return data == null || !data.limited(e164Number, currentTime); + } + + /** + * Call this when the server has returned that it was successful in requesting a code via the specified mode. + */ + @MainThread + public void onSuccessfulRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) { + dataMap.put(mode, new Data(e164Number, currentTime + timePeriod)); + } + + /** + * Call this if a mode was unsuccessful in sending. + */ + @MainThread + public void onUnsuccessfulRequest() { + dataMap.clear(); + } + + static class Data { + + final String e164Number; + final long limitedUntil; + + Data(@NonNull String e164Number, long limitedUntil) { + this.e164Number = e164Number; + this.limitedUntil = limitedUntil; + } + + boolean limited(String e164Number, long currentTime) { + return this.e164Number.equals(e164Number) && currentTime < limitedUntil; + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public LocalCodeRequestRateLimiter createFromParcel(Parcel in) { + long timePeriod = in.readLong(); + int numberOfMapEntries = in.readInt(); + + LocalCodeRequestRateLimiter localCodeRequestRateLimiter = new LocalCodeRequestRateLimiter(timePeriod); + + for (int i = 0; i < numberOfMapEntries; i++) { + RegistrationCodeRequest.Mode mode = RegistrationCodeRequest.Mode.values()[in.readInt()]; + String e164Number = in.readString(); + long limitedUntil = in.readLong(); + + localCodeRequestRateLimiter.dataMap.put(mode, new Data(Objects.requireNonNull(e164Number), limitedUntil)); + } + return localCodeRequestRateLimiter; + } + + @Override + public LocalCodeRequestRateLimiter[] newArray(int size) { + return new LocalCodeRequestRateLimiter[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(timePeriod); + dest.writeInt(dataMap.size()); + + for (Map.Entry a : dataMap.entrySet()) { + dest.writeInt(a.getKey().ordinal()); + dest.writeString(a.getValue().e164Number); + dest.writeLong(a.getValue().limitedUntil); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/NumberViewState.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/NumberViewState.java new file mode 100644 index 00000000..2f59fbcb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/NumberViewState.java @@ -0,0 +1,186 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; + +import java.util.Objects; + +public final class NumberViewState implements Parcelable { + + public static final NumberViewState INITIAL = new Builder().build(); + + private final String selectedCountryName; + private final int countryCode; + private final String nationalNumber; + + private NumberViewState(Builder builder) { + this.selectedCountryName = builder.countryDisplayName; + this.countryCode = builder.countryCode; + this.nationalNumber = builder.nationalNumber; + } + + public Builder toBuilder() { + return new Builder().countryCode(countryCode) + .selectedCountryDisplayName(selectedCountryName) + .nationalNumber(nationalNumber); + } + + public int getCountryCode() { + return countryCode; + } + + public String getNationalNumber() { + return nationalNumber; + } + + public String getCountryDisplayName() { + if (selectedCountryName != null) { + return selectedCountryName; + } + + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + if (isValid()) { + String actualCountry = getActualCountry(util, getE164Number()); + + if (actualCountry != null) { + return actualCountry; + } + } + + String regionCode = util.getRegionCodeForCountryCode(countryCode); + return PhoneNumberFormatter.getRegionDisplayNameLegacy(regionCode); + } + + /** + * Finds actual name of region from a valid number. So for example +1 might map to US or Canada or other territories. + */ + private static @Nullable String getActualCountry(@NonNull PhoneNumberUtil util, @NonNull String e164Number) { + try { + Phonenumber.PhoneNumber phoneNumber = getPhoneNumber(util, e164Number); + String regionCode = util.getRegionCodeForNumber(phoneNumber); + + if (regionCode != null) { + return PhoneNumberFormatter.getRegionDisplayNameLegacy(regionCode); + } + + } catch (NumberParseException e) { + return null; + } + return null; + } + + public boolean isValid() { + return PhoneNumberFormatter.isValidNumber(getE164Number(), Integer.toString(getCountryCode())); + } + + @Override + public int hashCode() { + int hash = countryCode; + hash *= 31; + hash += nationalNumber != null ? nationalNumber.hashCode() : 0; + hash *= 31; + hash += selectedCountryName != null ? selectedCountryName.hashCode() : 0; + return hash; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == null) return false; + if (obj.getClass() != getClass()) return false; + + NumberViewState other = (NumberViewState) obj; + + return other.countryCode == countryCode && + Objects.equals(other.nationalNumber, nationalNumber) && + Objects.equals(other.selectedCountryName, selectedCountryName); + } + + public String getE164Number() { + return getConfiguredE164Number(countryCode, nationalNumber); + } + + public String getFullFormattedNumber() { + return formatNumber(PhoneNumberUtil.getInstance(), getE164Number()); + } + + private static String formatNumber(@NonNull PhoneNumberUtil util, @NonNull String e164Number) { + try { + Phonenumber.PhoneNumber number = getPhoneNumber(util, e164Number); + return util.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); + } catch (NumberParseException e) { + return e164Number; + } + } + + private static String getConfiguredE164Number(int countryCode, String number) { + return PhoneNumberFormatter.formatE164(String.valueOf(countryCode), number); + } + + private static Phonenumber.PhoneNumber getPhoneNumber(@NonNull PhoneNumberUtil util, @NonNull String e164Number) + throws NumberParseException + { + return util.parse(e164Number, null); + } + + public static class Builder { + private String countryDisplayName; + private int countryCode; + private String nationalNumber; + + public Builder countryCode(int countryCode) { + this.countryCode = countryCode; + return this; + } + + public Builder selectedCountryDisplayName(String countryDisplayName) { + this.countryDisplayName = countryDisplayName; + return this; + } + + public Builder nationalNumber(String nationalNumber) { + this.nationalNumber = nationalNumber; + return this; + } + + public NumberViewState build() { + return new NumberViewState(this); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeString(selectedCountryName); + parcel.writeInt(countryCode); + parcel.writeString(nationalNumber); + } + + public static final Creator CREATOR = new Creator() { + @Override + public NumberViewState createFromParcel(Parcel in) { + return new Builder().selectedCountryDisplayName(in.readString()) + .countryCode(in.readInt()) + .nationalNumber(in.readString()) + .build(); + } + + @Override + public NumberViewState[] newArray(int size) { + return new NumberViewState[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java new file mode 100644 index 00000000..ccaec3a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.ViewModel; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.TimeUnit; + +public final class RegistrationViewModel extends ViewModel { + + private static final String TAG = Log.tag(RegistrationViewModel.class); + + private static final long FIRST_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(64); + private static final long SUBSEQUENT_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(300); + + private final String secret; + private final MutableLiveData number; + private final MutableLiveData textCodeEntered; + private final MutableLiveData captchaToken; + private final MutableLiveData fcmToken; + private final MutableLiveData restoreFlowShown; + private final MutableLiveData successfulCodeRequestAttempts; + private final MutableLiveData requestLimiter; + private final MutableLiveData kbsTokenData; + private final MutableLiveData lockedTimeRemaining; + private final MutableLiveData canCallAtTime; + + public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle) { + secret = loadValue(savedStateHandle, "REGISTRATION_SECRET", Util.getSecret(18)); + + number = savedStateHandle.getLiveData("NUMBER", NumberViewState.INITIAL); + textCodeEntered = savedStateHandle.getLiveData("TEXT_CODE_ENTERED", ""); + captchaToken = savedStateHandle.getLiveData("CAPTCHA"); + fcmToken = savedStateHandle.getLiveData("FCM_TOKEN"); + restoreFlowShown = savedStateHandle.getLiveData("RESTORE_FLOW_SHOWN", false); + successfulCodeRequestAttempts = savedStateHandle.getLiveData("SUCCESSFUL_CODE_REQUEST_ATTEMPTS", 0); + requestLimiter = savedStateHandle.getLiveData("REQUEST_RATE_LIMITER", new LocalCodeRequestRateLimiter(60_000)); + kbsTokenData = savedStateHandle.getLiveData("KBS_TOKEN"); + lockedTimeRemaining = savedStateHandle.getLiveData("TIME_REMAINING", 0L); + canCallAtTime = savedStateHandle.getLiveData("CAN_CALL_AT_TIME", 0L); + } + + private static T loadValue(@NonNull SavedStateHandle savedStateHandle, @NonNull String key, @NonNull T initialValue) { + if (!savedStateHandle.contains(key)) { + savedStateHandle.set(key, initialValue); + } + return savedStateHandle.get(key); + } + + public @NonNull NumberViewState getNumber() { + //noinspection ConstantConditions Live data was given an initial value + return number.getValue(); + } + + public @NonNull LiveData getLiveNumber() { + return number; + } + + public @NonNull String getTextCodeEntered() { + //noinspection ConstantConditions Live data was given an initial value + return textCodeEntered.getValue(); + } + + public String getCaptchaToken() { + return captchaToken.getValue(); + } + + public boolean hasCaptchaToken() { + return getCaptchaToken() != null; + } + + public String getRegistrationSecret() { + return secret; + } + + public void onCaptchaResponse(String captchaToken) { + this.captchaToken.setValue(captchaToken); + } + + public void clearCaptchaResponse() { + captchaToken.setValue(null); + } + + public void onCountrySelected(@Nullable String selectedCountryName, int countryCode) { + setViewState(getNumber().toBuilder() + .selectedCountryDisplayName(selectedCountryName) + .countryCode(countryCode).build()); + } + + public void setNationalNumber(String number) { + NumberViewState numberViewState = getNumber().toBuilder().nationalNumber(number).build(); + setViewState(numberViewState); + } + + private void setViewState(NumberViewState numberViewState) { + if (!numberViewState.equals(getNumber())) { + number.setValue(numberViewState); + } + } + + @MainThread + public void onVerificationCodeEntered(String code) { + textCodeEntered.setValue(code); + } + + public void onNumberDetected(int countryCode, String nationalNumber) { + setViewState(getNumber().toBuilder() + .countryCode(countryCode) + .nationalNumber(nationalNumber) + .build()); + } + + public String getFcmToken() { + return fcmToken.getValue(); + } + + @MainThread + public void setFcmToken(@Nullable String fcmToken) { + this.fcmToken.setValue(fcmToken); + } + + public void setWelcomeSkippedOnRestore() { + restoreFlowShown.setValue(true); + } + + public boolean hasRestoreFlowBeenShown() { + //noinspection ConstantConditions Live data was given an initial value + return restoreFlowShown.getValue(); + } + + public void markASuccessfulAttempt() { + //noinspection ConstantConditions Live data was given an initial value + successfulCodeRequestAttempts.setValue(successfulCodeRequestAttempts.getValue() + 1); + } + + public LiveData getSuccessfulCodeRequestAttempts() { + return successfulCodeRequestAttempts; + } + + public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() { + //noinspection ConstantConditions Live data was given an initial value + return requestLimiter.getValue(); + } + + public void updateLimiter() { + requestLimiter.setValue(requestLimiter.getValue()); + } + + public @Nullable TokenData getKeyBackupCurrentToken() { + return kbsTokenData.getValue(); + } + + public void setKeyBackupTokenData(TokenData tokenData) { + kbsTokenData.setValue(tokenData); + } + + public LiveData getLockedTimeRemaining() { + return lockedTimeRemaining; + } + + public LiveData getCanCallAtTime() { + return canCallAtTime; + } + + public void setLockedTimeRemaining(long lockedTimeRemaining) { + this.lockedTimeRemaining.setValue(lockedTimeRemaining); + } + + public void onStartEnterCode() { + canCallAtTime.setValue(System.currentTimeMillis() + FIRST_CALL_AVAILABLE_AFTER_MS); + } + + public void onCallRequested() { + canCallAtTime.setValue(System.currentTimeMillis() + SUBSEQUENT_CALL_AVAILABLE_AFTER_MS); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceExpirationInfo.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceExpirationInfo.java new file mode 100644 index 00000000..26722b0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceExpirationInfo.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.revealable; + +public class ViewOnceExpirationInfo { + + private final long messageId; + private final long receiveTime; + + public ViewOnceExpirationInfo(long messageId, long receiveTime) { + this.messageId = messageId; + this.receiveTime = receiveTime; + } + + public long getMessageId() { + return messageId; + } + + public long getReceiveTime() { + return receiveTime; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java new file mode 100644 index 00000000..fbf4518e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageActivity.java @@ -0,0 +1,183 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.lifecycle.ViewModelProviders; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.video.VideoPlayer; + +import java.util.concurrent.TimeUnit; + +public class ViewOnceMessageActivity extends PassphraseRequiredActivity implements VideoPlayer.PlayerStateCallback { + + private static final String TAG = Log.tag(ViewOnceMessageActivity.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_URI = "uri"; + + private ImageView image; + private VideoPlayer video; + private View closeButton; + private TextView duration; + private ViewOnceMessageViewModel viewModel; + private Uri uri; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable durationUpdateRunnable = () -> { + long timeLeft = TimeUnit.MILLISECONDS.toSeconds(video.getDuration() - video.getPlaybackPosition()); + long minutes = timeLeft / 60; + long seconds = timeLeft % 60; + + duration.setText(getString(R.string.ViewOnceMessageActivity_video_duration, minutes, seconds)); + scheduleDurationUpdate(); + }; + + public static Intent getIntent(@NonNull Context context, long messageId, @NonNull Uri uri) { + Intent intent = new Intent(context, ViewOnceMessageActivity.class); + intent.putExtra(KEY_MESSAGE_ID, messageId); + intent.putExtra(KEY_URI, uri); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.view_once_message_activity); + + this.image = findViewById(R.id.view_once_image); + this.video = findViewById(R.id.view_once_video); + this.duration = findViewById(R.id.view_once_duration); + this.closeButton = findViewById(R.id.view_once_close_button); + this.uri = getIntent().getParcelableExtra(KEY_URI); + + closeButton.setOnClickListener(v -> finish()); + + initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1), uri); + } + + @Override + protected void onStop() { + super.onStop(); + cancelDurationUpdate(); + video.cleanup(); + BlobProvider.getInstance().delete(this, uri); + finish(); + } + + @Override + public void onPlayerReady() { + handler.post(durationUpdateRunnable); + } + + private void initViewModel(long messageId, @NonNull Uri uri) { + ViewOnceMessageRepository repository = new ViewOnceMessageRepository(this); + + viewModel = ViewModelProviders.of(this, new ViewOnceMessageViewModel.Factory(getApplication(), messageId, repository)) + .get(ViewOnceMessageViewModel.class); + + viewModel.getMessage().observe(this, (message) -> { + if (message == null) return; + + if (message.isPresent()) { + displayMedia(uri); + } else { + image.setImageDrawable(null); + finish(); + } + }); + } + + private void displayMedia(@NonNull Uri uri) { + if (MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(this, uri))) { + displayVideo(uri); + } else { + displayImage(uri); + } + } + + private void displayVideo(@NonNull Uri uri) { + video.setVisibility(View.VISIBLE); + image.setVisibility(View.GONE); + duration.setVisibility(View.VISIBLE); + + VideoSlide videoSlide = new VideoSlide(this, uri, 0); + + video.setWindow(getWindow()); + video.setPlayerStateCallbacks(this); + video.setVideoSource(videoSlide, true); + + video.hideControls(); + video.loopForever(); + } + + private void displayImage(@NonNull Uri uri) { + video.setVisibility(View.GONE); + image.setVisibility(View.VISIBLE); + duration.setVisibility(View.GONE); + + GlideApp.with(this) + .load(new DecryptableUri(uri)) + .into(image); + } + + private void scheduleDurationUpdate() { + handler.postDelayed(durationUpdateRunnable, 100); + } + + private void cancelDurationUpdate() { + handler.removeCallbacks(durationUpdateRunnable); + } + + private class ViewOnceGestureListener extends GestureDetector.SimpleOnGestureListener { + + private final View view; + + private ViewOnceGestureListener(View view) { + this.view = view; + } + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + view.performClick(); + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + finish(); + return true; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageManager.java new file mode 100644 index 00000000..861b1094 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageManager.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.revealable; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.service.TimedEventManager; + +/** + * Manages clearing removable message content after they're opened. + */ +public class ViewOnceMessageManager extends TimedEventManager { + + private static final String TAG = Log.tag(ViewOnceMessageManager.class); + + private final MessageDatabase mmsDatabase; + private final AttachmentDatabase attachmentDatabase; + + public ViewOnceMessageManager(@NonNull Application application) { + super(application, "RevealableMessageManager"); + + this.mmsDatabase = DatabaseFactory.getMmsDatabase(application); + this.attachmentDatabase = DatabaseFactory.getAttachmentDatabase(application); + + scheduleIfNecessary(); + } + + @WorkerThread + @Override + protected @Nullable ViewOnceExpirationInfo getNextClosestEvent() { + ViewOnceExpirationInfo expirationInfo = mmsDatabase.getNearestExpiringViewOnceMessage(); + + if (expirationInfo != null) { + Log.i(TAG, "Next closest expiration is in " + getDelayForEvent(expirationInfo) + " ms for messsage " + expirationInfo.getMessageId() + "."); + } else { + Log.i(TAG, "No messages to schedule."); + } + + return expirationInfo; + } + + @WorkerThread + @Override + protected void executeEvent(@NonNull ViewOnceExpirationInfo event) { + Log.i(TAG, "Deleting attachments for message " + event.getMessageId()); + attachmentDatabase.deleteAttachmentFilesForViewOnceMessage(event.getMessageId()); + } + + @WorkerThread + @Override + protected long getDelayForEvent(@NonNull ViewOnceExpirationInfo event) { + long expiresAt = event.getReceiveTime() + ViewOnceUtil.MAX_LIFESPAN; + long timeLeft = expiresAt - System.currentTimeMillis(); + + return Math.max(0, timeLeft); + } + + @AnyThread + @Override + protected void scheduleAlarm(@NonNull Application application, long delay) { + setAlarm(application, delay, ViewOnceAlarm.class); + } + + public static class ViewOnceAlarm extends BroadcastReceiver { + + private static final String TAG = Log.tag(ViewOnceAlarm.class); + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "onReceive()"); + ApplicationContext.getInstance(context).getViewOnceMessageManager().scheduleIfNecessary(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java new file mode 100644 index 00000000..acce4b69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.whispersystems.libsignal.util.guava.Optional; + +class ViewOnceMessageRepository { + + private static final String TAG = Log.tag(ViewOnceMessageRepository.class); + + private final MessageDatabase mmsDatabase; + + ViewOnceMessageRepository(@NonNull Context context) { + this.mmsDatabase = DatabaseFactory.getMmsDatabase(context); + } + + void getMessage(long messageId, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try (MmsDatabase.Reader reader = MmsDatabase.readerFor(mmsDatabase.getMessageCursor(messageId))) { + MmsMessageRecord record = (MmsMessageRecord) reader.getNext(); + MessageDatabase.MarkedMessageInfo info = mmsDatabase.setIncomingMessageViewed(record.getId()); + if (info != null) { + ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(record.getThreadId(), + info.getSyncMessageId().getRecipientId(), + info.getSyncMessageId().getTimetamp())); + } + callback.onComplete(Optional.fromNullable(record)); + } + }); + } + + interface Callback { + void onComplete(T result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java new file mode 100644 index 00000000..3875ceae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageView.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.revealable; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; + +public class ViewOnceMessageView extends LinearLayout { + + private static final String TAG = Log.tag(ViewOnceMessageView.class); + + private ImageView icon; + private ProgressWheel progress; + private TextView text; + private Attachment attachment; + private int unopenedForegroundColor; + private int openedForegroundColor; + private int foregroundColor; + + public ViewOnceMessageView(Context context) { + super(context); + init(null); + } + + public ViewOnceMessageView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.revealable_message_view, this); + setOrientation(LinearLayout.HORIZONTAL); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ViewOnceMessageView, 0, 0); + + unopenedForegroundColor = typedArray.getColor(R.styleable.ViewOnceMessageView_revealable_unopenedForegroundColor, Color.BLACK); + openedForegroundColor = typedArray.getColor(R.styleable.ViewOnceMessageView_revealable_openedForegroundColor, Color.BLACK); + + typedArray.recycle(); + } + + this.icon = findViewById(R.id.revealable_icon); + this.progress = findViewById(R.id.revealable_progress); + this.text = findViewById(R.id.revealable_text); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + EventBus.getDefault().unregister(this); + } + + public boolean requiresTapToDownload(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.isOutgoing() || messageRecord.getSlideDeck().getThumbnailSlide() == null) { + return false; + } + + Attachment attachment = messageRecord.getSlideDeck().getThumbnailSlide().asAttachment(); + return attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_FAILED || + attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING; + } + + public void setMessage(@NonNull MmsMessageRecord message) { + this.attachment = message.getSlideDeck().getThumbnailSlide() != null ? message.getSlideDeck().getThumbnailSlide().asAttachment() : null; + + presentMessage(message); + } + + public void presentMessage(@NonNull MmsMessageRecord message) { + presentText(message); + } + + private void presentText(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.isOutgoing() && networkInProgress(messageRecord)) { + foregroundColor = openedForegroundColor; + text.setText(R.string.RevealableMessageView_media); + icon.setImageResource(0); + progress.setVisibility(VISIBLE); + } else if (messageRecord.isOutgoing()) { + foregroundColor = openedForegroundColor; + text.setText(R.string.RevealableMessageView_media); + icon.setImageResource(R.drawable.ic_viewed_once_24); + progress.setVisibility(GONE); + } else if (ViewOnceUtil.isViewable(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(getDescriptionId(messageRecord)); + icon.setImageResource(R.drawable.ic_view_once_24); + progress.setVisibility(GONE); + } else if (networkInProgress(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(""); + icon.setImageResource(0); + progress.setVisibility(VISIBLE); + } else if (requiresTapToDownload(messageRecord)) { + foregroundColor = unopenedForegroundColor; + text.setText(formatFileSize(messageRecord)); + icon.setImageResource(R.drawable.ic_arrow_down_circle_outline_24); + progress.setVisibility(GONE); + } else { + foregroundColor = openedForegroundColor; + text.setText(R.string.RevealableMessageView_viewed); + icon.setImageResource(R.drawable.ic_viewed_once_24); + progress.setVisibility(GONE); + } + + text.setTextColor(foregroundColor); + icon.setColorFilter(foregroundColor); + progress.setBarColor(foregroundColor); + progress.setRimColor(Color.TRANSPARENT); + } + + private boolean networkInProgress(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.getSlideDeck().getThumbnailSlide() == null) return false; + + Attachment attachment = messageRecord.getSlideDeck().getThumbnailSlide().asAttachment(); + return attachment.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED; + } + + private @NonNull String formatFileSize(@NonNull MmsMessageRecord messageRecord) { + if (messageRecord.getSlideDeck().getThumbnailSlide() == null) return ""; + + long size = messageRecord.getSlideDeck().getThumbnailSlide().getFileSize(); + return Util.getPrettyFileSize(size); + } + + private static @StringRes int getDescriptionId(@NonNull MmsMessageRecord messageRecord) { + Slide thumbnailSlide = messageRecord.getSlideDeck().getThumbnailSlide(); + + if (thumbnailSlide != null && MediaUtil.isVideoType(thumbnailSlide.getContentType())) { + return R.string.RevealableMessageView_view_video; + } + + return R.string.RevealableMessageView_view_photo; + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventAsync(final PartProgressEvent event) { + if (event.attachment.equals(attachment)) { + progress.setInstantProgress((float) event.progress / (float) event.total); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageViewModel.java new file mode 100644 index 00000000..4c1c93cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageViewModel.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.revealable; + +import android.app.Application; +import android.database.ContentObserver; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +class ViewOnceMessageViewModel extends ViewModel { + + private static final String TAG = Log.tag(ViewOnceMessageViewModel.class); + + private final Application application; + private final ViewOnceMessageRepository repository; + private final MutableLiveData> message; + private final ContentObserver observer; + + private ViewOnceMessageViewModel(@NonNull Application application, + long messageId, + @NonNull ViewOnceMessageRepository repository) + { + this.application = application; + this.repository = repository; + this.message = new MutableLiveData<>(); + this.observer = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + repository.getMessage(messageId, optionalMessage -> onMessageRetrieved(optionalMessage)); + } + }; + + repository.getMessage(messageId, message -> { + if (message.isPresent()) { + Uri uri = DatabaseContentProviders.Conversation.getUriForThread(message.get().getThreadId()); + application.getContentResolver().registerContentObserver(uri, true, observer); + } + + onMessageRetrieved(message); + }); + } + + @NonNull LiveData> getMessage() { + return message; + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(observer); + } + + private void onMessageRetrieved(@NonNull Optional optionalMessage) { + Util.runOnMain(() -> { + MmsMessageRecord current = message.getValue() != null ? message.getValue().orNull() : null; + MmsMessageRecord proposed = optionalMessage.orNull(); + + if (current != null && proposed != null && current.getId() == proposed.getId()) { + Log.d(TAG, "Same ID -- skipping update"); + } else { + message.setValue(optionalMessage); + } + }); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final Application application; + private final long messageId; + private final ViewOnceMessageRepository repository; + + Factory(@NonNull Application application, + long messageId, + @NonNull ViewOnceMessageRepository repository) + { + this.application = application; + this.messageId = messageId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ViewOnceMessageViewModel(application, messageId, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceUtil.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceUtil.java new file mode 100644 index 00000000..6db29078 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceUtil.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.revealable; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; + +import java.util.concurrent.TimeUnit; + +public class ViewOnceUtil { + + public static final long MAX_LIFESPAN = TimeUnit.DAYS.toMillis(30); + + public static boolean isViewable(@NonNull MmsMessageRecord message) { + if (!message.isViewOnce()) { + return true; + } + + if (message.isOutgoing()) { + return false; + } + + if (message.getSlideDeck().getThumbnailSlide() == null) { + return false; + } + + if (message.getSlideDeck().getThumbnailSlide().getUri() == null) { + return false; + } + + if (message.getSlideDeck().getThumbnailSlide().getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + return false; + } + + if (isViewed(message)) { + return false; + } + + return true; + } + + public static boolean isViewed(@NonNull MmsMessageRecord message) { + if (!message.isViewOnce()) { + return false; + } + + if (message.getDateReceived() + MAX_LIFESPAN <= System.currentTimeMillis()) { + return true; + } + + if (message.getSlideDeck().getThumbnailSlide() != null && message.getSlideDeck().getThumbnailSlide().getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + return false; + } + + if (message.getSlideDeck().getThumbnailSlide() == null) { + return true; + } + + if (message.getSlideDeck().getThumbnailSlide().getUri() == null) { + return true; + } + + if (message.isOutgoing()) { + return true; + } + + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CallState.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CallState.java new file mode 100644 index 00000000..1f1037e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CallState.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.ringrtc; + +/** + * + * Enumeration of call state + * + */ +public enum CallState { + + /** Idle, setting up objects */ + IDLE, + + /** Dialing. Outgoing call is signaling the remote peer */ + DIALING, + + /** Answering. Incoming call is responding to remote peer */ + ANSWERING, + + /** Remote ringing. Outgoing call, ICE negotiation is complete */ + REMOTE_RINGING, + + /** Local ringing. Incoming call, ICE negotiation is complete */ + LOCAL_RINGING, + + /** Connected. Incoming/Outgoing call, the call is connected */ + CONNECTED, + + /** Terminated. Incoming/Outgoing call, the call is terminated */ + TERMINATED, + + /** Busy. Outgoing call received a busy notification */ + RECEIVED_BUSY; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java new file mode 100644 index 00000000..da077e6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java @@ -0,0 +1,300 @@ +package org.thoughtcrime.securesms.ringrtc; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CameraControl; +import org.webrtc.Camera1Enumerator; +import org.webrtc.Camera2Capturer; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +import org.webrtc.CameraVideoCapturer; +import org.webrtc.CapturerObserver; +import org.webrtc.EglBase; +import org.webrtc.SurfaceTextureHelper; + +import java.util.LinkedList; +import java.util.List; + +import static org.thoughtcrime.securesms.ringrtc.CameraState.Direction.BACK; +import static org.thoughtcrime.securesms.ringrtc.CameraState.Direction.FRONT; +import static org.thoughtcrime.securesms.ringrtc.CameraState.Direction.NONE; +import static org.thoughtcrime.securesms.ringrtc.CameraState.Direction.PENDING; + +/** + * Encapsulate the camera functionality needed for video calling. + */ +public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHandler { + + private static final String TAG = Log.tag(Camera.class); + + @NonNull private final Context context; + @Nullable private final CameraVideoCapturer capturer; + @NonNull private final CameraEventListener cameraEventListener; + @NonNull private final EglBase eglBase; + private final int cameraCount; + @NonNull private CameraState.Direction activeDirection; + private boolean enabled; + private boolean isInitialized; + private int orientation; + + public Camera(@NonNull Context context, + @NonNull CameraEventListener cameraEventListener, + @NonNull EglBase eglBase, + @NonNull CameraState.Direction desiredCameraDirection) + { + this.context = context; + this.cameraEventListener = cameraEventListener; + this.eglBase = eglBase; + CameraEnumerator enumerator = getCameraEnumerator(context); + cameraCount = enumerator.getDeviceNames().length; + + CameraState.Direction firstChoice = desiredCameraDirection.isUsable() ? desiredCameraDirection : FRONT; + + CameraVideoCapturer capturerCandidate = createVideoCapturer(enumerator, firstChoice); + if (capturerCandidate != null) { + activeDirection = firstChoice; + } else { + CameraState.Direction secondChoice = firstChoice.switchDirection(); + capturerCandidate = createVideoCapturer(enumerator, secondChoice); + if (capturerCandidate != null) { + activeDirection = secondChoice; + } else { + activeDirection = NONE; + } + } + capturer = capturerCandidate; + } + + @Override + public void initCapturer(@NonNull CapturerObserver observer) { + if (capturer != null) { + capturer.initialize(SurfaceTextureHelper.create("WebRTC-SurfaceTextureHelper", eglBase.getEglBaseContext()), + context, + observer); + capturer.setOrientation(orientation); + isInitialized = true; + } + } + + @Override + public boolean hasCapturer() { + return capturer != null; + } + + @Override + public void flip() { + if (capturer == null || cameraCount < 2) { + throw new AssertionError("Tried to flip the camera, but we only have " + cameraCount + " of them."); + } + activeDirection = PENDING; + capturer.switchCamera(this); + } + + @Override + public void setOrientation(@Nullable Integer orientation) { + this.orientation = orientation; + + if (isInitialized && capturer != null) { + capturer.setOrientation(orientation); + } + } + + @Override + public void setEnabled(boolean enabled) { + Log.i(TAG, "setEnabled(): " + enabled); + + this.enabled = enabled; + + if (capturer == null) { + return; + } + + try { + if (enabled) { + Log.i(TAG, "setEnabled(): starting capture"); + capturer.startCapture(1280, 720, 30); + } else { + Log.i(TAG, "setEnabled(): stopping capture"); + capturer.stopCapture(); + } + } catch (InterruptedException e) { + Log.w(TAG, "Got interrupted while trying to stop video capture", e); + } + } + + public void dispose() { + if (capturer != null) { + capturer.dispose(); + isInitialized = false; + } + } + + int getCount() { + return cameraCount; + } + + @NonNull CameraState.Direction getActiveDirection() { + return enabled ? activeDirection : NONE; + } + + @NonNull public CameraState getCameraState() { + return new CameraState(getActiveDirection(), getCount()); + } + + @Nullable CameraVideoCapturer getCapturer() { + return capturer; + } + + public boolean isInitialized() { + return isInitialized; + } + + private @Nullable CameraVideoCapturer createVideoCapturer(@NonNull CameraEnumerator enumerator, + @NonNull CameraState.Direction direction) + { + String[] deviceNames = enumerator.getDeviceNames(); + for (String deviceName : deviceNames) { + if ((direction == FRONT && enumerator.isFrontFacing(deviceName)) || + (direction == BACK && enumerator.isBackFacing(deviceName))) + { + return enumerator.createCapturer(deviceName, null); + } + } + + return null; + } + + private @NonNull CameraEnumerator getCameraEnumerator(@NonNull Context context) { + boolean camera2EnumeratorIsSupported = false; + try { + camera2EnumeratorIsSupported = Camera2Enumerator.isSupported(context); + } catch (final Throwable throwable) { + Log.w(TAG, "Camera2Enumator.isSupport() threw.", throwable); + } + + Log.i(TAG, "Camera2 enumerator supported: " + camera2EnumeratorIsSupported); + + return camera2EnumeratorIsSupported ? new FilteredCamera2Enumerator(context) + : new Camera1Enumerator(true); + } + + @Override + public void onCameraSwitchDone(boolean isFrontFacing) { + activeDirection = isFrontFacing ? FRONT : BACK; + cameraEventListener.onCameraSwitchCompleted(new CameraState(getActiveDirection(), getCount())); + } + + @Override + public void onCameraSwitchError(String errorMessage) { + Log.e(TAG, "onCameraSwitchError: " + errorMessage); + cameraEventListener.onCameraSwitchCompleted(new CameraState(getActiveDirection(), getCount())); + } + + @TargetApi(21) + private static class FilteredCamera2Enumerator extends Camera2Enumerator { + + private static final String TAG = Log.tag(Camera2Enumerator.class); + + @NonNull private final Context context; + @Nullable private final CameraManager cameraManager; + @Nullable private String[] deviceNames; + + FilteredCamera2Enumerator(@NonNull Context context) { + super(context); + + this.context = context; + this.cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + this.deviceNames = null; + } + + private static boolean isMonochrome(@NonNull String deviceName, @NonNull CameraManager cameraManager) { + try { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(deviceName); + int[] capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES); + + if (capabilities != null) { + for (int capability : capabilities) { + if (capability == CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_MONOCHROME) { + return true; + } + } + } + } catch (CameraAccessException e) { + return false; + } + + return false; + } + + private static boolean isLensFacing(@NonNull String deviceName, @NonNull CameraManager cameraManager, @NonNull Integer facing) { + try { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(deviceName); + Integer lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); + + return facing.equals(lensFacing); + } catch (CameraAccessException e) { + return false; + } + } + + @Override + public @NonNull String[] getDeviceNames() { + if (deviceNames != null) { + return deviceNames; + } + + try { + List cameraList = new LinkedList<>(); + + if (cameraManager != null) { + List devices = Stream.of(cameraManager.getCameraIdList()) + .filterNot(id -> isMonochrome(id, cameraManager)) + .toList(); + + String frontCamera = Stream.of(devices) + .filter(id -> isLensFacing(id, cameraManager, CameraMetadata.LENS_FACING_FRONT)) + .findFirst() + .orElse(null); + + if (frontCamera != null) { + cameraList.add(frontCamera); + } + + String backCamera = Stream.of(devices) + .filter(id -> isLensFacing(id, cameraManager, CameraMetadata.LENS_FACING_BACK)) + .findFirst() + .orElse(null); + + if (backCamera != null) { + cameraList.add(backCamera); + } + } + + this.deviceNames = cameraList.toArray(new String[0]); + } catch (CameraAccessException e) { + Log.e(TAG, "Camera access exception: " + e); + this.deviceNames = new String[] {}; + } + + return deviceNames; + } + + @Override + public @NonNull CameraVideoCapturer createCapturer(@Nullable String deviceName, + @Nullable CameraVideoCapturer.CameraEventsHandler eventsHandler) + { + return new Camera2Capturer(context, deviceName, eventsHandler, new FilteredCamera2Enumerator(context)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraEventListener.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraEventListener.java new file mode 100644 index 00000000..03d4d583 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraEventListener.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.ringrtc; + +import androidx.annotation.NonNull; + +public interface CameraEventListener { + void onCameraSwitchCompleted(@NonNull CameraState newCameraState); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraState.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraState.java new file mode 100644 index 00000000..1b6c554f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraState.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.ringrtc; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +public class CameraState implements Parcelable { + + public static final CameraState UNKNOWN = new CameraState(Direction.NONE, 0); + + private final Direction activeDirection; + private final int cameraCount; + + public CameraState(@NonNull Direction activeDirection, int cameraCount) { + this.activeDirection = activeDirection; + this.cameraCount = cameraCount; + } + + private CameraState(Parcel in) { + this(Direction.valueOf(in.readString()), in.readInt()); + } + + public int getCameraCount() { + return cameraCount; + } + + public Direction getActiveDirection() { + return activeDirection; + } + + public boolean isEnabled() { + return this.activeDirection != Direction.NONE; + } + + @Override + public String toString() { + return "count: " + cameraCount + ", activeDirection: " + activeDirection; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(activeDirection.name()); + dest.writeInt(cameraCount); + } + + @Override + public int describeContents() { + return 0; + } + + public enum Direction { + FRONT, BACK, NONE, PENDING; + + public boolean isUsable() { + return this == FRONT || this == BACK; + } + + public Direction switchDirection() { + switch (this) { + case FRONT: + return BACK; + case BACK: + return FRONT; + default: + return this; + } + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public CameraState createFromParcel(Parcel in) { + return new CameraState(in); + } + + @Override + public CameraState[] newArray(int size) { + return new CameraState[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/IceCandidateParcel.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/IceCandidateParcel.java new file mode 100644 index 00000000..ef7e93c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/IceCandidateParcel.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.ringrtc; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.signal.ringrtc.CallId; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; + +/** + * Utility class for passing ICE candidate objects via Intents. + * + * Also provides utility methods for converting to/from Signal ICE + * candidate messages. + */ +public class IceCandidateParcel implements Parcelable { + + @NonNull private final byte[] iceCandidate; + + public IceCandidateParcel(@NonNull byte[] iceCandidate) { + this.iceCandidate = iceCandidate; + } + + public IceCandidateParcel(@NonNull IceUpdateMessage iceUpdateMessage) { + this.iceCandidate = iceUpdateMessage.getOpaque(); + } + + private IceCandidateParcel(@NonNull Parcel in) { + this.iceCandidate = in.createByteArray(); + } + + public @NonNull byte[] getIceCandidate() { + return iceCandidate; + } + + public @NonNull IceUpdateMessage getIceUpdateMessage(@NonNull CallId callId) { + return new IceUpdateMessage(callId.longValue(), + iceCandidate, + null); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeByteArray(iceCandidate); + } + + public static final Creator CREATOR = new Creator() { + @Override + public IceCandidateParcel createFromParcel(@NonNull Parcel in) { + return new IceCandidateParcel(in); + } + + @Override + public IceCandidateParcel[] newArray(int size) { + return new IceCandidateParcel[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RemotePeer.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RemotePeer.java new file mode 100644 index 00000000..50458587 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RemotePeer.java @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.ringrtc; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallId; +import org.signal.ringrtc.Remote; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +/** + * Container class that represents the remote peer and current state + * of a video/voice call. + * + * The class is also Parcelable for passing around via an Intent. + */ +public final class RemotePeer implements Remote, Parcelable +{ + private static final String TAG = Log.tag(RemotePeer.class); + + @NonNull private final RecipientId recipientId; + @NonNull private CallState callState; + @NonNull private CallId callId; + private long callStartTimestamp; + + public RemotePeer(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + this.callState = CallState.IDLE; + this.callId = new CallId(-1L); + this.callStartTimestamp = 0; + } + + private RemotePeer(@NonNull Parcel in) { + this.recipientId = RecipientId.CREATOR.createFromParcel(in); + this.callState = CallState.values()[in.readInt()]; + this.callId = new CallId(in.readLong()); + this.callStartTimestamp = in.readLong(); + } + + public @NonNull CallId getCallId() { + return callId; + } + + public void setCallId(@NonNull CallId callId) { + this.callId = callId; + } + + public void setCallStartTimestamp(long callStartTimestamp) { + this.callStartTimestamp = callStartTimestamp; + } + + public long getCallStartTimestamp() { + return callStartTimestamp; + } + + public @NonNull CallState getState() { + return callState; + } + + public @NonNull RecipientId getId() { + return recipientId; + } + + public @NonNull Recipient getRecipient() { + return Recipient.resolved(recipientId); + } + + @Override + public String toString() { + return "recipientId: " + this.recipientId + + ", callId: " + this.callId + + ", state: " + this.callState; + } + + @Override + public boolean recipientEquals(Remote obj) { + if (obj != null && this.getClass() == obj.getClass()) { + RemotePeer that = (RemotePeer)obj; + return this.recipientId.equals(that.recipientId); + } + + return false; + } + + public boolean callIdEquals(@Nullable RemotePeer remotePeer) { + return remotePeer != null && this.callId.equals(remotePeer.callId); + } + + public void dialing() { + if (callState != CallState.IDLE) { + throw new IllegalStateException("Cannot transition to DIALING from state: " + callState); + } + + this.callState = CallState.DIALING; + } + + public void answering() { + if (callState != CallState.IDLE) { + throw new IllegalStateException("Cannot transition to ANSWERING from state: " + callState); + } + + this.callState = CallState.ANSWERING; + } + + public void remoteRinging() { + if (callState != CallState.DIALING) { + throw new IllegalStateException("Cannot transition to REMOTE_RINGING from state: " + callState); + } + + this.callState = CallState.REMOTE_RINGING; + } + + public void localRinging() { + if (callState != CallState.ANSWERING) { + throw new IllegalStateException("Cannot transition to LOCAL_RINGING from state: " + callState); + } + + this.callState = CallState.LOCAL_RINGING; + } + + public void connected() { + if (callState != CallState.REMOTE_RINGING && callState != CallState.LOCAL_RINGING) { + throw new IllegalStateException("Cannot transition outgoing call to CONNECTED from state: " + callState); + } + + this.callState = CallState.CONNECTED; + } + + public void receivedBusy() { + if (callState != CallState.DIALING) { + Log.w(TAG, "RECEIVED_BUSY from unexpected state: " + callState); + } + + this.callState = CallState.RECEIVED_BUSY; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + recipientId.writeToParcel(dest, flags); + dest.writeInt(callState.ordinal()); + dest.writeLong(callId.longValue()); + dest.writeLong(callStartTimestamp); + } + + public static final Creator CREATOR = new Creator() { + @Override + public RemotePeer createFromParcel(@NonNull Parcel in) { + return new RemotePeer(in); + } + + @Override + public RemotePeer[] newArray(int size) { + return new RemotePeer[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RingRtcLogger.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RingRtcLogger.java new file mode 100644 index 00000000..ddabda19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/RingRtcLogger.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.ringrtc; + +import org.signal.core.util.logging.Log; + +public class RingRtcLogger implements org.signal.ringrtc.Log.Logger { + @Override + public void v(String tag, String message, Throwable t) { + Log.v(tag, message, t); + } + + @Override + public void d(String tag, String message, Throwable t) { + Log.d(tag, message, t); + } + + @Override + public void i(String tag, String message, Throwable t) { + Log.i(tag, message, t); + } + + @Override + public void w(String tag, String message, Throwable t) { + Log.w(tag, message, t); + } + + @Override + public void e(String tag, String message, Throwable t) { + Log.e(tag, message, t); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/TurnServerInfoParcel.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/TurnServerInfoParcel.java new file mode 100644 index 00000000..472a4206 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/TurnServerInfoParcel.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.ringrtc; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; + +import java.util.ArrayList; +import java.util.List; + +/** + * Wrap turn server info so it can be sent via an intent. + */ +public class TurnServerInfoParcel implements Parcelable { + + private final String username; + private final String password; + private final List urls; + + public TurnServerInfoParcel(@NonNull TurnServerInfo turnServerInfo) { + urls = new ArrayList<>(turnServerInfo.getUrls()); + username = turnServerInfo.getUsername(); + password = turnServerInfo.getPassword(); + } + + private TurnServerInfoParcel(@NonNull Parcel in) { + username = in.readString(); + password = in.readString(); + urls = in.createStringArrayList(); + } + + public @Nullable String getUsername() { + return username; + } + + public @Nullable String getPassword() { + return password; + } + + public @NonNull List getUrls() { + return urls; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(username); + dest.writeString(password); + dest.writeStringList(urls); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public TurnServerInfoParcel createFromParcel(Parcel in) { + return new TurnServerInfoParcel(in); + } + + @Override + public TurnServerInfoParcel[] newArray(int size) { + return new TurnServerInfoParcel[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java new file mode 100644 index 00000000..1e087607 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.PointF; +import android.graphics.RectF; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; + +import java.util.List; +import java.util.Locale; + +/** + * Detects faces with the built in Android face detection. + */ +final class AndroidFaceDetector implements FaceDetector { + + private static final String TAG = Log.tag(AndroidFaceDetector.class); + + private static final int MAX_FACES = 20; + + @Override + public List detect(@NonNull Bitmap source) { + long startTime = System.currentTimeMillis(); + + Log.d(TAG, String.format(Locale.US, "Bitmap format is %dx%d %s", source.getWidth(), source.getHeight(), source.getConfig())); + + boolean createBitmap = source.getConfig() != Bitmap.Config.RGB_565 || source.getWidth() % 2 != 0; + Bitmap bitmap; + + if (createBitmap) { + Log.d(TAG, "Changing colour format to 565, with even width"); + bitmap = Bitmap.createBitmap(source.getWidth() & ~0x1, source.getHeight(), Bitmap.Config.RGB_565); + new Canvas(bitmap).drawBitmap(source, 0, 0, null); + } else { + bitmap = source; + } + + try { + android.media.FaceDetector faceDetector = new android.media.FaceDetector(bitmap.getWidth(), bitmap.getHeight(), MAX_FACES); + android.media.FaceDetector.Face[] faces = new android.media.FaceDetector.Face[MAX_FACES]; + int foundFaces = faceDetector.findFaces(bitmap, faces); + + Log.d(TAG, String.format(Locale.US, "Found %d faces", foundFaces)); + + return Stream.of(faces) + .limit(foundFaces) + .map(AndroidFaceDetector::faceToFace) + .toList(); + } finally { + if (createBitmap) { + bitmap.recycle(); + } + + Log.d(TAG, "Finished in " + (System.currentTimeMillis() - startTime) + " ms"); + } + } + + private static Face faceToFace(@NonNull android.media.FaceDetector.Face face) { + PointF point = new PointF(); + face.getMidPoint(point); + + float halfWidth = face.eyesDistance() * 1.4f; + float yOffset = face.eyesDistance() * 0.4f; + RectF bounds = new RectF(point.x - halfWidth, point.y - halfWidth + yOffset, point.x + halfWidth, point.y + halfWidth + yOffset); + + return new DefaultFace(bounds, face.confidence()); + } + + private static class DefaultFace implements Face { + private final RectF bounds; + private final float certainty; + + public DefaultFace(@NonNull RectF bounds, float confidence) { + this.bounds = bounds; + this.certainty = confidence; + } + + @Override + public RectF getBounds() { + return bounds; + } + + @Override + public Class getDetectorClass() { + return AndroidFaceDetector.class; + } + + @Override + public float getConfidence() { + return certainty; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java new file mode 100644 index 00000000..4365dbd3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.graphics.Bitmap; +import android.graphics.RectF; + +import androidx.annotation.NonNull; + +import java.util.List; + +interface FaceDetector { + List detect(@NonNull Bitmap bitmap); + + interface Face { + RectF getBounds(); + + Class getDetectorClass(); + + float getConfidence(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java new file mode 100644 index 00000000..27355c3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -0,0 +1,589 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.Manifest; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.ImageEditorView; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.renderers.FaceBlurRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PushMediaConstraints; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.Pair; + +import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.List; + +import static android.app.Activity.RESULT_OK; + +public final class ImageEditorFragment extends Fragment implements ImageEditorHud.EventListener, + VerticalSlideColorPicker.OnColorChangeListener, + MediaSendPageFragment { + + private static final String TAG = Log.tag(ImageEditorFragment.class); + + private static final String KEY_IMAGE_URI = "image_uri"; + private static final String KEY_IS_AVATAR_MODE = "avatar_mode"; + + private static final int SELECT_STICKER_REQUEST_CODE = 124; + + private EditorModel restoredModel; + + private Pair cachedFaceDetection; + + @Nullable private EditorElement currentSelection; + private int imageMaxHeight; + private int imageMaxWidth; + + public static class Data { + private final Bundle bundle; + + Data(Bundle bundle) { + this.bundle = bundle; + } + + public Data() { + this(new Bundle()); + } + + void writeModel(@NonNull EditorModel model) { + byte[] bytes = ParcelUtil.serialize(model); + bundle.putByteArray("MODEL", bytes); + } + + @Nullable + public EditorModel readModel() { + byte[] bytes = bundle.getByteArray("MODEL"); + if (bytes == null) { + return null; + } + return ParcelUtil.deserialize(bytes, EditorModel.CREATOR); + } + } + + private Uri imageUri; + private Controller controller; + private ImageEditorHud imageEditorHud; + private ImageEditorView imageEditorView; + + public static ImageEditorFragment newInstanceForAvatar(@NonNull Uri imageUri) { + ImageEditorFragment fragment = newInstance(imageUri); + fragment.requireArguments().putBoolean(KEY_IS_AVATAR_MODE, true); + return fragment; + } + + public static ImageEditorFragment newInstance(@NonNull Uri imageUri) { + Bundle args = new Bundle(); + args.putParcelable(KEY_IMAGE_URI, imageUri); + + ImageEditorFragment fragment = new ImageEditorFragment(); + fragment.setArguments(args); + fragment.setUri(imageUri); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement Controller interface."); + } + controller = (Controller) getActivity(); + Bundle arguments = getArguments(); + if (arguments != null) { + imageUri = arguments.getParcelable(KEY_IMAGE_URI); + } + + if (imageUri == null) { + throw new AssertionError("No KEY_IMAGE_URI supplied"); + } + + MediaConstraints mediaConstraints = new PushMediaConstraints(); + + imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext()); + imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.image_editor_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + boolean isAvatarMode = requireArguments().getBoolean(KEY_IS_AVATAR_MODE, false); + + imageEditorHud = view.findViewById(R.id.scribble_hud); + imageEditorView = view.findViewById(R.id.image_editor_view); + + imageEditorHud.setEventListener(this); + + imageEditorView.setTapListener(selectionListener); + imageEditorView.setDrawingChangedListener(this::refreshUniqueColors); + imageEditorView.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); + + EditorModel editorModel = null; + + if (restoredModel != null) { + editorModel = restoredModel; + restoredModel = null; + } + + if (editorModel == null) { + editorModel = isAvatarMode ? EditorModel.createForCircleEditing() : EditorModel.create(); + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight)); + image.getFlags().setSelectable(false).persist(); + editorModel.addElement(image); + } + + if (isAvatarMode) { + imageEditorHud.setUpForAvatarEditing(); + imageEditorHud.enterMode(ImageEditorHud.Mode.CROP); + } + + imageEditorView.setModel(editorModel); + + if (!SignalStore.tooltips().hasSeenBlurHudIconTooltip()) { + imageEditorHud.showBlurHudTooltip(); + SignalStore.tooltips().markBlurHudIconTooltipSeen(); + } + + refreshUniqueColors(); + } + + @Override + public void setUri(@NonNull Uri uri) { + this.imageUri = uri; + } + + @NonNull + @Override + public Uri getUri() { + return imageUri; + } + + @Nullable + @Override + public View getPlaybackControls() { + return null; + } + + @Override + public Object saveState() { + Data data = new Data(); + data.writeModel(imageEditorView.getModel()); + return data; + } + + @Override + public void restoreState(@NonNull Object state) { + if (state instanceof Data) { + + Data data = (Data) state; + EditorModel model = data.readModel(); + + if (model != null) { + if (imageEditorView != null) { + imageEditorView.setModel(model); + refreshUniqueColors(); + } else { + this.restoredModel = model; + } + } + } else { + Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); + } + } + + @Override + public void notifyHidden() { + } + + private void changeEntityColor(int selectedColor) { + if (currentSelection != null) { + Renderer renderer = currentSelection.getRenderer(); + if (renderer instanceof ColorableRenderer) { + ((ColorableRenderer) renderer).setColor(selectedColor); + refreshUniqueColors(); + } + } + } + + private void startTextEntityEditing(@NonNull EditorElement textElement, boolean selectAll) { + imageEditorView.startTextEditing(textElement, TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()), selectAll); + } + + protected void addText() { + String initialText = ""; + int color = imageEditorHud.getActiveColor(); + MultiLineTextRenderer renderer = new MultiLineTextRenderer(initialText, color); + EditorElement element = new EditorElement(renderer, EditorModel.Z_TEXT); + + imageEditorView.getModel().addElementCentered(element, 1); + imageEditorView.invalidate(); + + currentSelection = element; + + startTextEntityEditing(element, true); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) { + final Uri uri = data.getData(); + if (uri != null) { + UriGlideRenderer renderer = new UriGlideRenderer(uri, true, imageMaxWidth, imageMaxHeight); + EditorElement element = new EditorElement(renderer, EditorModel.Z_STICKERS); + imageEditorView.getModel().addElementCentered(element, 0.2f); + currentSelection = element; + imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE); + } + } else { + imageEditorHud.setMode(ImageEditorHud.Mode.NONE); + } + } + + @Override + public void onModeStarted(@NonNull ImageEditorHud.Mode mode) { + imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); + imageEditorView.doneTextEditing(); + + controller.onTouchEventsNeeded(mode != ImageEditorHud.Mode.NONE); + + switch (mode) { + case CROP: { + imageEditorView.getModel().startCrop(); + break; + } + + case DRAW: { + imageEditorView.startDrawing(0.01f, Paint.Cap.ROUND, false); + break; + } + + case HIGHLIGHT: { + imageEditorView.startDrawing(0.03f, Paint.Cap.SQUARE, false); + break; + } + + case BLUR: { + imageEditorView.startDrawing(0.052f, Paint.Cap.ROUND, true); + imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer()); + break; + } + + case TEXT: { + addText(); + break; + } + + case INSERT_STICKER: { + Intent intent = new Intent(getContext(), ImageEditorStickerSelectActivity.class); + startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE); + break; + } + + case MOVE_DELETE: + break; + + case NONE: { + imageEditorView.getModel().doneCrop(); + currentSelection = null; + break; + } + } + } + + @Override + public void onColorChange(int color) { + imageEditorView.setDrawingBrushColor(color); + changeEntityColor(color); + } + + @Override + public void onBlurFacesToggled(boolean enabled) { + EditorModel model = imageEditorView.getModel(); + EditorElement mainImage = model.getMainImage(); + if (mainImage == null) { + imageEditorHud.hideBlurToast(); + return; + } + + if (!enabled) { + model.clearFaceRenderers(); + imageEditorHud.hideBlurToast(); + return; + } + + Matrix inverseCropPosition = model.getInverseCropPosition(); + + if (cachedFaceDetection != null) { + if (cachedFaceDetection.first().equals(getUri()) && cachedFaceDetection.second().position.equals(inverseCropPosition)) { + renderFaceBlurs(cachedFaceDetection.second()); + imageEditorHud.showBlurToast(); + return; + } else { + cachedFaceDetection = null; + } + } + + AlertDialog progress = SimpleProgressDialog.show(requireContext()); + mainImage.getFlags().setChildrenVisible(false); + + SimpleTask.run(getLifecycle(), () -> { + if (mainImage.getRenderer() != null) { + Bitmap bitmap = ((UriGlideRenderer) mainImage.getRenderer()).getBitmap(); + if (bitmap != null) { + FaceDetector detector = new AndroidFaceDetector(); + + Point size = model.getOutputSizeMaxWidth(1000); + Bitmap render = model.render(ApplicationDependencies.getApplication(), size); + try { + return new FaceDetectionResult(detector.detect(render), new Point(render.getWidth(), render.getHeight()), inverseCropPosition); + } finally { + render.recycle(); + mainImage.getFlags().reset(); + } + } + } + + return new FaceDetectionResult(Collections.emptyList(), new Point(0, 0), new Matrix()); + }, result -> { + mainImage.getFlags().reset(); + renderFaceBlurs(result); + progress.dismiss(); + imageEditorHud.showBlurToast(); + }); + } + + @Override + public void onUndo() { + imageEditorView.getModel().undo(); + refreshUniqueColors(); + imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer()); + } + + @Override + public void onDelete() { + imageEditorView.deleteElement(currentSelection); + refreshUniqueColors(); + } + + @Override + public void onSave() { + SaveAttachmentTask.showWarningDialog(requireContext(), (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSaveToDisk(); + return; + } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(this::performSaveToDisk) + .execute(); + }); + } + + @Override + public void onFlipHorizontal() { + imageEditorView.getModel().flipHorizontal(); + } + + @Override + public void onRotate90AntiClockwise() { + imageEditorView.getModel().rotate90anticlockwise(); + } + + @Override + public void onCropAspectLock(boolean locked) { + imageEditorView.getModel().setCropAspectLock(locked); + } + + @Override + public boolean isCropAspectLocked() { + return imageEditorView.getModel().isCropAspectLocked(); + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + controller.onRequestFullScreen(fullScreen, hideKeyboard); + } + + @Override + public void onDone() { + controller.onDoneEditing(); + } + + private void performSaveToDisk() { + SimpleTask.run(() -> { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Bitmap image = imageEditorView.getModel().render(requireContext()); + + image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); + + return BlobProvider.getInstance() + .forData(outputStream.toByteArray()) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleUseInMemory(); + + }, uri -> { + SaveAttachmentTask saveTask = new SaveAttachmentTask(requireContext()); + SaveAttachmentTask.Attachment attachment = new SaveAttachmentTask.Attachment(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), null); + saveTask.executeOnExecutor(SignalExecutors.BOUNDED, attachment); + }); + } + + private void refreshUniqueColors() { + imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); + } + + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + imageEditorHud.setUndoAvailability(undoAvailable); + } + + private void renderFaceBlurs(@NonNull FaceDetectionResult result) { + List faces = result.faces; + + if (faces.isEmpty()) { + cachedFaceDetection = null; + return; + } + + imageEditorView.getModel().pushUndoPoint(); + + Matrix faceMatrix = new Matrix(); + + for (FaceDetector.Face face : faces) { + Renderer faceBlurRenderer = new FaceBlurRenderer(); + EditorElement element = new EditorElement(faceBlurRenderer, EditorModel.Z_MASK); + Matrix localMatrix = element.getLocalMatrix(); + + faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face.getBounds(), Matrix.ScaleToFit.FILL); + + localMatrix.set(result.position); + localMatrix.preConcat(faceMatrix); + + element.getFlags().setEditable(false) + .setSelectable(false) + .persist(); + + imageEditorView.getModel().addElementWithoutPushUndo(element); + } + + imageEditorView.invalidate(); + + cachedFaceDetection = new Pair<>(getUri(), result); + } + + private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() { + + @Override + public void onEntityDown(@Nullable EditorElement editorElement) { + if (editorElement != null) { + controller.onTouchEventsNeeded(true); + } else { + currentSelection = null; + controller.onTouchEventsNeeded(false); + imageEditorHud.setMode(ImageEditorHud.Mode.NONE); + } + } + + @Override + public void onEntitySingleTap(@Nullable EditorElement editorElement) { + currentSelection = editorElement; + if (currentSelection != null) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing()); + } else { + imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE); + } + } + } + + @Override + public void onEntityDoubleTap(@NonNull EditorElement editorElement) { + currentSelection = editorElement; + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true); + } + } + + private void setTextElement(@NonNull EditorElement editorElement, + @NonNull ColorableRenderer colorableRenderer, + boolean startEditing) + { + int color = colorableRenderer.getColor(); + imageEditorHud.enterMode(ImageEditorHud.Mode.TEXT); + imageEditorHud.setActiveColor(color); + if (startEditing) { + startTextEntityEditing(editorElement, false); + } + } + }; + + public interface Controller { + void onTouchEventsNeeded(boolean needed); + + void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard); + + void onDoneEditing(); + } + + private static class FaceDetectionResult { + private final List faces; + private final Matrix position; + + private FaceDetectionResult(@NonNull List faces, @NonNull Point imageSize, @NonNull Matrix position) { + this.faces = faces; + this.position = new Matrix(position); + + Matrix imageProjectionMatrix = new Matrix(); + imageProjectionMatrix.setRectToRect(new RectF(0, 0, imageSize.x, imageSize.y), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.FILL); + this.position.preConcat(imageProjectionMatrix); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java new file mode 100644 index 00000000..1dc2b17c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -0,0 +1,396 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; +import org.thoughtcrime.securesms.util.Debouncer; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * The HUD (heads-up display) that contains all of the tools for interacting with + * {@link org.thoughtcrime.securesms.imageeditor.ImageEditorView} + */ +public final class ImageEditorHud extends LinearLayout { + + private View cropButton; + private View cropFlipButton; + private View cropRotateButton; + private ImageView cropAspectLock; + private View drawButton; + private View highlightButton; + private View blurButton; + private View textButton; + private View stickerButton; + private View undoButton; + private View saveButton; + private View deleteButton; + private View confirmButton; + private View doneButton; + private View blurToggleHud; + private Switch blurToggle; + private View blurToast; + private VerticalSlideColorPicker colorPicker; + private RecyclerView colorPalette; + + + @NonNull + private EventListener eventListener = NULL_EVENT_LISTENER; + @Nullable + private ColorPaletteAdapter colorPaletteAdapter; + + private final Map> visibilityModeMap = new HashMap<>(); + private final Set allViews = new HashSet<>(); + private final Debouncer toastDebouncer = new Debouncer(3000); + + private Mode currentMode; + private boolean undoAvailable; + + public ImageEditorHud(@NonNull Context context) { + super(context); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.image_editor_hud, this); + setOrientation(VERTICAL); + + cropButton = findViewById(R.id.scribble_crop_button); + cropFlipButton = findViewById(R.id.scribble_crop_flip); + cropRotateButton = findViewById(R.id.scribble_crop_rotate); + cropAspectLock = findViewById(R.id.scribble_crop_aspect_lock); + colorPalette = findViewById(R.id.scribble_color_palette); + drawButton = findViewById(R.id.scribble_draw_button); + highlightButton = findViewById(R.id.scribble_highlight_button); + blurButton = findViewById(R.id.scribble_blur_button); + textButton = findViewById(R.id.scribble_text_button); + stickerButton = findViewById(R.id.scribble_sticker_button); + undoButton = findViewById(R.id.scribble_undo_button); + saveButton = findViewById(R.id.scribble_save_button); + deleteButton = findViewById(R.id.scribble_delete_button); + confirmButton = findViewById(R.id.scribble_confirm_button); + colorPicker = findViewById(R.id.scribble_color_picker); + doneButton = findViewById(R.id.scribble_done_button); + blurToggleHud = findViewById(R.id.scribble_blur_toggle_hud); + blurToggle = findViewById(R.id.scribble_blur_toggle); + blurToast = findViewById(R.id.scribble_blur_toast); + + cropAspectLock.setOnClickListener(v -> { + eventListener.onCropAspectLock(!eventListener.isCropAspectLocked()); + updateCropAspectLockImage(eventListener.isCropAspectLocked()); + }); + + initializeViews(); + initializeVisibilityMap(); + setMode(Mode.NONE); + } + + private void updateCropAspectLockImage(boolean cropAspectLocked) { + cropAspectLock.setImageDrawable(getResources().getDrawable(cropAspectLocked ? R.drawable.ic_crop_lock_32 : R.drawable.ic_crop_unlock_32)); + } + + private void initializeVisibilityMap() { + setVisibleViewsWhenInMode(Mode.NONE, drawButton, blurButton, textButton, stickerButton, cropButton, undoButton, saveButton); + + setVisibleViewsWhenInMode(Mode.DRAW, confirmButton, undoButton, colorPicker, colorPalette, highlightButton); + + setVisibleViewsWhenInMode(Mode.HIGHLIGHT, confirmButton, undoButton, colorPicker, colorPalette, drawButton); + + setVisibleViewsWhenInMode(Mode.BLUR, confirmButton, undoButton, blurToggleHud); + + setVisibleViewsWhenInMode(Mode.TEXT, confirmButton, deleteButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.MOVE_DELETE, confirmButton, deleteButton); + + setVisibleViewsWhenInMode(Mode.INSERT_STICKER, confirmButton); + + setVisibleViewsWhenInMode(Mode.CROP, confirmButton, cropFlipButton, cropRotateButton, cropAspectLock, undoButton); + + for (Set views : visibilityModeMap.values()) { + allViews.addAll(views); + } + + allViews.add(stickerButton); + allViews.add(doneButton); + } + + private void setVisibleViewsWhenInMode(Mode mode, View... views) { + visibilityModeMap.put(mode, new HashSet<>(Arrays.asList(views))); + } + + private void initializeViews() { + undoButton.setOnClickListener(v -> eventListener.onUndo()); + + deleteButton.setOnClickListener(v -> { + eventListener.onDelete(); + setMode(Mode.NONE); + }); + + cropButton.setOnClickListener(v -> setMode(Mode.CROP)); + cropFlipButton.setOnClickListener(v -> eventListener.onFlipHorizontal()); + cropRotateButton.setOnClickListener(v -> eventListener.onRotate90AntiClockwise()); + + confirmButton.setOnClickListener(v -> setMode(Mode.NONE)); + + colorPaletteAdapter = new ColorPaletteAdapter(); + colorPaletteAdapter.setEventListener(colorPicker::setActiveColor); + + colorPalette.setLayoutManager(new LinearLayoutManager(getContext())); + colorPalette.setAdapter(colorPaletteAdapter); + + drawButton.setOnClickListener(v -> setMode(Mode.DRAW)); + blurButton.setOnClickListener(v -> setMode(Mode.BLUR)); + highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT)); + textButton.setOnClickListener(v -> setMode(Mode.TEXT)); + stickerButton.setOnClickListener(v -> setMode(Mode.INSERT_STICKER)); + saveButton.setOnClickListener(v -> eventListener.onSave()); + doneButton.setOnClickListener(v -> eventListener.onDone()); + blurToggle.setOnCheckedChangeListener((button, enabled) -> eventListener.onBlurFacesToggled(enabled)); + } + + public void setUpForAvatarEditing() { + visibilityModeMap.get(Mode.NONE).add(doneButton); + visibilityModeMap.get(Mode.NONE).remove(saveButton); + visibilityModeMap.get(Mode.CROP).remove(cropAspectLock); + + if (currentMode == Mode.NONE) { + doneButton.setVisibility(View.VISIBLE); + saveButton.setVisibility(View.GONE); + } else if (currentMode == Mode.CROP) { + cropAspectLock.setVisibility(View.GONE); + } + } + + public void setColorPalette(@NonNull Set colors) { + if (colorPaletteAdapter != null) { + colorPaletteAdapter.setColors(colors); + } + } + + public int getActiveColor() { + return colorPicker.getActiveColor(); + } + + public void setActiveColor(int color) { + colorPicker.setActiveColor(color); + } + + public void setBlurFacesToggleEnabled(boolean enabled) { + blurToggle.setOnCheckedChangeListener(null); + blurToggle.setChecked(enabled); + blurToggle.setOnCheckedChangeListener((button, value) -> eventListener.onBlurFacesToggled(value)); + } + + public void showBlurHudTooltip() { + TooltipPopup.forTarget(blurButton) + .setText(R.string.ImageEditorHud_new_blur_faces_or_draw_anywhere_to_blur) + .setBackgroundTint(ContextCompat.getColor(getContext(), R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(getContext(), R.color.core_white)) + .show(TooltipPopup.POSITION_BELOW); + } + + public void showBlurToast() { + blurToast.clearAnimation(); + blurToast.setVisibility(View.VISIBLE); + toastDebouncer.publish(() -> blurToast.setVisibility(GONE)); + } + + public void hideBlurToast() { + blurToast.clearAnimation(); + blurToast.setVisibility(View.GONE); + toastDebouncer.clear(); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener != null ? eventListener : NULL_EVENT_LISTENER; + } + + public void enterMode(@NonNull Mode mode) { + setMode(mode, false); + } + + public void setMode(@NonNull Mode mode) { + setMode(mode, true); + } + + private void setMode(@NonNull Mode mode, boolean notify) { + this.currentMode = mode; + updateButtonVisibility(mode); + + switch (mode) { + case NONE: presentModeNone(); break; + case CROP: presentModeCrop(); break; + case DRAW: presentModeDraw(); break; + case BLUR: presentModeBlur(); break; + case HIGHLIGHT: presentModeHighlight(); break; + case TEXT: presentModeText(); break; + } + + if (notify) { + eventListener.onModeStarted(mode); + } + eventListener.onRequestFullScreen(mode != Mode.NONE, mode != Mode.TEXT); + } + + private void updateButtonVisibility(@NonNull Mode mode) { + Set visibleButtons = visibilityModeMap.get(mode); + for (View button : allViews) { + button.setVisibility(buttonIsVisible(visibleButtons, button) ? VISIBLE : GONE); + } + } + + private boolean buttonIsVisible(@Nullable Set visibleButtons, @NonNull View button) { + return visibleButtons != null && + visibleButtons.contains(button) && + (button != undoButton || undoAvailable); + } + + private void presentModeNone() { + blurToast.setVisibility(GONE); + } + + private void presentModeCrop() { + updateCropAspectLockImage(eventListener.isCropAspectLocked()); + } + + private void presentModeDraw() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.RED); + } + + private void presentModeBlur() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.BLACK); + } + + private void presentModeHighlight() { + colorPicker.setOnColorChangeListener(highlightOnColorChangeListener); + colorPicker.setActiveColor(Color.YELLOW); + } + + private void presentModeText() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.WHITE); + } + + private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = selectedColor -> eventListener.onColorChange(selectedColor); + + private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = selectedColor -> eventListener.onColorChange(withHighlighterAlpha(selectedColor)); + + private static int withHighlighterAlpha(int color) { + return color & ~0xff000000 | 0x60000000; + } + + public void setUndoAvailability(boolean undoAvailable) { + this.undoAvailable = undoAvailable; + + undoButton.setVisibility(buttonIsVisible(visibilityModeMap.get(currentMode), undoButton) ? VISIBLE : GONE); + } + + public enum Mode { + NONE, + CROP, + TEXT, + DRAW, + HIGHLIGHT, + BLUR, + MOVE_DELETE, + INSERT_STICKER, + } + + public interface EventListener { + void onModeStarted(@NonNull Mode mode); + void onColorChange(int color); + void onBlurFacesToggled(boolean enabled); + void onUndo(); + void onDelete(); + void onSave(); + void onFlipHorizontal(); + void onRotate90AntiClockwise(); + void onCropAspectLock(boolean locked); + boolean isCropAspectLocked(); + void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard); + void onDone(); + } + + private static final EventListener NULL_EVENT_LISTENER = new EventListener() { + + @Override + public void onModeStarted(@NonNull Mode mode) { + } + + @Override + public void onColorChange(int color) { + } + + @Override + public void onBlurFacesToggled(boolean enabled) { + } + + @Override + public void onUndo() { + } + + @Override + public void onDelete() { + } + + @Override + public void onSave() { + } + + @Override + public void onFlipHorizontal() { + } + + @Override + public void onRotate90AntiClockwise() { + } + + @Override + public void onCropAspectLock(boolean locked) { + } + + @Override + public boolean isCropAspectLocked() { + return false; + } + + @Override + public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { + } + + @Override + public void onDone() { + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java new file mode 100644 index 00000000..21b5680c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider; +import org.thoughtcrime.securesms.stickers.StickerManagementActivity; + +public final class ImageEditorStickerSelectActivity extends FragmentActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + + setContentView(R.layout.scribble_select_new_sticker_activity); + + MediaKeyboard mediaKeyboard = findViewById(R.id.emoji_drawer); + + mediaKeyboard.setProviders(0, new StickerKeyboardProvider(this, new StickerKeyboardProvider.StickerEventListener() { + @Override + public void onStickerSelected(@NonNull StickerRecord sticker) { + Intent intent = new Intent(); + intent.setData(sticker.getUri()); + setResult(RESULT_OK, intent); + + SignalExecutors.BOUNDED.execute(() -> + DatabaseFactory.getStickerDatabase(getApplicationContext()) + .updateStickerLastUsedTime(sticker.getRowId(), System.currentTimeMillis()) + ); + + finish(); + } + + @Override + public void onStickerManagementClicked() { + startActivity(StickerManagementActivity.getIntent(ImageEditorStickerSelectActivity.this)); + } + } + )); + + mediaKeyboard.setKeyboardListener(new MediaKeyboard.MediaKeyboardListener() { + @Override + public void onShown() { + } + + @Override + public void onHidden() { + finish(); + } + + @Override + public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) { + } + }); + + mediaKeyboard.show(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java new file mode 100644 index 00000000..420668e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java @@ -0,0 +1,319 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Parcel; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.util.BitmapUtil; + +import java.util.concurrent.ExecutionException; + +/** + * Uses Glide to load an image and implements a {@link Renderer}. + * + * The image can be encrypted. + */ +public final class UriGlideRenderer implements Renderer { + + private static final String TAG = Log.tag(UriGlideRenderer.class); + + private static final int PREVIEW_DIMENSION_LIMIT = 2048; + private static final int MAX_BLUR_DIMENSION = 300; + + public static final float WEAK_BLUR = 3f; + public static final float STRONG_BLUR = 25f; + + private final Uri imageUri; + private final Paint paint = new Paint(); + private final Matrix imageProjectionMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final Matrix blurScaleMatrix = new Matrix(); + private final boolean decryptable; + private final int maxWidth; + private final int maxHeight; + private final float blurRadius; + + @Nullable private Bitmap bitmap; + @Nullable private Bitmap blurredBitmap; + @Nullable private Paint blurPaint; + + public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) { + this(imageUri, decryptable, maxWidth, maxHeight, STRONG_BLUR); + } + + public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight, float blurRadius) { + this.imageUri = imageUri; + this.decryptable = decryptable; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.blurRadius = blurRadius; + paint.setAntiAlias(true); + paint.setFilterBitmap(true); + paint.setDither(true); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + if (getBitmap() == null) { + if (rendererContext.isBlockingLoad()) { + try { + Bitmap bitmap = getBitmapGlideRequest(rendererContext.context, false).submit().get(); + setBitmap(rendererContext, bitmap); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } else { + getBitmapGlideRequest(rendererContext.context, true).into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + setBitmap(rendererContext, resource); + + rendererContext.invalidate.onInvalidate(UriGlideRenderer.this); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + bitmap = null; + } + }); + } + } + + final Bitmap bitmap = getBitmap(); + if (bitmap != null) { + rendererContext.save(); + + rendererContext.canvasMatrix.concat(imageProjectionMatrix); + + // Units are image level pixels at this point. + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawBitmap(bitmap, 0, 0, rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint() : paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + + renderBlurOverlay(rendererContext); + } else if (rendererContext.isBlockingLoad()) { + // If failed to load, we draw a black out, in case image was sticker positioned to cover private info. + rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint); + } + } + + private void renderBlurOverlay(RendererContext rendererContext) { + boolean renderMask = false; + + for (EditorElement child : rendererContext.getChildren()) { + if (child.getZOrder() == EditorModel.Z_MASK) { + renderMask = true; + if (blurPaint == null) { + blurPaint = new Paint(); + blurPaint.setAntiAlias(true); + blurPaint.setFilterBitmap(true); + blurPaint.setDither(true); + } + blurPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + rendererContext.setMaskPaint(blurPaint); + child.draw(rendererContext); + } + } + + if (renderMask) { + rendererContext.save(); + rendererContext.canvasMatrix.concat(imageProjectionMatrix); + + blurPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP)); + blurPaint.setMaskFilter(null); + + if (blurredBitmap == null) { + blurredBitmap = blur(bitmap, rendererContext.context, blurRadius); + + blurScaleMatrix.setRectToRect(new RectF(0, 0, blurredBitmap.getWidth(), blurredBitmap.getHeight()), + new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()), + Matrix.ScaleToFit.FILL); + } + + rendererContext.canvas.concat(blurScaleMatrix); + rendererContext.canvas.drawBitmap(blurredBitmap, 0, 0, blurPaint); + blurPaint.setXfermode(null); + + rendererContext.restore(); + } + } + + private GlideRequest getBitmapGlideRequest(@NonNull Context context, boolean preview) { + int width = this.maxWidth; + int height = this.maxHeight; + + if (preview) { + width = Math.min(width, PREVIEW_DIMENSION_LIMIT); + height = Math.min(height, PREVIEW_DIMENSION_LIMIT); + } + + return GlideApp.with(context) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .override(width, height) + .centerInside() + .load(decryptable ? new DecryptableStreamUriLoader.DecryptableUri(imageUri) : imageUri); + } + + @Override + public boolean hitTest(float x, float y) { + return pixelAlphaNotZero(x, y); + } + + private boolean pixelAlphaNotZero(float x, float y) { + Bitmap bitmap = getBitmap(); + + if (bitmap == null) return false; + + imageProjectionMatrix.invert(temp); + + float[] onBmp = new float[2]; + temp.mapPoints(onBmp, new float[]{ x, y }); + + int xInt = (int) onBmp[0]; + int yInt = (int) onBmp[1]; + + if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) { + return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0; + } else { + return false; + } + } + + /** + * Always use this getter, as Bitmap is kept in Glide's LRUCache, so it could have been recycled + * by Glide. If it has, or was never set, this method returns null. + */ + public @Nullable Bitmap getBitmap() { + if (bitmap != null && bitmap.isRecycled()) { + bitmap = null; + } + return bitmap; + } + + private void setBitmap(@NonNull RendererContext rendererContext, @Nullable Bitmap bitmap) { + this.bitmap = bitmap; + if (bitmap != null) { + RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()); + imageProjectionMatrix.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + rendererContext.rendererReady.onReady(UriGlideRenderer.this, cropMatrix(bitmap), new Point(bitmap.getWidth(), bitmap.getHeight())); + } + } + + private static Matrix cropMatrix(Bitmap bitmap) { + Matrix matrix = new Matrix(); + if (bitmap.getWidth() > bitmap.getHeight()) { + matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth()); + } else { + matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1); + } + return matrix; + } + + private static @NonNull Bitmap blur(Bitmap bitmap, Context context, float blurRadius) { + Point previewSize = scaleKeepingAspectRatio(new Point(bitmap.getWidth(), bitmap.getHeight()), PREVIEW_DIMENSION_LIMIT); + Point blurSize = scaleKeepingAspectRatio(new Point(previewSize.x / 2, previewSize.y / 2 ), MAX_BLUR_DIMENSION); + Bitmap small = BitmapUtil.createScaledBitmap(bitmap, blurSize.x, blurSize.y); + + Log.d(TAG, "Bitmap: " + bitmap.getWidth() + "x" + bitmap.getHeight() + ", Blur: " + blurSize.x + "x" + blurSize.y); + + RenderScript rs = RenderScript.create(context); + Allocation input = Allocation.createFromBitmap(rs, small); + Allocation output = Allocation.createTyped(rs, input.getType()); + ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + + script.setRadius(blurRadius); + script.setInput(input); + script.forEach(output); + + Bitmap blurred = Bitmap.createBitmap(small.getWidth(), small.getHeight(), small.getConfig()); + output.copyTo(blurred); + return blurred; + } + + private static @NonNull Point scaleKeepingAspectRatio(@NonNull Point dimens, int maxDimen) { + int outX = dimens.x; + int outY = dimens.y; + + if (dimens.x > maxDimen || dimens.y > maxDimen) { + outX = maxDimen; + outY = maxDimen; + + float widthRatio = dimens.x / (float) maxDimen; + float heightRatio = dimens.y / (float) maxDimen; + + if (widthRatio > heightRatio) { + outY = (int) (dimens.y / widthRatio); + } else { + outX = (int) (dimens.x / heightRatio); + } + } + + return new Point(outX, outY); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriGlideRenderer createFromParcel(Parcel in) { + return new UriGlideRenderer(Uri.parse(in.readString()), + in.readInt() == 1, + in.readInt(), + in.readInt(), + in.readFloat() + ); + } + + @Override + public UriGlideRenderer[] newArray(int size) { + return new UriGlideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(imageUri.toString()); + dest.writeInt(decryptable ? 1 : 0); + dest.writeInt(maxWidth); + dest.writeInt(maxHeight); + dest.writeFloat(blurRadius); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java new file mode 100644 index 00000000..0679c3d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java @@ -0,0 +1,210 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.animation.Animator; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.OvershootInterpolator; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.video.VideoBitRateCalculator; +import org.thoughtcrime.securesms.video.VideoUtil; +import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * The HUD (heads-up display) that contains all of the tools for editing video. + */ +public final class VideoEditorHud extends LinearLayout { + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(VideoEditorHud.class); + + private VideoThumbnailsRangeSelectorView videoTimeLine; + private EventListener eventListener; + private View playOverlay; + + public VideoEditorHud(@NonNull Context context) { + super(context); + initialize(); + } + + public VideoEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public VideoEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + View root = inflate(getContext(), R.layout.video_editor_hud, this); + setOrientation(VERTICAL); + + videoTimeLine = root.findViewById(R.id.video_timeline); + playOverlay = root.findViewById(R.id.play_overlay); + + playOverlay.setOnClickListener(v -> eventListener.onPlay()); + } + + public void setEventListener(EventListener eventListener) { + this.eventListener = eventListener; + } + + @RequiresApi(api = 23) + public void setVideoSource(@NonNull VideoSlide slide, @NonNull VideoBitRateCalculator videoBitRateCalculator, long maxSendSize) + throws IOException + { + Uri uri = slide.getUri(); + + if (uri == null || !slide.hasVideo()) { + return; + } + + videoTimeLine.setInput(DecryptableUriMediaInput.createForUri(getContext(), uri)); + + long size = tryGetUriSize(getContext(), uri, Long.MAX_VALUE); + + if (size > maxSendSize) { + videoTimeLine.setTimeLimit(VideoUtil.getMaxVideoUploadDurationInSeconds(), TimeUnit.SECONDS); + } + + videoTimeLine.setOnRangeChangeListener(new VideoThumbnailsRangeSelectorView.OnRangeChangeListener() { + + @Override + public void onPositionDrag(long position) { + if (eventListener != null) { + eventListener.onSeek(position, false); + } + } + + @Override + public void onEndPositionDrag(long position) { + if (eventListener != null) { + eventListener.onSeek(position, true); + } + } + + @Override + public void onRangeDrag(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { + if (eventListener != null) { + eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, false); + } + } + + @Override + public void onRangeDragEnd(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { + if (eventListener != null) { + eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, true); + } + } + + @Override + public VideoThumbnailsRangeSelectorView.Quality getQuality(long clipDurationUs, long totalDurationUs) { + int inputBitRate = VideoBitRateCalculator.bitRate(size, TimeUnit.MICROSECONDS.toMillis(totalDurationUs)); + + VideoBitRateCalculator.Quality targetQuality = videoBitRateCalculator.getTargetQuality(TimeUnit.MICROSECONDS.toMillis(clipDurationUs), inputBitRate); + return new VideoThumbnailsRangeSelectorView.Quality(targetQuality.getFileSizeEstimate(), (int) (100 * targetQuality.getQuality())); + } + }); + } + + public void showPlayButton() { + playOverlay.setVisibility(VISIBLE); + playOverlay.animate() + .setListener(null) + .alpha(1) + .scaleX(1).scaleY(1) + .setInterpolator(new OvershootInterpolator()) + .start(); + } + + public void fadePlayButton() { + playOverlay.animate() + .setListener(new Animator.AnimatorListener() { + @Override + public void onAnimationEnd(Animator animation) { + playOverlay.setVisibility(GONE); + } + + @Override + public void onAnimationStart(Animator animation) {} + + @Override + public void onAnimationCancel(Animator animation) {} + + @Override + public void onAnimationRepeat(Animator animation) {} + }) + .alpha(0) + .scaleX(0.8f).scaleY(0.8f) + .start(); + } + + public void hidePlayButton() { + playOverlay.setVisibility(GONE); + playOverlay.setAlpha(0); + playOverlay.setScaleX(0.8f); + playOverlay.setScaleY(0.8f); + } + + @RequiresApi(api = 23) + public void setDurationRange(long totalDuration, long fromDuration, long toDuration) { + videoTimeLine.setRange(fromDuration, toDuration); + } + + @RequiresApi(api = 23) + public void setPosition(long playbackPositionUs) { + videoTimeLine.setActualPosition(playbackPositionUs); + } + + public interface EventListener { + + void onEditVideoDuration(long totalDurationUs, long startTimeUs, long endTimeUs, boolean fromEdited, boolean editingComplete); + + void onPlay(); + + void onSeek(long position, boolean dragComplete); + } + + private long tryGetUriSize(@NonNull Context context, @NonNull Uri uri, long defaultValue) { + try { + return getSize(context, uri); + } catch (IOException e) { + Log.w(TAG, e); + return defaultValue; + } + } + + private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { + long size = 0; + + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri); + } + + return size; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java new file mode 100644 index 00000000..2146d4c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/ColorPaletteAdapter.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.scribbles.widget; + +import android.graphics.PorterDuff; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class ColorPaletteAdapter extends RecyclerView.Adapter { + + private final List colors = new ArrayList<>(); + + private EventListener eventListener; + + @Override + public @NonNull ColorViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ColorViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_color, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ColorViewHolder holder, int position) { + holder.bind(colors.get(position), eventListener); + } + + @Override + public int getItemCount() { + return colors.size(); + } + + public void setColors(@NonNull Collection colors) { + this.colors.clear(); + this.colors.addAll(colors); + + notifyDataSetChanged(); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + + notifyDataSetChanged(); + } + + public interface EventListener { + void onColorSelected(int color); + } + + static class ColorViewHolder extends RecyclerView.ViewHolder { + + ImageView foreground; + + ColorViewHolder(View itemView) { + super(itemView); + foreground = itemView.findViewById(R.id.palette_item_foreground); + } + + void bind(int color, @Nullable EventListener eventListener) { + foreground.setColorFilter(color, PorterDuff.Mode.SRC_IN); + + if (eventListener != null) { + itemView.setOnClickListener(v -> eventListener.onColorSelected(color)); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java new file mode 100644 index 00000000..878bd8bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java @@ -0,0 +1,239 @@ +/** + * Copyright (c) 2016 Mark Charles + * Copyright (c) 2016 Open Whisper Systems + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.thoughtcrime.securesms.scribbles.widget; + + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Shader; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import org.thoughtcrime.securesms.R; + +public class VerticalSlideColorPicker extends View { + + private static final float INDICATOR_TO_BAR_WIDTH_RATIO = 0.5f; + + private Paint paint; + private Paint strokePaint; + private Paint indicatorStrokePaint; + private Paint indicatorFillPaint; + private Path path; + private Bitmap bitmap; + private Canvas bitmapCanvas; + + private int viewWidth; + private int viewHeight; + private int centerX; + private float colorPickerRadius; + private RectF colorPickerBody; + + private OnColorChangeListener onColorChangeListener; + + private int borderColor; + private float borderWidth; + private float indicatorRadius; + private int[] colors; + + private int touchY; + private int activeColor; + + public VerticalSlideColorPicker(Context context) { + super(context); + init(); + } + + public VerticalSlideColorPicker(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.VerticalSlideColorPicker, 0, 0); + + try { + int colorsResourceId = a.getResourceId(R.styleable.VerticalSlideColorPicker_pickerColors, R.array.scribble_colors); + + colors = a.getResources().getIntArray(colorsResourceId); + borderColor = a.getColor(R.styleable.VerticalSlideColorPicker_pickerBorderColor, Color.WHITE); + borderWidth = a.getDimension(R.styleable.VerticalSlideColorPicker_pickerBorderWidth, 10f); + + } finally { + a.recycle(); + } + + init(); + } + + public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setWillNotDraw(false); + + paint = new Paint(); + paint.setStyle(Paint.Style.FILL); + paint.setAntiAlias(true); + + path = new Path(); + + strokePaint = new Paint(); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setColor(borderColor); + strokePaint.setAntiAlias(true); + strokePaint.setStrokeWidth(borderWidth); + + indicatorStrokePaint = new Paint(strokePaint); + indicatorStrokePaint.setStrokeWidth(borderWidth / 2); + + indicatorFillPaint = new Paint(); + indicatorFillPaint.setStyle(Paint.Style.FILL); + indicatorFillPaint.setAntiAlias(true); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + path.addCircle(centerX, borderWidth + colorPickerRadius + indicatorRadius, colorPickerRadius, Path.Direction.CW); + path.addRect(colorPickerBody, Path.Direction.CW); + path.addCircle(centerX, viewHeight - (borderWidth + colorPickerRadius + indicatorRadius), colorPickerRadius, Path.Direction.CW); + + bitmapCanvas.drawColor(Color.TRANSPARENT); + + bitmapCanvas.drawPath(path, strokePaint); + bitmapCanvas.drawPath(path, paint); + + canvas.drawBitmap(bitmap, 0, 0, null); + + touchY = Math.max((int) colorPickerBody.top, touchY); + + indicatorFillPaint.setColor(activeColor); + canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorFillPaint); + canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorStrokePaint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + touchY = (int) Math.min(event.getY(), colorPickerBody.bottom); + touchY = (int) Math.max(colorPickerBody.top, touchY); + + activeColor = bitmap.getPixel(viewWidth/2, touchY); + + if (onColorChangeListener != null) { + onColorChangeListener.onColorChange(activeColor); + } + + invalidate(); + + return true; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + viewWidth = w; + viewHeight = h; + + if (viewWidth <= 0 || viewHeight <= 0) return; + + int barWidth = (int) (viewWidth * INDICATOR_TO_BAR_WIDTH_RATIO); + + centerX = viewWidth / 2; + indicatorRadius = (viewWidth / 2) - borderWidth; + colorPickerRadius = (barWidth / 2) - borderWidth; + + colorPickerBody = new RectF(centerX - colorPickerRadius, + borderWidth + colorPickerRadius + indicatorRadius, + centerX + colorPickerRadius, + viewHeight - (borderWidth + colorPickerRadius + indicatorRadius)); + + LinearGradient gradient = new LinearGradient(0, colorPickerBody.top, 0, colorPickerBody.bottom, colors, null, Shader.TileMode.CLAMP); + paint.setShader(gradient); + + if (bitmap != null) { + bitmap.recycle(); + } + + bitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888); + bitmapCanvas = new Canvas(bitmap); + } + + public void setBorderColor(int borderColor) { + this.borderColor = borderColor; + invalidate(); + } + + public void setBorderWidth(float borderWidth) { + this.borderWidth = borderWidth; + invalidate(); + } + + public void setColors(int[] colors) { + this.colors = colors; + invalidate(); + } + + public void setActiveColor(int color) { + activeColor = color; + + if (colorPickerBody != null) { + touchY = (int) colorPickerBody.top; + } + + if (onColorChangeListener != null) { + onColorChangeListener.onColorChange(color); + } + + invalidate(); + } + + public int getActiveColor() { + return activeColor; + } + + public void setOnColorChangeListener(OnColorChangeListener onColorChangeListener) { + this.onColorChangeListener = onColorChangeListener; + } + + public interface OnColorChangeListener { + void onColorChange(int selectedColor); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java new file mode 100644 index 00000000..2f3f78c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -0,0 +1,447 @@ +package org.thoughtcrime.securesms.search; + +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.MergeCursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.contacts.ContactAccessor; +import org.thoughtcrime.securesms.contacts.ContactRepository; +import org.thoughtcrime.securesms.conversationlist.model.MessageResult; +import org.thoughtcrime.securesms.conversationlist.model.SearchResult; +import org.thoughtcrime.securesms.database.CursorList; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MentionDatabase; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +import static org.thoughtcrime.securesms.database.SearchDatabase.SNIPPET_WRAP; + +/** + * Manages data retrieval for search. + */ +public class SearchRepository { + + private static final String TAG = SearchRepository.class.getSimpleName(); + + private static final Set BANNED_CHARACTERS = new HashSet<>(); + static { + // Several ranges of invalid ASCII characters + for (int i = 33; i <= 47; i++) { + BANNED_CHARACTERS.add((char) i); + } + for (int i = 58; i <= 64; i++) { + BANNED_CHARACTERS.add((char) i); + } + for (int i = 91; i <= 96; i++) { + BANNED_CHARACTERS.add((char) i); + } + for (int i = 123; i <= 126; i++) { + BANNED_CHARACTERS.add((char) i); + } + } + + private final Context context; + private final SearchDatabase searchDatabase; + private final ContactRepository contactRepository; + private final ThreadDatabase threadDatabase; + private final ContactAccessor contactAccessor; + private final Executor serialExecutor; + private final ExecutorService parallelExecutor; + private final RecipientDatabase recipientDatabase; + private final MentionDatabase mentionDatabase; + private final MessageDatabase mmsDatabase; + + public SearchRepository() { + this.context = ApplicationDependencies.getApplication().getApplicationContext(); + this.searchDatabase = DatabaseFactory.getSearchDatabase(context); + this.threadDatabase = DatabaseFactory.getThreadDatabase(context); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.mentionDatabase = DatabaseFactory.getMentionDatabase(context); + this.mmsDatabase = DatabaseFactory.getMmsDatabase(context); + this.contactRepository = new ContactRepository(context); + this.contactAccessor = ContactAccessor.getInstance(); + this.serialExecutor = SignalExecutors.SERIAL; + this.parallelExecutor = SignalExecutors.BOUNDED; + } + + public void query(@NonNull String query, @NonNull Callback callback) { + if (TextUtils.isEmpty(query)) { + callback.onResult(SearchResult.EMPTY); + return; + } + + serialExecutor.execute(() -> { + String cleanQuery = sanitizeQuery(query); + + Future> contacts = parallelExecutor.submit(() -> queryContacts(cleanQuery)); + Future> conversations = parallelExecutor.submit(() -> queryConversations(cleanQuery)); + Future> messages = parallelExecutor.submit(() -> queryMessages(cleanQuery)); + Future> mentionMessages = parallelExecutor.submit(() -> queryMentions(sanitizeQueryAsTokens(query))); + + try { + long startTime = System.currentTimeMillis(); + SearchResult result = new SearchResult(cleanQuery, contacts.get(), conversations.get(), mergeMessagesAndMentions(messages.get(), mentionMessages.get())); + + Log.d(TAG, "Total time: " + (System.currentTimeMillis() - startTime) + " ms"); + + callback.onResult(result); + } catch (ExecutionException | InterruptedException e) { + Log.w(TAG, e); + callback.onResult(SearchResult.EMPTY); + } + }); + } + + public void query(@NonNull String query, long threadId, @NonNull Callback> callback) { + if (TextUtils.isEmpty(query)) { + callback.onResult(CursorList.emptyList()); + return; + } + + serialExecutor.execute(() -> { + long startTime = System.currentTimeMillis(); + List messages = queryMessages(sanitizeQuery(query), threadId); + List mentionMessages = queryMentions(sanitizeQueryAsTokens(query), threadId); + + Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms"); + + callback.onResult(mergeMessagesAndMentions(messages, mentionMessages)); + }); + } + + private List queryContacts(String query) { + Cursor contacts = null; + + try { + Cursor textSecureContacts = contactRepository.querySignalContacts(query); + Cursor systemContacts = contactRepository.queryNonSignalContacts(query); + + contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); + + return readToList(contacts, new RecipientModelBuilder(), 250); + } finally { + if (contacts != null) { + contacts.close(); + } + } + } + + private @NonNull List queryConversations(@NonNull String query) { + List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); + List recipientIds = Stream.of(numbers).map(number -> Recipient.external(context, number)).map(Recipient::getId).toList(); + + try (Cursor cursor = threadDatabase.getFilteredConversationList(recipientIds)) { + return readToList(cursor, new ThreadModelBuilder(threadDatabase)); + } + } + + private @NonNull List queryMessages(@NonNull String query) { + List results; + try (Cursor cursor = searchDatabase.queryMessages(query)) { + results = readToList(cursor, new MessageModelBuilder()); + } + + List messageIds = new LinkedList<>(); + for (MessageResult result : results) { + if (result.isMms) { + messageIds.add(result.messageId); + } + } + + if (messageIds.isEmpty()) { + return results; + } + + Map> mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessages(messageIds); + if (mentions.isEmpty()) { + return results; + } + + List updatedResults = new ArrayList<>(results.size()); + for (MessageResult result : results) { + if (result.isMms && mentions.containsKey(result.messageId)) { + List messageMentions = mentions.get(result.messageId); + + //noinspection ConstantConditions + String updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, result.body, messageMentions).getBody().toString(); + String updatedSnippet = updateSnippetWithDisplayNames(result.body, result.bodySnippet, messageMentions); + + //noinspection ConstantConditions + updatedResults.add(new MessageResult(result.conversationRecipient, result.messageRecipient, updatedBody, updatedSnippet, result.threadId, result.messageId, result.receivedTimestampMs, result.isMms)); + } else { + updatedResults.add(result); + } + } + + return updatedResults; + } + + private @NonNull String updateSnippetWithDisplayNames(@NonNull String body, @NonNull String bodySnippet, @NonNull List mentions) { + String cleanSnippet = bodySnippet; + int startOffset = 0; + + if (cleanSnippet.startsWith(SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.substring(SNIPPET_WRAP.length()); + startOffset = SNIPPET_WRAP.length(); + } + + if (cleanSnippet.endsWith(SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.substring(0, cleanSnippet.length() - SNIPPET_WRAP.length()); + } + + int startIndex = body.indexOf(cleanSnippet); + + if (startIndex != -1) { + List adjustMentions = new ArrayList<>(mentions.size()); + for (Mention mention : mentions) { + int adjustedStart = mention.getStart() - startIndex + startOffset; + if (adjustedStart >= 0 && adjustedStart + mention.getLength() <= cleanSnippet.length()) { + adjustMentions.add(new Mention(mention.getRecipientId(), adjustedStart, mention.getLength())); + } + } + + //noinspection ConstantConditions + return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, adjustMentions).getBody().toString(); + } + + return bodySnippet; + } + + private @NonNull List queryMessages(@NonNull String query, long threadId) { + try (Cursor cursor = searchDatabase.queryMessages(query, threadId)) { + return readToList(cursor, new MessageModelBuilder()); + } + } + + private @NonNull List queryMentions(@NonNull List cleanQueries) { + Set recipientIds = new HashSet<>(); + for (String cleanQuery : cleanQueries) { + for (Recipient recipient : recipientDatabase.queryRecipientsForMentions(cleanQuery)) { + recipientIds.add(recipient.getId()); + } + } + + Map> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, 500); + + if (mentionQueryResults.isEmpty()) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(); + + try (MessageDatabase.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) { + MessageRecord record; + while ((record = reader.getNext()) != null) { + List mentions = mentionQueryResults.get(record.getId()); + if (Util.hasItems(mentions)) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, record.getBody(), mentions); + String updatedBody = updated.getBody() != null ? updated.getBody().toString() : record.getBody(); + String updatedSnippet = makeSnippet(cleanQueries, updatedBody); + + //noinspection ConstantConditions + results.add(new MessageResult(threadDatabase.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), updatedBody, updatedSnippet, record.getThreadId(), record.getId(), record.getDateReceived(), true)); + } + } + } + + return results; + } + + private @NonNull List queryMentions(@NonNull List cleanQueries, long threadId) { + Set recipientIds = new HashSet<>(); + for (String cleanQuery : cleanQueries) { + for (Recipient recipient : recipientDatabase.queryRecipientsForMentions(cleanQuery)) { + recipientIds.add(recipient.getId()); + } + } + + Map> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, threadId, 500); + + if (mentionQueryResults.isEmpty()) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(); + + try (MessageDatabase.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) { + MessageRecord record; + while ((record = reader.getNext()) != null) { + //noinspection ConstantConditions + results.add(new MessageResult(threadDatabase.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), record.getBody(), record.getBody(), record.getThreadId(), record.getId(), record.getDateReceived(), true)); + } + } + + return results; + } + + private @NonNull String makeSnippet(@NonNull List queries, @NonNull String body) { + if (body.length() < 50) { + return body; + } + + String lowerBody = body.toLowerCase(); + for (String query : queries) { + int foundIndex = lowerBody.indexOf(query.toLowerCase()); + if (foundIndex != -1) { + int snippetStart = Math.max(0, Math.max(body.lastIndexOf(' ', foundIndex - 5) + 1, foundIndex - 15)); + int lastSpace = body.indexOf(' ', foundIndex + 30); + int snippetEnd = Math.min(body.length(), lastSpace > 0 ? Math.min(lastSpace, foundIndex + 40) : foundIndex + 40); + + return (snippetStart > 0 ? SNIPPET_WRAP : "") + body.substring(snippetStart, snippetEnd) + (snippetEnd < body.length() ? SNIPPET_WRAP : ""); + } + } + return body; + } + + private @NonNull List readToList(@Nullable Cursor cursor, @NonNull CursorList.ModelBuilder builder) { + return readToList(cursor, builder, -1); + } + + private @NonNull List readToList(@Nullable Cursor cursor, @NonNull CursorList.ModelBuilder builder, int limit) { + if (cursor == null) { + return Collections.emptyList(); + } + + int i = 0; + List list = new ArrayList<>(cursor.getCount()); + + while (cursor.moveToNext() && (limit < 0 || i < limit)) { + list.add(builder.build(cursor)); + i++; + } + + return list; + } + + /** + * Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes. + * MATCH queries have a separate format of their own that disallow most "special" characters. + * + * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". + * However, if we replace the apostrophe with a space, then the query will find the match. + */ + private String sanitizeQuery(@NonNull String query) { + StringBuilder out = new StringBuilder(); + + for (int i = 0; i < query.length(); i++) { + char c = query.charAt(i); + if (!BANNED_CHARACTERS.contains(c)) { + out.append(c); + } else if (c == '\'') { + out.append(' '); + } + } + + return out.toString(); + } + + private @NonNull List sanitizeQueryAsTokens(@NonNull String query) { + String[] parts = query.split("\\s+"); + if (parts.length > 3) { + return Collections.emptyList(); + } + + return Stream.of(parts).map(this::sanitizeQuery).toList(); + } + + private static @NonNull List mergeMessagesAndMentions(@NonNull List messages, @NonNull List mentionMessages) { + Set includedMmsMessages = new HashSet<>(); + + List combined = new ArrayList<>(messages.size() + mentionMessages.size()); + for (MessageResult result : messages) { + combined.add(result); + if (result.isMms) { + includedMmsMessages.add(result.messageId); + } + } + + for (MessageResult result : mentionMessages) { + if (!includedMmsMessages.contains(result.messageId)) { + combined.add(result); + } + } + + Collections.sort(combined, Collections.reverseOrder((left, right) -> Long.compare(left.receivedTimestampMs, right.receivedTimestampMs))); + + return combined; + } + + private static class RecipientModelBuilder implements CursorList.ModelBuilder { + + @Override + public Recipient build(@NonNull Cursor cursor) { + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)); + return Recipient.resolved(RecipientId.from(recipientId)); + } + } + + private static class ThreadModelBuilder implements CursorList.ModelBuilder { + + private final ThreadDatabase threadDatabase; + + ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) { + this.threadDatabase = threadDatabase; + } + + @Override + public ThreadRecord build(@NonNull Cursor cursor) { + return threadDatabase.readerFor(cursor).getCurrent(); + } + } + + private static class MessageModelBuilder implements CursorList.ModelBuilder { + + @Override + public MessageResult build(@NonNull Cursor cursor) { + RecipientId conversationRecipientId = RecipientId.from(CursorUtil.requireLong(cursor, SearchDatabase.CONVERSATION_RECIPIENT)); + RecipientId messageRecipientId = RecipientId.from(CursorUtil.requireLong(cursor, SearchDatabase.MESSAGE_RECIPIENT)); + Recipient conversationRecipient = Recipient.live(conversationRecipientId).get(); + Recipient messageRecipient = Recipient.live(messageRecipientId).get(); + String body = CursorUtil.requireString(cursor, SearchDatabase.BODY); + String bodySnippet = CursorUtil.requireString(cursor, SearchDatabase.SNIPPET); + long receivedMs = CursorUtil.requireLong(cursor, MmsSmsColumns.NORMALIZED_DATE_RECEIVED); + long threadId = CursorUtil.requireLong(cursor, MmsSmsColumns.THREAD_ID); + int messageId = CursorUtil.requireInt(cursor, SearchDatabase.MESSAGE_ID); + boolean isMms = CursorUtil.requireInt(cursor, SearchDatabase.IS_MMS) == 1; + + return new MessageResult(conversationRecipient, messageRecipient, body, bodySnippet, threadId, messageId, receivedMs, isMms); + } + } + + public interface Callback { + void onResult(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java b/app/src/main/java/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java new file mode 100644 index 00000000..01068182 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.service; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.NetworkErrorException; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; + +public class AccountAuthenticatorService extends Service { + + private static AccountAuthenticatorImpl accountAuthenticator = null; + + @Override + public IBinder onBind(Intent intent) { + if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT)) { + return getAuthenticator().getIBinder(); + } else { + return null; + } + } + + private synchronized AccountAuthenticatorImpl getAuthenticator() { + if (accountAuthenticator == null) { + accountAuthenticator = new AccountAuthenticatorImpl(this); + } + + return accountAuthenticator; + } + + private static class AccountAuthenticatorImpl extends AbstractAccountAuthenticator { + + public AccountAuthenticatorImpl(Context context) { + super(context); + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, + String[] requiredFeatures, Bundle options) + throws NetworkErrorException + { + return null; + } + + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) { + return null; + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, + Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + return null; + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) + throws NetworkErrorException { + return null; + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, + Bundle options) { + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/AccountVerificationTimeoutException.java b/app/src/main/java/org/thoughtcrime/securesms/service/AccountVerificationTimeoutException.java new file mode 100644 index 00000000..4a38a3c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/AccountVerificationTimeoutException.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.service; + +public class AccountVerificationTimeoutException extends Exception { + public AccountVerificationTimeoutException() { + } + + public AccountVerificationTimeoutException(String detailMessage) { + super(detailMessage); + } + + public AccountVerificationTimeoutException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public AccountVerificationTimeoutException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java b/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java new file mode 100644 index 00000000..81e91849 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java @@ -0,0 +1,224 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.BitmapFactory; +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; + +import androidx.core.app.NotificationCompat; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.SmsMigrator; +import org.thoughtcrime.securesms.database.SmsMigrator.ProgressDescription; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; + +import java.lang.ref.WeakReference; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +// FIXME: This class is nuts. +public class ApplicationMigrationService extends Service + implements SmsMigrator.SmsMigrationProgressListener +{ + private static final String TAG = ApplicationMigrationService.class.getSimpleName(); + public static final String MIGRATE_DATABASE = "org.thoughtcrime.securesms.ApplicationMigration.MIGRATE_DATABSE"; + public static final String COMPLETED_ACTION = "org.thoughtcrime.securesms.ApplicationMigrationService.COMPLETED"; + private static final String PREFERENCES_NAME = "SecureSMS"; + private static final String DATABASE_MIGRATED = "migrated"; + + private final BroadcastReceiver completedReceiver = new CompletedReceiver(); + private final Binder binder = new ApplicationMigrationBinder(); + private final Executor executor = Executors.newSingleThreadExecutor(); + + private WeakReference handler = null; + private NotificationCompat.Builder notification = null; + private ImportState state = new ImportState(ImportState.STATE_IDLE, null); + + @Override + public void onCreate() { + registerCompletedReceiver(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) return START_NOT_STICKY; + + if (intent.getAction() != null && intent.getAction().equals(MIGRATE_DATABASE)) { + executor.execute(new ImportRunnable()); + } + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + unregisterCompletedReceiver(); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + public void setImportStateHandler(Handler handler) { + this.handler = new WeakReference<>(handler); + } + + private void registerCompletedReceiver() { + IntentFilter filter = new IntentFilter(); + filter.addAction(COMPLETED_ACTION); + + registerReceiver(completedReceiver, filter); + } + + private void unregisterCompletedReceiver() { + unregisterReceiver(completedReceiver); + } + + private void notifyImportComplete() { + Intent intent = new Intent(); + intent.setAction(COMPLETED_ACTION); + + sendOrderedBroadcast(intent, null); + } + + @Override + public void progressUpdate(ProgressDescription progress) { + setState(new ImportState(ImportState.STATE_MIGRATING_IN_PROGRESS, progress)); + } + + public ImportState getState() { + return state; + } + + private void setState(ImportState state) { + this.state = state; + + if (this.handler != null) { + Handler handler = this.handler.get(); + + if (handler != null) { + handler.obtainMessage(state.state, state.progress).sendToTarget(); + } + } + + if (state.progress != null && state.progress.secondaryComplete == 0) { + updateBackgroundNotification(state.progress.primaryTotal, state.progress.primaryComplete); + } + } + + private void updateBackgroundNotification(int total, int complete) { + notification.setProgress(total, complete, false); + + ((NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(NotificationIds.APPLICATION_MIGRATION, notification.build()); + } + + private NotificationCompat.Builder initializeBackgroundNotification() { + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NotificationChannels.OTHER); + + builder.setSmallIcon(R.drawable.ic_notification); + builder.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_notification)); + builder.setContentTitle(getString(R.string.ApplicationMigrationService_importing_text_messages)); + builder.setContentText(getString(R.string.ApplicationMigrationService_import_in_progress)); + builder.setOngoing(true); + builder.setProgress(100, 0, false); + // TODO [greyson] Navigation + builder.setContentIntent(PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), 0)); + + stopForeground(true); + startForeground(NotificationIds.APPLICATION_MIGRATION, builder.build()); + + return builder; + } + + private class ImportRunnable implements Runnable { + + ImportRunnable() {} + + @Override + public void run() { + notification = initializeBackgroundNotification(); + PowerManager powerManager = (PowerManager)getSystemService(Context.POWER_SERVICE); + WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:migration"); + + try { + wakeLock.acquire(); + + setState(new ImportState(ImportState.STATE_MIGRATING_BEGIN, null)); + + SmsMigrator.migrateDatabase(ApplicationMigrationService.this, + ApplicationMigrationService.this); + + setState(new ImportState(ImportState.STATE_MIGRATING_COMPLETE, null)); + + setDatabaseImported(ApplicationMigrationService.this); + stopForeground(true); + notifyImportComplete(); + stopSelf(); + } finally { + wakeLock.release(); + } + } + } + + public class ApplicationMigrationBinder extends Binder { + public ApplicationMigrationService getService() { + return ApplicationMigrationService.this; + } + } + + private static class CompletedReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.OTHER); + builder.setSmallIcon(R.drawable.ic_notification); + builder.setContentTitle(context.getString(R.string.ApplicationMigrationService_import_complete)); + builder.setContentText(context.getString(R.string.ApplicationMigrationService_system_database_import_is_complete)); + // TODO [greyson] Navigation + builder.setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)); + builder.setWhen(System.currentTimeMillis()); + builder.setDefaults(Notification.DEFAULT_VIBRATE); + builder.setAutoCancel(true); + + Notification notification = builder.build(); + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)).notify(NotificationIds.SMS_IMPORT_COMPLETE, notification); + } + } + + public static class ImportState { + public static final int STATE_IDLE = 0; + public static final int STATE_MIGRATING_BEGIN = 1; + public static final int STATE_MIGRATING_IN_PROGRESS = 2; + public static final int STATE_MIGRATING_COMPLETE = 3; + + public int state; + public ProgressDescription progress; + + public ImportState(int state, ProgressDescription progress) { + this.state = state; + this.progress = progress; + } + } + + public static boolean isDatabaseImported(Context context) { + return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .getBoolean(DATABASE_MIGRATED, false); + } + + public static void setDatabaseImported(Context context) { + context.getSharedPreferences(PREFERENCES_NAME, 0).edit().putBoolean(DATABASE_MIGRATED, true).apply(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java new file mode 100644 index 00000000..d968878a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/BootReceiver.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; + +public class BootReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java b/app/src/main/java/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java new file mode 100644 index 00000000..a454a31b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.contacts.ContactsSyncAdapter; + +public class ContactsSyncAdapterService extends Service { + + private static ContactsSyncAdapter syncAdapter; + + @Override + public synchronized void onCreate() { + if (syncAdapter == null) { + syncAdapter = new ContactsSyncAdapter(this, true); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DelayedNotificationController.java b/app/src/main/java/org/thoughtcrime/securesms/service/DelayedNotificationController.java new file mode 100644 index 00000000..29ce6258 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DelayedNotificationController.java @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.service; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; + +import java.util.Objects; + +/** + * Represents a delayed foreground notification. + *

+ * With this, you can {@link #close()} it to dismiss or prevent it showing if it hasn't already. + *

+ * You can also {@link #showNow()} to show if it's not already. This returns a regular {@link NotificationController} which can be updated if required. + */ +public abstract class DelayedNotificationController implements AutoCloseable { + + private static final String TAG = Log.tag(DelayedNotificationController.class); + + public static final long SHOW_WITHOUT_DELAY = 0; + public static final long DO_NOT_SHOW = -1; + + private DelayedNotificationController() {} + + static DelayedNotificationController create(long delayMillis, @NonNull Create createTask) { + if (delayMillis == SHOW_WITHOUT_DELAY) return new Shown(createTask.create()); + if (delayMillis == DO_NOT_SHOW) return new NoShow(); + if (delayMillis > 0) return new DelayedShow(delayMillis, createTask); + + throw new IllegalArgumentException("Illegal delay " + delayMillis); + } + + /** + * Show the foreground notification if it's not already showing. + *

+ * If it does show, it returns a regular {@link NotificationController} which you can use to update its message or progress. + */ + public abstract @Nullable NotificationController showNow(); + + @Override + public void close() { + } + + private static final class NoShow extends DelayedNotificationController { + + @Override + public @Nullable NotificationController showNow() { + return null; + } + } + + private static final class Shown extends DelayedNotificationController { + + private final NotificationController controller; + + Shown(@NonNull NotificationController controller) { + this.controller = controller; + } + + @Override + public void close() { + this.controller.close(); + } + + @Override + public NotificationController showNow() { + return controller; + } + } + + private static final class DelayedShow extends DelayedNotificationController { + + private final Create createTask; + private final Handler handler; + private final Runnable start; + private NotificationController notificationController; + private boolean isClosed; + + private DelayedShow(long delayMillis, @NonNull Create createTask) { + this.createTask = createTask; + this.handler = new Handler(Looper.getMainLooper()); + this.start = this::start; + + handler.postDelayed(start, delayMillis); + } + + private void start() { + SignalExecutors.BOUNDED.execute(this::showNowInner); + } + + public synchronized @NonNull NotificationController showNow() { + if (isClosed) { + throw new AssertionError("showNow called after close"); + } + return Objects.requireNonNull(showNowInner()); + } + + private synchronized @Nullable NotificationController showNowInner() { + if (notificationController != null) { + return notificationController; + } + + if (!isClosed) { + Log.i(TAG, "Starting foreground service"); + notificationController = createTask.create(); + return notificationController; + } else { + Log.i(TAG, "Did not start foreground service as close has been called"); + return null; + } + } + + @Override + public synchronized void close() { + handler.removeCallbacks(start); + isClosed = true; + if (notificationController != null) { + Log.d(TAG, "Closing"); + notificationController.close(); + } else { + Log.d(TAG, "Never showed"); + } + } + } + + public interface Create { + @NonNull NotificationController create(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java new file mode 100644 index 00000000..329ee054 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.ComponentName; +import android.content.Context; +import android.content.IntentFilter; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.os.Bundle; +import android.service.chooser.ChooserTarget; +import android.service.chooser.ChooserTargetService; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.view.ContextThemeWrapper; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +@RequiresApi(api = Build.VERSION_CODES.M) +public class DirectShareService extends ChooserTargetService { + + + private static final String TAG = DirectShareService.class.getSimpleName(); + private static final int MAX_TARGETS = 10; + + @Override + public List onGetChooserTargets(ComponentName targetActivityName, + IntentFilter matchedFilter) + { + Map results = new LinkedHashMap<>(); + + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(this); + if (shortcutManager != null && !shortcutManager.getDynamicShortcuts().isEmpty()) { + addChooserTargetsFromDynamicShortcuts(results, shortcutManager.getDynamicShortcuts()); + } + + if (results.size() >= MAX_TARGETS) { + return new ArrayList<>(results.values()); + } + } + + ComponentName componentName = new ComponentName(this, ShareActivity.class); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(this); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(MAX_TARGETS, false, FeatureFlags.groupsV1ForcedMigration()))) { + ThreadRecord record; + + while ((record = reader.getNext()) != null) { + if (results.containsKey(record.getRecipient().getId())) { + continue; + } + + Recipient recipient = Recipient.resolved(record.getRecipient().getId()); + String name = recipient.getDisplayName(this); + + Bitmap avatar; + + if (recipient.getContactPhoto() != null) { + try { + avatar = GlideApp.with(this) + .asBitmap() + .load(recipient.getContactPhoto()) + .circleCrop() + .submit(getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width)) + .get(); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, e); + avatar = getFallbackDrawable(recipient); + } + } else { + avatar = getFallbackDrawable(recipient); + } + + Bundle bundle = buildExtras(record); + + results.put(recipient.getId(), new ChooserTarget(name, Icon.createWithBitmap(avatar), 1.0f, componentName, bundle)); + } + + return new ArrayList<>(results.values()); + } + } + + private @NonNull Bundle buildExtras(@NonNull ThreadRecord threadRecord) { + Bundle bundle = new Bundle(); + + bundle.putLong(ShareActivity.EXTRA_THREAD_ID, threadRecord.getThreadId()); + bundle.putString(ShareActivity.EXTRA_RECIPIENT_ID, threadRecord.getRecipient().getId().serialize()); + bundle.putInt(ShareActivity.EXTRA_DISTRIBUTION_TYPE, threadRecord.getDistributionType()); + + return bundle; + } + + private Bitmap getFallbackDrawable(@NonNull Recipient recipient) { + Context themedContext = new ContextThemeWrapper(this, R.style.TextSecure_LightTheme); + return BitmapUtil.createFromDrawable(recipient.getFallbackContactPhotoDrawable(themedContext, false), + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)); + } + + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private void addChooserTargetsFromDynamicShortcuts(@NonNull Map targetMap, @NonNull List shortcutInfos) { + Stream.of(shortcutInfos) + .sorted((lhs, rhs) -> Integer.compare(lhs.getRank(), rhs.getRank())) + .takeWhileIndexed((idx, info) -> idx < MAX_TARGETS) + .forEach(info -> { + Recipient recipient = Recipient.resolved(RecipientId.from(info.getId())); + ChooserTarget target = buildChooserTargetFromShortcutInfo(info, recipient); + + targetMap.put(RecipientId.from(info.getId()), target); + }); + } + + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private @NonNull ChooserTarget buildChooserTargetFromShortcutInfo(@NonNull ShortcutInfo info, @NonNull Recipient recipient) { + ThreadRecord threadRecord = DatabaseFactory.getThreadDatabase(this).getThreadRecordFor(recipient); + + return new ChooserTarget(info.getShortLabel(), + AvatarUtil.getIconForShortcut(this, recipient), + info.getRank() / ((float) MAX_TARGETS), + new ComponentName(this, ShareActivity.class), + buildExtras(threadRecord)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java new file mode 100644 index 00000000..de246ecf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class DirectoryRefreshListener extends PersistentAlarmManagerListener { + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getDirectoryRefreshTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + if (scheduledTime != 0 && TextSecurePreferences.isPushRegistered(context)) { + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(true)); + } + + long interval = TimeUnit.SECONDS.toMillis(FeatureFlags.cdsRefreshIntervalSeconds()); + long newTime = System.currentTimeMillis() + interval; + + TextSecurePreferences.setDirectoryRefreshTime(context, newTime); + + return newTime; + } + + public static void schedule(Context context) { + new DirectoryRefreshListener().onReceive(context, new Intent()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java new file mode 100644 index 00000000..4a83707d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.service; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.ApplicationContext; + +public class ExpirationListener extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + ApplicationContext.getInstance(context).getExpiringMessageManager().checkSchedule(); + } + + public static void setAlarm(Context context, long waitTimeMillis) { + Intent intent = new Intent(context, ExpirationListener.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); + + alarmManager.cancel(pendingIntent); + alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + waitTimeMillis, pendingIntent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java new file mode 100644 index 00000000..9a133f76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.service; + +import android.content.Context; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; + +import java.util.Comparator; +import java.util.TreeSet; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class ExpiringMessageManager { + + private static final String TAG = ExpiringMessageManager.class.getSimpleName(); + + private final TreeSet expiringMessageReferences = new TreeSet<>(new ExpiringMessageComparator()); + private final Executor executor = Executors.newSingleThreadExecutor(); + + private final MessageDatabase smsDatabase; + private final MessageDatabase mmsDatabase; + private final Context context; + + public ExpiringMessageManager(Context context) { + this.context = context.getApplicationContext(); + this.smsDatabase = DatabaseFactory.getSmsDatabase(context); + this.mmsDatabase = DatabaseFactory.getMmsDatabase(context); + + executor.execute(new LoadTask()); + executor.execute(new ProcessTask()); + } + + public void scheduleDeletion(long id, boolean mms, long expiresInMillis) { + scheduleDeletion(id, mms, System.currentTimeMillis(), expiresInMillis); + } + + public void scheduleDeletion(long id, boolean mms, long startedAtTimestamp, long expiresInMillis) { + long expiresAtMillis = startedAtTimestamp + expiresInMillis; + + synchronized (expiringMessageReferences) { + expiringMessageReferences.add(new ExpiringMessageReference(id, mms, expiresAtMillis)); + expiringMessageReferences.notifyAll(); + } + } + + public void checkSchedule() { + synchronized (expiringMessageReferences) { + expiringMessageReferences.notifyAll(); + } + } + + private class LoadTask implements Runnable { + public void run() { + SmsDatabase.Reader smsReader = SmsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()); + MmsDatabase.Reader mmsReader = MmsDatabase.readerFor(mmsDatabase.getExpirationStartedMessages()); + + MessageRecord messageRecord; + + while ((messageRecord = smsReader.getNext()) != null) { + expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), + messageRecord.isMms(), + messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); + } + + while ((messageRecord = mmsReader.getNext()) != null) { + expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), + messageRecord.isMms(), + messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); + } + + smsReader.close(); + mmsReader.close(); + } + } + + @SuppressWarnings("InfiniteLoopStatement") + private class ProcessTask implements Runnable { + public void run() { + while (true) { + ExpiringMessageReference expiredMessage = null; + + synchronized (expiringMessageReferences) { + try { + while (expiringMessageReferences.isEmpty()) expiringMessageReferences.wait(); + + ExpiringMessageReference nextReference = expiringMessageReferences.first(); + long waitTime = nextReference.expiresAtMillis - System.currentTimeMillis(); + + if (waitTime > 0) { + ExpirationListener.setAlarm(context, waitTime); + expiringMessageReferences.wait(waitTime); + } else { + expiredMessage = nextReference; + expiringMessageReferences.remove(nextReference); + } + + } catch (InterruptedException e) { + Log.w(TAG, e); + } + } + + if (expiredMessage != null) { + if (expiredMessage.mms) mmsDatabase.deleteMessage(expiredMessage.id); + else smsDatabase.deleteMessage(expiredMessage.id); + } + } + } + } + + private static class ExpiringMessageReference { + private final long id; + private final boolean mms; + private final long expiresAtMillis; + + private ExpiringMessageReference(long id, boolean mms, long expiresAtMillis) { + this.id = id; + this.mms = mms; + this.expiresAtMillis = expiresAtMillis; + } + + @Override + public boolean equals(Object other) { + if (other == null) return false; + if (!(other instanceof ExpiringMessageReference)) return false; + + ExpiringMessageReference that = (ExpiringMessageReference)other; + return this.id == that.id && this.mms == that.mms && this.expiresAtMillis == that.expiresAtMillis; + } + + @Override + public int hashCode() { + return (int)this.id ^ (mms ? 1 : 0) ^ (int)expiresAtMillis; + } + } + + private static class ExpiringMessageComparator implements Comparator { + @Override + public int compare(ExpiringMessageReference lhs, ExpiringMessageReference rhs) { + if (lhs.expiresAtMillis < rhs.expiresAtMillis) return -1; + else if (lhs.expiresAtMillis > rhs.expiresAtMillis) return 1; + else if (lhs.id < rhs.id) return -1; + else if (lhs.id > rhs.id) return 1; + else if (!lhs.mms && rhs.mms) return -1; + else if (lhs.mms && !rhs.mms) return 1; + else return 0; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java new file mode 100644 index 00000000..08f4d889 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java @@ -0,0 +1,269 @@ +package org.thoughtcrime.securesms.service; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.notifications.NotificationChannels; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +public final class GenericForegroundService extends Service { + + private static final String TAG = Log.tag(GenericForegroundService.class); + + private final IBinder binder = new LocalBinder(); + + private static final int NOTIFICATION_ID = 827353982; + private static final String EXTRA_TITLE = "extra_title"; + private static final String EXTRA_CHANNEL_ID = "extra_channel_id"; + private static final String EXTRA_ICON_RES = "extra_icon_res"; + private static final String EXTRA_ID = "extra_id"; + private static final String EXTRA_PROGRESS = "extra_progress"; + private static final String EXTRA_PROGRESS_MAX = "extra_progress_max"; + private static final String EXTRA_PROGRESS_INDETERMINATE = "extra_progress_indeterminate"; + + private static final String ACTION_START = "start"; + private static final String ACTION_STOP = "stop"; + + private static final AtomicInteger NEXT_ID = new AtomicInteger(); + + private final LinkedHashMap allActiveMessages = new LinkedHashMap<>(); + + private static final Entry DEFAULTS = new Entry("", NotificationChannels.OTHER, R.drawable.ic_notification, -1, 0, 0, false); + + private @Nullable Entry lastPosted; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + throw new IllegalStateException("Intent needs to be non-null."); + } + + synchronized (GenericForegroundService.class) { + String action = intent.getAction(); + + if (action != null) { + if (ACTION_START.equals(action)) handleStart(intent); + else if (ACTION_STOP .equals(action)) handleStop(intent); + else throw new IllegalStateException(String.format("Action needs to be %s or %s.", ACTION_START, ACTION_STOP)); + + updateNotification(); + } + + return START_NOT_STICKY; + } + } + + private synchronized void updateNotification() { + Iterator iterator = allActiveMessages.values().iterator(); + + if (iterator.hasNext()) { + postObligatoryForegroundNotification(iterator.next()); + } else { + Log.i(TAG, "Last request. Ending foreground service."); + postObligatoryForegroundNotification(lastPosted != null ? lastPosted : DEFAULTS); + stopForeground(true); + stopSelf(); + } + } + + private synchronized void handleStart(@NonNull Intent intent) { + Entry entry = Entry.fromIntent(intent); + + Log.i(TAG, String.format(Locale.US, "handleStart() %s", entry)); + + allActiveMessages.put(entry.id, entry); + } + + private synchronized void handleStop(@NonNull Intent intent) { + Log.i(TAG, "handleStop()"); + + int id = intent.getIntExtra(EXTRA_ID, -1); + + Entry removed = allActiveMessages.remove(id); + + if (removed == null) { + Log.w(TAG, "Could not find entry to remove"); + } + } + + private void postObligatoryForegroundNotification(@NonNull Entry active) { + lastPosted = active; + // TODO [greyson] Navigation + startForeground(NOTIFICATION_ID, new NotificationCompat.Builder(this, active.channelId) + .setSmallIcon(active.iconRes) + .setContentTitle(active.title) + .setProgress(active.progressMax, active.progress, active.indeterminate) + .setContentIntent(PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), 0)) + .setVibrate(new long[]{0}) + .build()); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + /** + * Waits for {@param delayMillis} ms before starting the foreground task. + *

+ * The delayed notification controller can also shown on demand and promoted to a regular notification controller to update the message etc. + */ + public static DelayedNotificationController startForegroundTaskDelayed(@NonNull Context context, @NonNull String task, long delayMillis) { + return DelayedNotificationController.create(delayMillis, () -> startForegroundTask(context, task)); + } + + public static NotificationController startForegroundTask(@NonNull Context context, @NonNull String task) { + return startForegroundTask(context, task, DEFAULTS.channelId); + } + + public static NotificationController startForegroundTask(@NonNull Context context, @NonNull String task, @NonNull String channelId) { + return startForegroundTask(context, task, channelId, DEFAULTS.iconRes); + } + + public static NotificationController startForegroundTask(@NonNull Context context, @NonNull String task, @NonNull String channelId, @DrawableRes int iconRes) { + final int id = NEXT_ID.getAndIncrement(); + + Intent intent = new Intent(context, GenericForegroundService.class); + intent.setAction(ACTION_START); + intent.putExtra(EXTRA_TITLE, task); + intent.putExtra(EXTRA_CHANNEL_ID, channelId); + intent.putExtra(EXTRA_ICON_RES, iconRes); + intent.putExtra(EXTRA_ID, id); + + Log.i(TAG, String.format(Locale.US, "Starting foreground service (%s) id=%d", task, id)); + ContextCompat.startForegroundService(context, intent); + + return new NotificationController(context, id); + } + + public static void stopForegroundTask(@NonNull Context context, int id) { + Intent intent = new Intent(context, GenericForegroundService.class); + intent.setAction(ACTION_STOP); + intent.putExtra(EXTRA_ID, id); + + Log.i(TAG, String.format(Locale.US, "Stopping foreground service id=%d", id)); + ContextCompat.startForegroundService(context, intent); + } + + synchronized void replaceProgress(int id, int progressMax, int progress, boolean indeterminate) { + Entry oldEntry = allActiveMessages.get(id); + + if (oldEntry == null) { + Log.w(TAG, "Failed to replace notification, it was not found"); + return; + } + + Entry newEntry = new Entry(oldEntry.title, oldEntry.channelId, oldEntry.iconRes, oldEntry.id, progressMax, progress, indeterminate); + + if (oldEntry.equals(newEntry)) { + Log.d(TAG, String.format("handleReplace() skip, no change %s", newEntry)); + return; + } + + Log.i(TAG, String.format("handleReplace() %s", newEntry)); + + allActiveMessages.put(newEntry.id, newEntry); + + updateNotification(); + } + + private static class Entry { + final @NonNull String title; + final @NonNull String channelId; + final int id; + final @DrawableRes int iconRes; + final int progress; + final int progressMax; + final boolean indeterminate; + + private Entry(@NonNull String title, @NonNull String channelId, @DrawableRes int iconRes, int id, int progressMax, int progress, boolean indeterminate) { + this.title = title; + this.channelId = channelId; + this.iconRes = iconRes; + this.id = id; + this.progress = progress; + this.progressMax = progressMax; + this.indeterminate = indeterminate; + } + + private static Entry fromIntent(@NonNull Intent intent) { + int id = intent.getIntExtra(EXTRA_ID, DEFAULTS.id); + + String title = intent.getStringExtra(EXTRA_TITLE); + if (title == null) title = DEFAULTS.title; + + String channelId = intent.getStringExtra(EXTRA_CHANNEL_ID); + if (channelId == null) channelId = DEFAULTS.channelId; + + int iconRes = intent.getIntExtra(EXTRA_ICON_RES, DEFAULTS.iconRes); + int progress = intent.getIntExtra(EXTRA_PROGRESS, DEFAULTS.progress); + int progressMax = intent.getIntExtra(EXTRA_PROGRESS_MAX, DEFAULTS.progressMax); + boolean indeterminate = intent.getBooleanExtra(EXTRA_PROGRESS_INDETERMINATE, DEFAULTS.indeterminate); + + return new Entry(title, channelId, iconRes, id, progressMax, progress, indeterminate); + } + + @Override + public @NonNull String toString() { + return String.format(Locale.US, "ChannelId: %s Id: %d Progress: %d/%d %s", channelId, id, progress, progressMax, indeterminate ? "indeterminate" : "determinate"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Entry entry = (Entry) o; + return id == entry.id && + iconRes == entry.iconRes && + progress == entry.progress && + progressMax == entry.progressMax && + indeterminate == entry.indeterminate && + Objects.equals(title, entry.title) && + Objects.equals(channelId, entry.channelId); + } + + @Override + public int hashCode() { + int hashCode = title.hashCode(); + hashCode *= 31; + hashCode += channelId.hashCode(); + hashCode *= 31; + hashCode += id; + hashCode *= 31; + hashCode += iconRes; + hashCode *= 31; + hashCode += progress; + hashCode *= 31; + hashCode += progressMax; + hashCode *= 31; + hashCode += indeterminate ? 1 : 0; + return hashCode; + } + } + + class LocalBinder extends Binder { + GenericForegroundService getService() { + // Return this instance of LocalService so clients can call public methods + return GenericForegroundService.this; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java new file mode 100644 index 00000000..e06ad8cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.service; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Binder; +import android.os.IBinder; +import android.os.SystemClock; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.DummyActivity; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.InvalidPassphraseException; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +/** + * Small service that stays running to keep a key cached in memory. + * + * @author Moxie Marlinspike + */ + +public class KeyCachingService extends Service { + + private static final String TAG = KeyCachingService.class.getSimpleName(); + + public static final int SERVICE_RUNNING_ID = 4141; + + public static final String KEY_PERMISSION = BuildConfig.APPLICATION_ID + ".ACCESS_SECRETS"; + public static final String NEW_KEY_EVENT = BuildConfig.APPLICATION_ID + ".service.action.NEW_KEY_EVENT"; + public static final String CLEAR_KEY_EVENT = BuildConfig.APPLICATION_ID + ".service.action.CLEAR_KEY_EVENT"; + public static final String LOCK_TOGGLED_EVENT = BuildConfig.APPLICATION_ID + ".service.action.LOCK_ENABLED_EVENT"; + private static final String PASSPHRASE_EXPIRED_EVENT = BuildConfig.APPLICATION_ID + ".service.action.PASSPHRASE_EXPIRED_EVENT"; + public static final String CLEAR_KEY_ACTION = BuildConfig.APPLICATION_ID + ".service.action.CLEAR_KEY"; + public static final String DISABLE_ACTION = BuildConfig.APPLICATION_ID + ".service.action.DISABLE"; + public static final String LOCALE_CHANGE_EVENT = BuildConfig.APPLICATION_ID + ".service.action.LOCALE_CHANGE_EVENT"; + + private DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private final IBinder binder = new KeySetBinder(); + + private static MasterSecret masterSecret; + + public KeyCachingService() {} + + public static synchronized boolean isLocked(Context context) { + boolean locked = masterSecret == null && (!TextSecurePreferences.isPasswordDisabled(context) || TextSecurePreferences.isScreenLockEnabled(context)); + + if (locked) { + Log.d(TAG, "Locked! PasswordDisabled: " + TextSecurePreferences.isPasswordDisabled(context) + ", ScreenLock: " + TextSecurePreferences.isScreenLockEnabled(context)); + } + + return locked; + } + + public static synchronized @Nullable MasterSecret getMasterSecret(Context context) { + if (masterSecret == null && (TextSecurePreferences.isPasswordDisabled(context) && !TextSecurePreferences.isScreenLockEnabled(context))) { + try { + return MasterSecretUtil.getMasterSecret(context, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + } catch (InvalidPassphraseException e) { + Log.w(TAG, e); + } + } + + return masterSecret; + } + + public static void onAppForegrounded(@NonNull Context context) { + ServiceUtil.getAlarmManager(context).cancel(buildExpirationPendingIntent(context)); + } + + public static void onAppBackgrounded(@NonNull Context context) { + startTimeoutIfAppropriate(context); + } + + @SuppressLint("StaticFieldLeak") + public void setMasterSecret(final MasterSecret masterSecret) { + synchronized (KeyCachingService.class) { + KeyCachingService.masterSecret = masterSecret; + + foregroundService(); + broadcastNewSecret(); + startTimeoutIfAppropriate(this); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + if (!ApplicationMigrations.isUpdate(KeyCachingService.this)) { + ApplicationDependencies.getMessageNotifier().updateNotification(KeyCachingService.this); + } + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) return START_NOT_STICKY; + Log.d(TAG, "onStartCommand, " + intent.getAction()); + + if (intent.getAction() != null) { + switch (intent.getAction()) { + case CLEAR_KEY_ACTION: handleClearKey(); break; + case PASSPHRASE_EXPIRED_EVENT: handleClearKey(); break; + case DISABLE_ACTION: handleDisableService(); break; + case LOCALE_CHANGE_EVENT: handleLocaleChanged(); break; + case LOCK_TOGGLED_EVENT: handleLockToggled(); break; + } + } + + return START_NOT_STICKY; + } + + @Override + public void onCreate() { + Log.i(TAG, "onCreate()"); + super.onCreate(); + + if (TextSecurePreferences.isPasswordDisabled(this) && !TextSecurePreferences.isScreenLockEnabled(this)) { + try { + MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + setMasterSecret(masterSecret); + } catch (InvalidPassphraseException e) { + Log.w(TAG, e); + } + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.w(TAG, "KCS Is Being Destroyed!"); + handleClearKey(); + } + + /** + * Workaround for Android bug: + * https://code.google.com/p/android/issues/detail?id=53313 + */ + @Override + public void onTaskRemoved(Intent rootIntent) { + Intent intent = new Intent(this, DummyActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + + @SuppressLint("StaticFieldLeak") + private void handleClearKey() { + Log.i(TAG, "handleClearKey()"); + KeyCachingService.masterSecret = null; + stopForeground(true); + + Intent intent = new Intent(CLEAR_KEY_EVENT); + intent.setPackage(getApplicationContext().getPackageName()); + + sendBroadcast(intent, KEY_PERMISSION); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + ApplicationDependencies.getMessageNotifier().updateNotification(KeyCachingService.this); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void handleLockToggled() { + stopForeground(true); + + try { + MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE); + setMasterSecret(masterSecret); + } catch (InvalidPassphraseException e) { + Log.w(TAG, e); + } + } + + private void handleDisableService() { + if (TextSecurePreferences.isPasswordDisabled(this) && + !TextSecurePreferences.isScreenLockEnabled(this)) + { + stopForeground(true); + } + } + + private void handleLocaleChanged() { + dynamicLanguage.updateServiceLocale(this); + foregroundService(); + } + + private static void startTimeoutIfAppropriate(@NonNull Context context) { + boolean appVisible = ApplicationDependencies.getAppForegroundObserver().isForegrounded(); + boolean secretSet = KeyCachingService.masterSecret != null; + + boolean timeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(context); + boolean passLockActive = timeoutEnabled && !TextSecurePreferences.isPasswordDisabled(context); + + long screenTimeout = TextSecurePreferences.getScreenLockTimeout(context); + boolean screenLockActive = screenTimeout >= 60 && TextSecurePreferences.isScreenLockEnabled(context); + + if (!appVisible && secretSet && (passLockActive || screenLockActive)) { + long passphraseTimeoutMinutes = TextSecurePreferences.getPassphraseTimeoutInterval(context); + long screenLockTimeoutSeconds = TextSecurePreferences.getScreenLockTimeout(context); + + long timeoutMillis; + + if (!TextSecurePreferences.isPasswordDisabled(context)) timeoutMillis = TimeUnit.MINUTES.toMillis(passphraseTimeoutMinutes); + else timeoutMillis = TimeUnit.SECONDS.toMillis(screenLockTimeoutSeconds); + + Log.i(TAG, "Starting timeout: " + timeoutMillis); + + AlarmManager alarmManager = ServiceUtil.getAlarmManager(context); + PendingIntent expirationIntent = buildExpirationPendingIntent(context); + + alarmManager.cancel(expirationIntent); + alarmManager.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + timeoutMillis, expirationIntent); + } + } + + private void foregroundService() { + if (TextSecurePreferences.isPasswordDisabled(this) && !TextSecurePreferences.isScreenLockEnabled(this)) { + stopForeground(true); + return; + } + + Log.i(TAG, "foregrounding KCS"); + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NotificationChannels.LOCKED_STATUS); + + builder.setContentTitle(getString(R.string.KeyCachingService_passphrase_cached)); + builder.setContentText(getString(R.string.KeyCachingService_signal_passphrase_cached)); + builder.setSmallIcon(R.drawable.icon_cached); + builder.setWhen(0); + builder.setPriority(Notification.PRIORITY_MIN); + + builder.addAction(R.drawable.ic_menu_lock_dark, getString(R.string.KeyCachingService_lock), buildLockIntent()); + builder.setContentIntent(buildLaunchIntent()); + + stopForeground(true); + startForeground(SERVICE_RUNNING_ID, builder.build()); + } + + private void broadcastNewSecret() { + Log.i(TAG, "Broadcasting new secret..."); + + Intent intent = new Intent(NEW_KEY_EVENT); + intent.setPackage(getApplicationContext().getPackageName()); + + sendBroadcast(intent, KEY_PERMISSION); + } + + private PendingIntent buildLockIntent() { + Intent intent = new Intent(this, KeyCachingService.class); + intent.setAction(PASSPHRASE_EXPIRED_EVENT); + return PendingIntent.getService(getApplicationContext(), 0, intent, 0); + } + + private PendingIntent buildLaunchIntent() { + // TODO [greyson] Navigation + return PendingIntent.getActivity(getApplicationContext(), 0, MainActivity.clearTop(this), 0); + } + + private static PendingIntent buildExpirationPendingIntent(@NonNull Context context) { + Intent expirationIntent = new Intent(PASSPHRASE_EXPIRED_EVENT, null, context, KeyCachingService.class); + return PendingIntent.getService(context, 0, expirationIntent, 0); + } + + @Override + public IBinder onBind(Intent arg0) { + return binder; + } + + public class KeySetBinder extends Binder { + public KeyCachingService getService() { + return KeyCachingService.this; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java new file mode 100644 index 00000000..a7587c7d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class LocalBackupListener extends PersistentAlarmManagerListener { + + private static final long INTERVAL = TimeUnit.DAYS.toMillis(1); + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getNextBackupTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + if (TextSecurePreferences.isBackupEnabled(context)) { + LocalBackupJob.enqueue(false); + } + + return setNextBackupTimeToIntervalFromNow(context); + } + + public static void schedule(Context context) { + if (TextSecurePreferences.isBackupEnabled(context)) { + new LocalBackupListener().onReceive(context, new Intent()); + } + } + + public static long setNextBackupTimeToIntervalFromNow(@NonNull Context context) { + long nextTime = System.currentTimeMillis() + INTERVAL; + TextSecurePreferences.setNextBackupTime(context, nextTime); + + return nextTime; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/MmsListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/MmsListener.java new file mode 100644 index 00000000..f3ad7bd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/MmsListener.java @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.provider.Telephony; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MmsReceiveJob; +import org.thoughtcrime.securesms.util.Util; + +public class MmsListener extends BroadcastReceiver { + + private static final String TAG = MmsListener.class.getSimpleName(); + + private boolean isRelevant(Context context, Intent intent) { + if (!ApplicationMigrationService.isDatabaseImported(context)) { + return false; + } + + if (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction()) && Util.isDefaultSmsProvider(context)) { + return false; + } + + return false; + } + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Got MMS broadcast..." + intent.getAction()); + + if ((Telephony.Sms.Intents.WAP_PUSH_DELIVER_ACTION.equals(intent.getAction()) && + Util.isDefaultSmsProvider(context)) || + (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction()) && + isRelevant(context, intent))) + { + Log.i(TAG, "Relevant!"); + int subscriptionId = intent.getExtras().getInt("subscription", -1); + + ApplicationDependencies.getJobManager().add(new MmsReceiveJob(intent.getByteArrayExtra("data"), subscriptionId)); + + abortBroadcast(); + } + } + + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java b/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java new file mode 100644 index 00000000..566964b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.concurrent.atomic.AtomicReference; + +public final class NotificationController implements AutoCloseable, + ServiceConnection +{ + private static final String TAG = Log.tag(NotificationController.class); + + private final Context context; + private final int id; + + private int progress; + private int progressMax; + private boolean indeterminate; + private long percent = -1; + + private final AtomicReference service = new AtomicReference<>(); + + NotificationController(@NonNull Context context, int id) { + this.context = context; + this.id = id; + + bindToService(); + } + + private void bindToService() { + context.bindService(new Intent(context, GenericForegroundService.class), this, Context.BIND_AUTO_CREATE); + } + + public int getId() { + return id; + } + + @Override + public void close() { + context.unbindService(this); + GenericForegroundService.stopForegroundTask(context, id); + } + + public void setIndeterminateProgress() { + setProgress(0, 0, true); + } + + public void setProgress(long newProgressMax, long newProgress) { + setProgress((int) newProgressMax, (int) newProgress, false); + } + + private synchronized void setProgress(int newProgressMax, int newProgress, boolean indeterminant) { + int newPercent = newProgressMax != 0 ? 100 * newProgress / newProgressMax : -1; + + boolean same = newPercent == percent && indeterminate == indeterminant; + + percent = newPercent; + progress = newProgress; + progressMax = newProgressMax; + indeterminate = indeterminant; + + if (same) return; + + updateProgressOnService(); + } + + private synchronized void updateProgressOnService() { + GenericForegroundService genericForegroundService = service.get(); + + if (genericForegroundService == null) return; + + genericForegroundService.replaceProgress(id, progressMax, progress, indeterminate); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.i(TAG, "Service connected " + name); + + GenericForegroundService.LocalBinder binder = (GenericForegroundService.LocalBinder) service; + GenericForegroundService genericForegroundService = binder.getService(); + + this.service.set(genericForegroundService); + + updateProgressOnService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.i(TAG, "Service disconnected " + name); + + service.set(null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java new file mode 100644 index 00000000..a3b9aee4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PanicResponderListener.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Respond to a PanicKit trigger Intent by locking the app. PanicKit provides a + * common framework for creating "panic button" apps that can trigger actions + * in "panic responder" apps. In this case, the response is to lock the app, + * if it has been configured to do so via the Signal lock preference. If the + * user has not set a passphrase, then the panic trigger intent does nothing. + */ +public class PanicResponderListener extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && !TextSecurePreferences.isPasswordDisabled(context) && + "info.guardianproject.panic.action.TRIGGER".equals(intent.getAction())) + { + Intent lockIntent = new Intent(context, KeyCachingService.class); + lockIntent.setAction(KeyCachingService.CLEAR_KEY_ACTION); + context.startService(lockIntent); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java new file mode 100644 index 00000000..5aec76f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.service; + + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.signal.core.util.logging.Log; + +public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { + + private static final String TAG = PersistentAlarmManagerListener.class.getSimpleName(); + + protected abstract long getNextScheduledExecutionTime(Context context); + protected abstract long onAlarm(Context context, long scheduledTime); + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, String.format("%s#onReceive(%s)", getClass().getSimpleName(), intent.getAction())); + + long scheduledTime = getNextScheduledExecutionTime(context); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent alarmIntent = new Intent(context, getClass()); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, 0); + + if (System.currentTimeMillis() >= scheduledTime) { + scheduledTime = onAlarm(context, scheduledTime); + } + + Log.i(TAG, getClass() + " scheduling for: " + scheduledTime + " action: " + intent.getAction()); + + alarmManager.cancel(pendingIntent); + alarmManager.set(AlarmManager.RTC_WAKEUP, scheduledTime, pendingIntent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentConnectionBootListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentConnectionBootListener.java new file mode 100644 index 00000000..feeefb7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentConnectionBootListener.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.signal.core.util.logging.Log; + + +public class PersistentConnectionBootListener extends BroadcastReceiver { + + private static final String TAG = PersistentConnectionBootListener.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + Log.i(TAG, "Received boot event. Application should be started, allowing non-GCM devices to start a foreground service."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java new file mode 100644 index 00000000..42383b9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.service; + +import android.app.IntentService; +import android.content.Intent; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.widget.Toast; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.Rfc5724Uri; + +import java.net.URISyntaxException; +import java.net.URLDecoder; + +public class QuickResponseService extends IntentService { + + private static final String TAG = QuickResponseService.class.getSimpleName(); + + public QuickResponseService() { + super("QuickResponseService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(intent.getAction())) { + Log.w(TAG, "Received unknown intent: " + intent.getAction()); + return; + } + + if (KeyCachingService.isLocked(this)) { + Log.w(TAG, "Got quick response request when locked..."); + Toast.makeText(this, R.string.QuickResponseService_quick_response_unavailable_when_Signal_is_locked, Toast.LENGTH_LONG).show(); + return; + } + + try { + Rfc5724Uri uri = new Rfc5724Uri(intent.getDataString()); + String content = intent.getStringExtra(Intent.EXTRA_TEXT); + String number = uri.getPath(); + + if (number.contains("%")){ + number = URLDecoder.decode(number); + } + + Recipient recipient = Recipient.external(this, number); + int subscriptionId = recipient.getDefaultSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + + if (!TextUtils.isEmpty(content)) { + MessageSender.send(this, new OutgoingTextMessage(recipient, content, expiresIn, subscriptionId), -1, false, null); + } + } catch (URISyntaxException e) { + Toast.makeText(this, R.string.QuickResponseService_problem_sending_message, Toast.LENGTH_LONG).show(); + Log.w(TAG, e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java new file mode 100644 index 00000000..9f8c0805 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RotateCertificateJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class RotateSenderCertificateListener extends PersistentAlarmManagerListener { + + private static final long INTERVAL = TimeUnit.DAYS.toMillis(1); + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getUnidentifiedAccessCertificateRotationTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + ApplicationDependencies.getJobManager().add(new RotateCertificateJob()); + + long nextTime = System.currentTimeMillis() + INTERVAL; + TextSecurePreferences.setUnidentifiedAccessCertificateRotationTime(context, nextTime); + + return nextTime; + } + + public static void schedule(Context context) { + new RotateSenderCertificateListener().onReceive(context, new Intent()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/RotateSignedPreKeyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSignedPreKeyListener.java new file mode 100644 index 00000000..4d2ef7f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSignedPreKeyListener.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RotateSignedPreKeyJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class RotateSignedPreKeyListener extends PersistentAlarmManagerListener { + + private static final long INTERVAL = TimeUnit.DAYS.toMillis(2); + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getSignedPreKeyRotationTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + if (scheduledTime != 0 && TextSecurePreferences.isPushRegistered(context)) { + ApplicationDependencies.getJobManager().add(new RotateSignedPreKeyJob()); + } + + long nextTime = System.currentTimeMillis() + INTERVAL; + TextSecurePreferences.setSignedPreKeyRotationTime(context, nextTime); + + return nextTime; + } + + public static void schedule(Context context) { + new RotateSignedPreKeyListener().onReceive(context, new Intent()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/SmsDeliveryListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/SmsDeliveryListener.java new file mode 100644 index 00000000..c4429b21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/SmsDeliveryListener.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.telephony.SmsMessage; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.SmsSentJob; + +public class SmsDeliveryListener extends BroadcastReceiver { + + private static final String TAG = SmsDeliveryListener.class.getSimpleName(); + + public static final String SENT_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.SENT_SMS_ACTION"; + public static final String DELIVERED_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.DELIVERED_SMS_ACTION"; + + @Override + public void onReceive(Context context, Intent intent) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + long messageId = intent.getLongExtra("message_id", -1); + int runAttempt = intent.getIntExtra("run_attempt", 0); + + switch (intent.getAction()) { + case SENT_SMS_ACTION: + int result = getResultCode(); + + jobManager.add(new SmsSentJob(messageId, SENT_SMS_ACTION, result, runAttempt)); + break; + case DELIVERED_SMS_ACTION: + byte[] pdu = intent.getByteArrayExtra("pdu"); + + if (pdu == null) { + Log.w(TAG, "No PDU in delivery receipt!"); + break; + } + + SmsMessage message = SmsMessage.createFromPdu(pdu); + + if (message == null) { + Log.w(TAG, "Delivery receipt failed to parse!"); + break; + } + + int status = message.getStatus(); + + Log.i(TAG, "Original status: " + status); + + // Note: https://developer.android.com/reference/android/telephony/SmsMessage.html#getStatus() + // " CDMA: For not interfering with status codes from GSM, the value is shifted to the bits 31-16" + // Note: https://stackoverflow.com/a/33240109 + if ("3gpp2".equals(intent.getStringExtra("format"))) { + Log.w(TAG, "Correcting for CDMA delivery receipt..."); + if (status >> 24 <= 0) status = SmsDatabase.Status.STATUS_COMPLETE; + else if (status >> 24 == 2) status = SmsDatabase.Status.STATUS_PENDING; + else if (status >> 24 == 3) status = SmsDatabase.Status.STATUS_FAILED; + } + + jobManager.add(new SmsSentJob(messageId, DELIVERED_SMS_ACTION, status, runAttempt)); + break; + default: + Log.w(TAG, "Unknown action: " + intent.getAction()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/SmsListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/SmsListener.java new file mode 100644 index 00000000..0a0a354f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/SmsListener.java @@ -0,0 +1,115 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.provider.Telephony; +import android.telephony.SmsMessage; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.SmsReceiveJob; +import org.thoughtcrime.securesms.util.Util; + +public class SmsListener extends BroadcastReceiver { + + private static final String TAG = Log.tag(SmsListener.class); + + private static final String SMS_RECEIVED_ACTION = Telephony.Sms.Intents.SMS_RECEIVED_ACTION; + private static final String SMS_DELIVERED_ACTION = Telephony.Sms.Intents.SMS_DELIVER_ACTION; + + private boolean isExemption(SmsMessage message, String messageBody) { + + // ignore CLASS0 ("flash") messages + if (message.getMessageClass() == SmsMessage.MessageClass.CLASS_0) + return true; + + // ignore OTP messages from Sparebank1 (Norwegian bank) + if (messageBody.startsWith("Sparebank1://otp?")) { + return true; + } + + return + message.getOriginatingAddress().length() < 7 && + (messageBody.toUpperCase().startsWith("//ANDROID:") || // Sprint Visual Voicemail + messageBody.startsWith("//BREW:")); //BREW stands for “Binary Runtime Environment for Wireless" + } + + private SmsMessage getSmsMessageFromIntent(Intent intent) { + Bundle bundle = intent.getExtras(); + Object[] pdus = (Object[])bundle.get("pdus"); + + if (pdus == null || pdus.length == 0) + return null; + + return SmsMessage.createFromPdu((byte[])pdus[0]); + } + + private String getSmsMessageBodyFromIntent(Intent intent) { + Bundle bundle = intent.getExtras(); + Object[] pdus = (Object[])bundle.get("pdus"); + StringBuilder bodyBuilder = new StringBuilder(); + + if (pdus == null) + return null; + + for (Object pdu : pdus) + bodyBuilder.append(SmsMessage.createFromPdu((byte[])pdu).getDisplayMessageBody()); + + return bodyBuilder.toString(); + } + + private boolean isRelevant(Context context, Intent intent) { + SmsMessage message = getSmsMessageFromIntent(intent); + String messageBody = getSmsMessageBodyFromIntent(intent); + + if (message == null && messageBody == null) + return false; + + if (isExemption(message, messageBody)) + return false; + + if (!ApplicationMigrationService.isDatabaseImported(context)) + return false; + + if (SMS_RECEIVED_ACTION.equals(intent.getAction()) && Util.isDefaultSmsProvider(context)) { + return false; + } + + return false; + } + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Got SMS broadcast..."); + + if ((intent.getAction().equals(SMS_DELIVERED_ACTION)) || + (intent.getAction().equals(SMS_RECEIVED_ACTION)) && isRelevant(context, intent)) + { + Log.i(TAG, "Constructing SmsReceiveJob..."); + Object[] pdus = (Object[]) intent.getExtras().get("pdus"); + int subscriptionId = intent.getExtras().getInt("subscription", -1); + + ApplicationDependencies.getJobManager().add(new SmsReceiveJob(pdus, subscriptionId)); + + abortBroadcast(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java new file mode 100644 index 00000000..ab7bbc31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.service; + +import android.app.AlarmManager; +import android.app.Application; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.util.ServiceUtil; + +/** + * Class to help manage scheduling events to happen in the future, whether the app is open or not. + */ +public abstract class TimedEventManager { + + private final Application application; + private final Handler handler; + + public TimedEventManager(@NonNull Application application, @NonNull String threadName) { + HandlerThread handlerThread = new HandlerThread(threadName); + handlerThread.start(); + + this.application = application; + this.handler = new Handler(handlerThread.getLooper()); + } + + /** + * Should be called whenever the underlying data of events has changed. Will appropriately + * schedule new event executions. + */ + public void scheduleIfNecessary() { + handler.removeCallbacksAndMessages(null); + + handler.post(() -> { + E event = getNextClosestEvent(); + + if (event != null) { + long delay = getDelayForEvent(event); + + handler.postDelayed(() -> { + executeEvent(event); + scheduleIfNecessary(); + }, delay); + + scheduleAlarm(application, delay); + } + }); + } + + /** + * @return The next event that should be executed, or {@code null} if there are no events to execute. + */ + @WorkerThread + protected @Nullable abstract E getNextClosestEvent(); + + /** + * Execute the provided event. + */ + @WorkerThread + protected abstract void executeEvent(@NonNull E event); + + /** + * @return How long before the provided event should be executed. + */ + @WorkerThread + protected abstract long getDelayForEvent(@NonNull E event); + + /** + * Schedules an alarm to call {@link #scheduleIfNecessary()} after the specified delay. You can + * use {@link #setAlarm(Context, long, Class)} as a helper method. + */ + @AnyThread + protected abstract void scheduleAlarm(@NonNull Application application, long delay); + + /** + * Helper method to set an alarm. + */ + protected static void setAlarm(@NonNull Context context, long delay, @NonNull Class alarmClass) { + Intent intent = new Intent(context, alarmClass); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + AlarmManager alarmManager = ServiceUtil.getAlarmManager(context); + + alarmManager.cancel(pendingIntent); + alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, pendingIntent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TrimThreadsByDateManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/TrimThreadsByDateManager.java new file mode 100644 index 00000000..7c4e5c84 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/TrimThreadsByDateManager.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public class TrimThreadsByDateManager extends TimedEventManager { + + private static final String TAG = Log.tag(TrimThreadsByDateManager.class); + + private final ThreadDatabase threadDatabase; + private final MmsSmsDatabase mmsSmsDatabase; + + public TrimThreadsByDateManager(@NonNull Application application) { + super(application, "TrimThreadsByDateManager"); + + threadDatabase = DatabaseFactory.getThreadDatabase(application); + mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(application); + + scheduleIfNecessary(); + } + + @Override + protected @Nullable TrimEvent getNextClosestEvent() { + KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); + if (keepMessagesDuration == KeepMessagesDuration.FOREVER) { + return null; + } + + long trimBeforeDate = System.currentTimeMillis() - keepMessagesDuration.getDuration(); + + if (mmsSmsDatabase.getMessageCountBeforeDate(trimBeforeDate) > 0) { + Log.i(TAG, "Messages exist before date, trim immediately"); + return new TrimEvent(0); + } + + long timestamp = mmsSmsDatabase.getTimestampForFirstMessageAfterDate(trimBeforeDate); + + if (timestamp == 0) { + return null; + } + + return new TrimEvent(Math.max(0, keepMessagesDuration.getDuration() - (System.currentTimeMillis() - timestamp))); + } + + @Override + protected void executeEvent(@NonNull TrimEvent event) { + KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); + + int trimLength = SignalStore.settings().isTrimByLengthEnabled() ? SignalStore.settings().getThreadTrimLength() + : ThreadDatabase.NO_TRIM_MESSAGE_COUNT_SET; + + long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration() + : ThreadDatabase.NO_TRIM_BEFORE_DATE_SET; + + Log.i(TAG, "Trimming all threads with length: " + trimLength + " before: " + trimBeforeDate); + threadDatabase.trimAllThreads(trimLength, trimBeforeDate); + } + + @Override + protected long getDelayForEvent(@NonNull TrimEvent event) { + return event.delay; + } + + @Override + protected void scheduleAlarm(@NonNull Application application, long delay) { + setAlarm(application, delay, TrimThreadsByDateAlarm.class); + } + + public static class TrimThreadsByDateAlarm extends BroadcastReceiver { + + private static final String TAG = Log.tag(TrimThreadsByDateAlarm.class); + + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + Log.d(TAG, "onReceive()"); + ApplicationDependencies.getTrimThreadsByDateManager().scheduleIfNecessary(); + } + } + + public static class TrimEvent { + final long delay; + + public TrimEvent(long delay) { + this.delay = delay; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java new file mode 100644 index 00000000..3cbd1140 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.service; + + +import android.app.DownloadManager; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.FileProviderUtil; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; + +public class UpdateApkReadyListener extends BroadcastReceiver { + + private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "onReceive()"); + + if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { + long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2); + + if (downloadId == TextSecurePreferences.getUpdateApkDownloadId(context)) { + Uri uri = getLocalUriForDownloadId(context, downloadId); + String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); + + if (uri == null) { + Log.w(TAG, "Downloaded local URI is null?"); + return; + } + + if (isMatchingDigest(context, downloadId, encodedDigest)) { + displayInstallNotification(context, uri); + } else { + Log.w(TAG, "Downloaded APK doesn't match digest..."); + } + } + } + } + + private void displayInstallNotification(Context context, Uri uri) { + Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setData(uri); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + Notification notification = new NotificationCompat.Builder(context, NotificationChannels.APP_UPDATES) + .setOngoing(true) + .setContentTitle(context.getString(R.string.UpdateApkReadyListener_Signal_update)) + .setContentText(context.getString(R.string.UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update)) + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getResources().getColor(R.color.core_ultramarine)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setContentIntent(pendingIntent) + .build(); + + ServiceUtil.getNotificationManager(context).notify(666, notification); + } + + private @Nullable Uri getLocalUriForDownloadId(Context context, long downloadId) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Query query = new DownloadManager.Query(); + query.setFilterById(downloadId); + + Cursor cursor = downloadManager.query(query); + + try { + if (cursor != null && cursor.moveToFirst()) { + String localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); + + if (localUri != null) { + File localFile = new File(Uri.parse(localUri).getPath()); + return FileProviderUtil.getUriFor(context, localFile); + } + } + } finally { + if (cursor != null) cursor.close(); + } + + return null; + } + + private boolean isMatchingDigest(Context context, long downloadId, String theirEncodedDigest) { + try { + if (theirEncodedDigest == null) return false; + + byte[] theirDigest = Hex.fromStringCondensed(theirEncodedDigest); + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); + byte[] ourDigest = FileUtils.getFileDigest(fin); + + fin.close(); + + return MessageDigest.isEqual(ourDigest, theirDigest); + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java new file mode 100644 index 00000000..b125b1c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.UpdateApkJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { + + private static final String TAG = UpdateApkRefreshListener.class.getSimpleName(); + + private static final long INTERVAL = TimeUnit.HOURS.toMillis(6); + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getUpdateApkRefreshTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + Log.i(TAG, "onAlarm..."); + + if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) { + Log.i(TAG, "Queueing APK update job..."); + ApplicationDependencies.getJobManager().add(new UpdateApkJob()); + } + + long newTime = System.currentTimeMillis() + INTERVAL; + TextSecurePreferences.setUpdateApkRefreshTime(context, newTime); + + return newTime; + } + + public static void schedule(Context context) { + new UpdateApkRefreshListener().onReceive(context, new Intent()); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/VerificationCodeParser.java b/app/src/main/java/org/thoughtcrime/securesms/service/VerificationCodeParser.java new file mode 100644 index 00000000..22961460 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/VerificationCodeParser.java @@ -0,0 +1,43 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.service; + + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class VerificationCodeParser { + + private static final Pattern CHALLENGE_PATTERN = Pattern.compile("(.*\\D|^)([0-9]{3,4})-([0-9]{3,4}).*", Pattern.DOTALL); + + public static Optional parse(String messageBody) { + if (messageBody == null) { + return Optional.absent(); + } + + Matcher challengeMatcher = CHALLENGE_PATTERN.matcher(messageBody); + + if (!challengeMatcher.matches()) { + return Optional.absent(); + } + + return Optional.of(challengeMatcher.group(challengeMatcher.groupCount() - 1) + + challengeMatcher.group(challengeMatcher.groupCount())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java new file mode 100644 index 00000000..dcb375d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -0,0 +1,1147 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.IBinder; +import android.os.ResultReceiver; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallId; +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.CallManager.CallEvent; +import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.HttpHeader; +import org.signal.ringrtc.Remote; +import org.signal.storageservice.protos.groups.GroupExternalCredential; +import org.signal.zkgroup.VerificationFailedException; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; +import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.GroupCallPeekEvent; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.ringrtc.CallState; +import org.thoughtcrime.securesms.ringrtc.CameraEventListener; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.ringrtc.TurnServerInfoParcel; +import org.thoughtcrime.securesms.service.webrtc.IdleActionProcessor; +import org.thoughtcrime.securesms.service.webrtc.WebRtcInteractor; +import org.thoughtcrime.securesms.service.webrtc.WebRtcUtil; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.FutureTaskListener; +import org.thoughtcrime.securesms.util.ListenableFutureTask; +import org.thoughtcrime.securesms.util.TelephonyUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; +import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager; +import org.thoughtcrime.securesms.webrtc.audio.BluetoothStateManager; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.calls.CallingResponse; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class WebRtcCallService extends Service implements CallManager.Observer, + BluetoothStateManager.BluetoothStateListener, + CameraEventListener, + GroupCall.Observer +{ + + private static final String TAG = WebRtcCallService.class.getSimpleName(); + + public static final String EXTRA_MUTE = "mute_value"; + public static final String EXTRA_AVAILABLE = "enabled_value"; + public static final String EXTRA_SERVER_RECEIVED_TIMESTAMP = "server_received_timestamp"; + public static final String EXTRA_SERVER_DELIVERED_TIMESTAMP = "server_delivered_timestamp"; + public static final String EXTRA_CALL_ID = "call_id"; + public static final String EXTRA_RESULT_RECEIVER = "result_receiver"; + public static final String EXTRA_SPEAKER = "audio_speaker"; + public static final String EXTRA_BLUETOOTH = "audio_bluetooth"; + public static final String EXTRA_REMOTE_PEER = "remote_peer"; + public static final String EXTRA_REMOTE_PEER_KEY = "remote_peer_key"; + public static final String EXTRA_REMOTE_DEVICE = "remote_device"; + public static final String EXTRA_REMOTE_IDENTITY_KEY = "remote_identity_key"; + public static final String EXTRA_OFFER_OPAQUE = "offer_opaque"; + public static final String EXTRA_OFFER_SDP = "offer_sdp"; + public static final String EXTRA_OFFER_TYPE = "offer_type"; + public static final String EXTRA_MULTI_RING = "multi_ring"; + public static final String EXTRA_HANGUP_TYPE = "hangup_type"; + public static final String EXTRA_HANGUP_IS_LEGACY = "hangup_is_legacy"; + public static final String EXTRA_HANGUP_DEVICE_ID = "hangup_device_id"; + public static final String EXTRA_ANSWER_OPAQUE = "answer_opaque"; + public static final String EXTRA_ANSWER_SDP = "answer_sdp"; + public static final String EXTRA_ICE_CANDIDATES = "ice_candidates"; + public static final String EXTRA_ENABLE = "enable_value"; + public static final String EXTRA_BROADCAST = "broadcast"; + public static final String EXTRA_ANSWER_WITH_VIDEO = "enable_video"; + public static final String EXTRA_ERROR_CALL_STATE = "error_call_state"; + public static final String EXTRA_ERROR_IDENTITY_KEY = "remote_identity_key"; + public static final String EXTRA_CAMERA_STATE = "camera_state"; + public static final String EXTRA_IS_ALWAYS_TURN = "is_always_turn"; + public static final String EXTRA_TURN_SERVER_INFO = "turn_server_info"; + public static final String EXTRA_GROUP_EXTERNAL_TOKEN = "group_external_token"; + public static final String EXTRA_HTTP_REQUEST_ID = "http_request_id"; + public static final String EXTRA_HTTP_RESPONSE_STATUS = "http_response_status"; + public static final String EXTRA_HTTP_RESPONSE_BODY = "http_response_body"; + public static final String EXTRA_OPAQUE_MESSAGE = "opaque"; + public static final String EXTRA_UUID = "uuid"; + public static final String EXTRA_MESSAGE_AGE_SECONDS = "message_age_seconds"; + public static final String EXTRA_GROUP_CALL_END_REASON = "group_call_end_reason"; + public static final String EXTRA_GROUP_CALL_HASH = "group_call_hash"; + public static final String EXTRA_GROUP_CALL_UPDATE_SENDER = "group_call_update_sender"; + public static final String EXTRA_GROUP_CALL_UPDATE_GROUP = "group_call_update_group"; + public static final String EXTRA_GROUP_CALL_ERA_ID = "era_id"; + public static final String EXTRA_RECIPIENT_IDS = "recipient_ids"; + public static final String EXTRA_ORIENTATION_DEGREES = "orientation_degrees"; + + public static final String ACTION_PRE_JOIN_CALL = "CALL_PRE_JOIN"; + public static final String ACTION_CANCEL_PRE_JOIN_CALL = "CANCEL_PRE_JOIN_CALL"; + public static final String ACTION_OUTGOING_CALL = "CALL_OUTGOING"; + public static final String ACTION_DENY_CALL = "DENY_CALL"; + public static final String ACTION_LOCAL_HANGUP = "LOCAL_HANGUP"; + public static final String ACTION_SET_MUTE_AUDIO = "SET_MUTE_AUDIO"; + public static final String ACTION_FLIP_CAMERA = "FLIP_CAMERA"; + public static final String ACTION_BLUETOOTH_CHANGE = "BLUETOOTH_CHANGE"; + public static final String ACTION_NETWORK_CHANGE = "NETWORK_CHANGE"; + public static final String ACTION_BANDWIDTH_MODE_UPDATE = "BANDWIDTH_MODE_UPDATE"; + public static final String ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE"; + public static final String ACTION_SCREEN_OFF = "SCREEN_OFF"; + public static final String ACTION_IS_IN_CALL_QUERY = "IS_IN_CALL"; + public static final String ACTION_SET_AUDIO_SPEAKER = "SET_AUDIO_SPEAKER"; + public static final String ACTION_SET_AUDIO_BLUETOOTH = "SET_AUDIO_BLUETOOTH"; + public static final String ACTION_CALL_CONNECTED = "CALL_CONNECTED"; + public static final String ACTION_START_OUTGOING_CALL = "START_OUTGOING_CALL"; + public static final String ACTION_START_INCOMING_CALL = "START_INCOMING_CALL"; + public static final String ACTION_LOCAL_RINGING = "LOCAL_RINGING"; + public static final String ACTION_REMOTE_RINGING = "REMOTE_RINGING"; + public static final String ACTION_ACCEPT_CALL = "ACCEPT_CALL"; + public static final String ACTION_SEND_OFFER = "SEND_OFFER"; + public static final String ACTION_SEND_ANSWER = "SEND_ANSWER"; + public static final String ACTION_SEND_ICE_CANDIDATES = "SEND_ICE_CANDIDATES"; + public static final String ACTION_SEND_HANGUP = "SEND_HANGUP"; + public static final String ACTION_SEND_BUSY = "SEND_BUSY"; + public static final String ACTION_RECEIVE_OFFER = "RECEIVE_OFFER"; + public static final String ACTION_RECEIVE_ANSWER = "RECEIVE_ANSWER"; + public static final String ACTION_RECEIVE_ICE_CANDIDATES = "RECEIVE_ICE_CANDIDATES"; + public static final String ACTION_RECEIVE_HANGUP = "RECEIVE_HANGUP"; + public static final String ACTION_RECEIVE_BUSY = "RECEIVE_BUSY"; + public static final String ACTION_REMOTE_VIDEO_ENABLE = "REMOTE_VIDEO_ENABLE"; + public static final String ACTION_SET_ENABLE_VIDEO = "SET_ENABLE_VIDEO"; + public static final String ACTION_ENDED_REMOTE_HANGUP = "ENDED_REMOTE_HANGUP"; + public static final String ACTION_ENDED_REMOTE_HANGUP_ACCEPTED = "ENDED_REMOTE_HANGUP_ACCEPTED"; + public static final String ACTION_ENDED_REMOTE_HANGUP_DECLINED = "ENDED_REMOTE_HANGUP_DECLINED"; + public static final String ACTION_ENDED_REMOTE_HANGUP_BUSY = "ENDED_REMOTE_HANGUP_BUSY"; + public static final String ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION = "ENDED_REMOTE_HANGUP_NEED_PERMISSION"; + public static final String ACTION_ENDED_REMOTE_BUSY = "ENDED_REMOTE_BUSY"; + public static final String ACTION_ENDED_REMOTE_GLARE = "ENDED_REMOTE_GLARE"; + public static final String ACTION_ENDED_TIMEOUT = "ENDED_TIMEOUT"; + public static final String ACTION_ENDED_INTERNAL_FAILURE = "ENDED_INTERNAL_FAILURE"; + public static final String ACTION_ENDED_SIGNALING_FAILURE = "ENDED_SIGNALING_FAILURE"; + public static final String ACTION_ENDED_CONNECTION_FAILURE = "ENDED_CONNECTION_FAILURE"; + public static final String ACTION_RECEIVED_OFFER_EXPIRED = "RECEIVED_OFFER_EXPIRED"; + public static final String ACTION_RECEIVED_OFFER_WHILE_ACTIVE = "RECEIVED_OFFER_WHILE_ACTIVE"; + public static final String ACTION_CALL_CONCLUDED = "CALL_CONCLUDED"; + public static final String ACTION_MESSAGE_SENT_SUCCESS = "MESSAGE_SENT_SUCCESS"; + public static final String ACTION_MESSAGE_SENT_ERROR = "MESSAGE_SENT_ERROR"; + public static final String ACTION_CAMERA_SWITCH_COMPLETED = "CAMERA_FLIP_COMPLETE"; + public static final String ACTION_TURN_SERVER_UPDATE = "TURN_SERVER_UPDATE"; + public static final String ACTION_SETUP_FAILURE = "SETUP_FAILURE"; + public static final String ACTION_HTTP_SUCCESS = "HTTP_SUCCESS"; + public static final String ACTION_HTTP_FAILURE = "HTTP_FAILURE"; + public static final String ACTION_SEND_OPAQUE_MESSAGE = "SEND_OPAQUE_MESSAGE"; + public static final String ACTION_RECEIVE_OPAQUE_MESSAGE = "RECEIVE_OPAQUE_MESSAGE"; + public static final String ACTION_ORIENTATION_CHANGED = "ORIENTATION_CHANGED"; + + public static final String ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED = "GROUP_LOCAL_DEVICE_CHANGE"; + public static final String ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED = "GROUP_REMOTE_DEVICE_CHANGE"; + public static final String ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED = "GROUP_JOINED_MEMBERS_CHANGE"; + public static final String ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF = "GROUP_REQUEST_MEMBERSHIP_PROOF"; + public static final String ACTION_GROUP_REQUEST_UPDATE_MEMBERS = "GROUP_REQUEST_UPDATE_MEMBERS"; + public static final String ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS = "GROUP_UPDATE_RENDERED_RESOLUTIONS"; + public static final String ACTION_GROUP_CALL_ENDED = "GROUP_CALL_ENDED"; + public static final String ACTION_GROUP_CALL_PEEK = "GROUP_CALL_PEEK"; + public static final String ACTION_GROUP_MESSAGE_SENT_ERROR = "GROUP_MESSAGE_SENT_ERROR"; + public static final String ACTION_GROUP_APPROVE_SAFETY_CHANGE = "GROUP_APPROVE_SAFETY_CHANGE"; + + public static final int BUSY_TONE_LENGTH = 2000; + + private SignalServiceMessageSender messageSender; + private SignalServiceAccountManager accountManager; + private BluetoothStateManager bluetoothStateManager; + private WiredHeadsetStateReceiver wiredHeadsetStateReceiver; + private NetworkReceiver networkReceiver; + private PowerButtonReceiver powerButtonReceiver; + private LockManager lockManager; + private UncaughtExceptionHandlerManager uncaughtExceptionHandlerManager; + private WebRtcInteractor webRtcInteractor; + + @Nullable private CallManager callManager; + + private final ExecutorService serviceExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService networkExecutor = Executors.newSingleThreadExecutor(); + + private final PhoneStateListener hangUpRtcOnDeviceCallAnswered = new HangUpRtcOnPstnCallAnsweredListener(); + + private WebRtcServiceState serviceState; + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "onCreate"); + + boolean successful = initializeResources(); + if (!successful) { + stopSelf(); + return; + } + + serviceState = new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + + registerUncaughtExceptionHandler(); + registerWiredHeadsetStateReceiver(); + registerNetworkReceiver(); + + TelephonyUtil.getManager(this) + .listen(hangUpRtcOnDeviceCallAnswered, PhoneStateListener.LISTEN_CALL_STATE); + } + + private boolean initializeResources() { + this.messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + this.lockManager = new LockManager(this); + this.bluetoothStateManager = new BluetoothStateManager(this, this); + + this.messageSender.setSoTimeoutMillis(TimeUnit.SECONDS.toMillis(10)); + this.accountManager.setSoTimeoutMillis(TimeUnit.SECONDS.toMillis(10)); + + try { + this.callManager = Objects.requireNonNull(CallManager.createCallManager(this)); + } catch (NullPointerException | CallException e) { + Log.e(TAG, "Unable to create Call Manager: ", e); + return false; + } + + webRtcInteractor = new WebRtcInteractor(this, + callManager, + lockManager, + new SignalAudioManager(this), + bluetoothStateManager, + this, + this); + return true; + } + + @Override + public int onStartCommand(final @Nullable Intent intent, int flags, int startId) { + Log.i(TAG, "onStartCommand... action: " + (intent == null ? "NA" : intent.getAction())); + if (intent == null || intent.getAction() == null) return START_NOT_STICKY; + + serviceExecutor.execute(() -> { + Log.d(TAG, "action: " + intent.getAction() + " action handler: " + serviceState.getActionProcessor().getTag()); + try { + WebRtcServiceState previous = serviceState; + serviceState = serviceState.getActionProcessor().processAction(intent.getAction(), intent, serviceState); + + if (previous != serviceState) { + if (serviceState.getCallInfoState().getCallState() != WebRtcViewModel.State.IDLE) { + sendMessage(); + } + } + } catch (AssertionError e) { + throw new AssertionError("Invalid state for action: " + intent.getAction() + " processor: " + serviceState.getActionProcessor().getTag(), e); + } + }); + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.i(TAG, "onDestroy"); + + if (callManager != null) { + try { + callManager.close(); + } catch (CallException e) { + Log.w(TAG, "Unable to close call manager: ", e); + } + callManager = null; + } + + if (uncaughtExceptionHandlerManager != null) { + uncaughtExceptionHandlerManager.unregister(); + } + + if (bluetoothStateManager != null) { + bluetoothStateManager.onDestroy(); + } + + if (wiredHeadsetStateReceiver != null) { + unregisterReceiver(wiredHeadsetStateReceiver); + wiredHeadsetStateReceiver = null; + } + + if (powerButtonReceiver != null) { + unregisterReceiver(powerButtonReceiver); + powerButtonReceiver = null; + } + + unregisterNetworkReceiver(); + + TelephonyUtil.getManager(this) + .listen(hangUpRtcOnDeviceCallAnswered, PhoneStateListener.LISTEN_NONE); + } + + @Override + public void onBluetoothStateChanged(boolean isAvailable) { + Log.i(TAG, "onBluetoothStateChanged: " + isAvailable); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(ACTION_BLUETOOTH_CHANGE); + intent.putExtra(EXTRA_AVAILABLE, isAvailable); + + startService(intent); + } + + @Override + public void onCameraSwitchCompleted(@NonNull CameraState newCameraState) { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(ACTION_CAMERA_SWITCH_COMPLETED) + .putExtra(EXTRA_CAMERA_STATE, newCameraState); + + startService(intent); + } + + private void registerUncaughtExceptionHandler() { + uncaughtExceptionHandlerManager = new UncaughtExceptionHandlerManager(); + uncaughtExceptionHandlerManager.registerHandler(new ProximityLockRelease(lockManager)); + } + + private void registerWiredHeadsetStateReceiver() { + wiredHeadsetStateReceiver = new WiredHeadsetStateReceiver(); + + String action; + + if (Build.VERSION.SDK_INT >= 21) { + action = AudioManager.ACTION_HEADSET_PLUG; + } else { + action = Intent.ACTION_HEADSET_PLUG; + } + + registerReceiver(wiredHeadsetStateReceiver, new IntentFilter(action)); + } + + private void registerNetworkReceiver() { + if (networkReceiver == null) { + networkReceiver = new NetworkReceiver(); + + registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + } + + private void unregisterNetworkReceiver() { + if (networkReceiver != null) { + unregisterReceiver(networkReceiver); + + networkReceiver = null; + } + } + + public void registerPowerButtonReceiver() { + if (powerButtonReceiver == null) { + powerButtonReceiver = new PowerButtonReceiver(); + + registerReceiver(powerButtonReceiver, new IntentFilter(Intent.ACTION_SCREEN_OFF)); + } + } + + public void unregisterPowerButtonReceiver() { + if (powerButtonReceiver != null) { + unregisterReceiver(powerButtonReceiver); + + powerButtonReceiver = null; + } + } + + public void insertMissedCall(@NonNull RemotePeer remotePeer, boolean signal, long timestamp, boolean isVideoOffer) { + Pair messageAndThreadId = DatabaseFactory.getSmsDatabase(this).insertMissedCall(remotePeer.getId(), timestamp, isVideoOffer); + ApplicationDependencies.getMessageNotifier().updateNotification(this, messageAndThreadId.second(), signal); + } + + public void retrieveTurnServers(@NonNull RemotePeer remotePeer) { + retrieveTurnServers().addListener(new FutureTaskListener() { + @Override + public void onSuccess(@Nullable TurnServerInfoParcel result) { + boolean isAlwaysTurn = TextSecurePreferences.isTurnOnly(WebRtcCallService.this); + + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_TURN_SERVER_UPDATE) + .putExtra(EXTRA_IS_ALWAYS_TURN, isAlwaysTurn) + .putExtra(EXTRA_TURN_SERVER_INFO, result); + + startService(intent); + } + + @Override + public void onFailure(@NonNull ExecutionException exception) { + Log.w(TAG, "Unable to retrieve turn servers: ", exception); + + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_SETUP_FAILURE) + .putExtra(EXTRA_CALL_ID, remotePeer.getCallId().longValue()); + + startService(intent); + } + }); + } + + public void setCallInProgressNotification(int type, @NonNull Recipient recipient) { + startForeground(CallNotificationBuilder.getNotificationId(type), + CallNotificationBuilder.getCallInProgressNotification(this, type, recipient)); + } + + public void sendMessage() { + sendMessage(serviceState); + } + + public void sendMessage(@NonNull WebRtcServiceState state) { + EventBus.getDefault().postSticky(new WebRtcViewModel(state)); + } + + private @NonNull ListenableFutureTask sendMessage(@NonNull final RemotePeer remotePeer, + @NonNull final SignalServiceCallMessage callMessage) + { + Callable callable = () -> { + Recipient recipient = remotePeer.getRecipient(); + if (recipient.isBlocked()) { + return true; + } + + messageSender.sendCallMessage(RecipientUtil.toSignalServiceAddress(WebRtcCallService.this, recipient), + UnidentifiedAccessUtil.getAccessFor(WebRtcCallService.this, recipient), + callMessage); + return true; + }; + + ListenableFutureTask listenableFutureTask = new ListenableFutureTask<>(callable, null, serviceExecutor); + networkExecutor.execute(listenableFutureTask); + + return listenableFutureTask; + } + + public void startCallCardActivityIfPossible() { + if (Build.VERSION.SDK_INT >= 29 && !ApplicationDependencies.getAppForegroundObserver().isForegrounded()) { + return; + } + + Intent activityIntent = new Intent(); + activityIntent.setClass(this, WebRtcCallActivity.class); + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(activityIntent); + } + + private static @NonNull OfferMessage.Type getOfferTypeFromCallMediaType(@NonNull CallManager.CallMediaType mediaType) { + return mediaType == CallManager.CallMediaType.VIDEO_CALL ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL; + } + + private static @NonNull HangupMessage.Type getHangupTypeFromCallHangupType(@NonNull CallManager.HangupType hangupType) { + switch (hangupType) { + case ACCEPTED: + return HangupMessage.Type.ACCEPTED; + case BUSY: + return HangupMessage.Type.BUSY; + case NORMAL: + return HangupMessage.Type.NORMAL; + case DECLINED: + return HangupMessage.Type.DECLINED; + case NEED_PERMISSION: + return HangupMessage.Type.NEED_PERMISSION; + default: + throw new IllegalArgumentException("Unexpected hangup type: " + hangupType); + } + } + + @Override + public @Nullable IBinder onBind(@NonNull Intent intent) { + return null; + } + + private static class WiredHeadsetStateReceiver extends BroadcastReceiver { + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + int state = intent.getIntExtra("state", -1); + + Intent serviceIntent = new Intent(context, WebRtcCallService.class); + serviceIntent.setAction(ACTION_WIRED_HEADSET_CHANGE); + serviceIntent.putExtra(WebRtcCallService.EXTRA_AVAILABLE, state != 0); + context.startService(serviceIntent); + } + } + + private static class NetworkReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + Intent serviceIntent = new Intent(context, WebRtcCallService.class); + + serviceIntent.setAction(ACTION_NETWORK_CHANGE); + serviceIntent.putExtra(EXTRA_AVAILABLE, activeNetworkInfo != null && activeNetworkInfo.isConnected()); + context.startService(serviceIntent); + + serviceIntent.setAction(ACTION_BANDWIDTH_MODE_UPDATE); + context.startService(serviceIntent); + } + } + + private static class PowerButtonReceiver extends BroadcastReceiver { + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + if (Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { + Intent serviceIntent = new Intent(context, WebRtcCallService.class); + serviceIntent.setAction(ACTION_SCREEN_OFF); + context.startService(serviceIntent); + } + } + } + + private static class ProximityLockRelease implements Thread.UncaughtExceptionHandler { + private final LockManager lockManager; + + private ProximityLockRelease(@NonNull LockManager lockManager) { + this.lockManager = lockManager; + } + + @Override + public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { + Log.i(TAG, "Uncaught exception - releasing proximity lock", throwable); + lockManager.updatePhoneState(LockManager.PhoneState.IDLE); + } + } + + public static void isCallActive(@NonNull Context context, @NonNull ResultReceiver resultReceiver) { + Intent intent = new Intent(context, WebRtcCallService.class); + intent.setAction(ACTION_IS_IN_CALL_QUERY); + intent.putExtra(EXTRA_RESULT_RECEIVER, resultReceiver); + + context.startService(intent); + } + + public static void notifyBandwidthModeUpdated(@NonNull Context context) { + Intent intent = new Intent(context, WebRtcCallService.class); + intent.setAction(ACTION_BANDWIDTH_MODE_UPDATE); + + context.startService(intent); + } + + private class HangUpRtcOnPstnCallAnsweredListener extends PhoneStateListener { + @Override + public void onCallStateChanged(int state, @NonNull String phoneNumber) { + super.onCallStateChanged(state, phoneNumber); + if (state == TelephonyManager.CALL_STATE_OFFHOOK) { + hangup(); + Log.i(TAG, "Device phone call ended Signal call."); + } + } + + private void hangup() { + if (serviceState != null && serviceState.getCallInfoState().getActivePeer() != null) { + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_LOCAL_HANGUP); + + startService(intent); + } + } + } + + private @NonNull ListenableFutureTask retrieveTurnServers() { + Callable callable = () -> new TurnServerInfoParcel(accountManager.getTurnServerInfo()); + + ListenableFutureTask futureTask = new ListenableFutureTask<>(callable, null, serviceExecutor); + networkExecutor.execute(futureTask); + + return futureTask; + } + + private abstract class StateAwareListener implements FutureTaskListener { + private final CallState expectedState; + private final CallId expectedCallId; + + StateAwareListener(@NonNull CallState expectedState, @NonNull CallId expectedCallId) { + this.expectedState = expectedState; + this.expectedCallId = expectedCallId; + } + + public @NonNull CallId getCallId() { + return this.expectedCallId; + } + + @Override + public void onSuccess(@Nullable V result) { + if (!isConsistentState()) { + Log.i(TAG, "State has changed since request, skipping success callback..."); + onStateChangeContinue(); + } else { + onSuccessContinue(result); + } + } + + @Override + public void onFailure(@NonNull ExecutionException throwable) { + if (!isConsistentState()) { + Log.w(TAG, throwable); + Log.w(TAG, "State has changed since request, skipping failure callback..."); + onStateChangeContinue(); + } else { + onFailureContinue(throwable.getCause()); + } + } + + public void onStateChangeContinue() {} + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isConsistentState() { + RemotePeer activePeer = serviceState.getCallInfoState().getActivePeer(); + return activePeer != null && expectedState == activePeer.getState() && expectedCallId.equals(activePeer.getCallId()); + } + + public abstract void onSuccessContinue(@Nullable V result); + public abstract void onFailureContinue(@Nullable Throwable throwable); + } + + private class SendCallMessageListener extends StateAwareListener { + SendCallMessageListener(@NonNull RemotePeer expectedRemotePeer) { + super(expectedRemotePeer.getState(), expectedRemotePeer.getCallId()); + } + + @Override + public void onSuccessContinue(@Nullable V result) { + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_MESSAGE_SENT_SUCCESS); + intent.putExtra(EXTRA_CALL_ID, getCallId().longValue()); + + startService(intent); + } + + @Override + public void onStateChangeContinue() { + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_MESSAGE_SENT_SUCCESS) + .putExtra(EXTRA_CALL_ID, getCallId().longValue()); + + startService(intent); + } + + @Override + public void onFailureContinue(@Nullable Throwable error) { + Log.i(TAG, "onFailureContinue: ", error); + + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_MESSAGE_SENT_ERROR) + .putExtra(EXTRA_CALL_ID, getCallId().longValue()); + + WebRtcViewModel.State state = WebRtcViewModel.State.NETWORK_FAILURE; + + if (error instanceof UntrustedIdentityException) { + intent.putExtra(EXTRA_ERROR_IDENTITY_KEY, new IdentityKeyParcelable(((UntrustedIdentityException) error).getIdentityKey())); + state = WebRtcViewModel.State.UNTRUSTED_IDENTITY; + } else if (error instanceof UnregisteredUserException) { + state = WebRtcViewModel.State.NO_SUCH_USER; + } + + intent.putExtra(EXTRA_ERROR_CALL_STATE, state); + + startService(intent); + } + } + + public void sendCallMessage(@NonNull RemotePeer remotePeer, @NonNull SignalServiceCallMessage callMessage) { + ListenableFutureTask listenableFutureTask = sendMessage(remotePeer, callMessage); + listenableFutureTask.addListener(new SendCallMessageListener<>(remotePeer)); + } + + public void sendOpaqueCallMessage(@NonNull UUID uuid, @NonNull SignalServiceCallMessage opaqueMessage) { + RecipientId recipientId = RecipientId.from(uuid, null); + ListenableFutureTask listenableFutureTask = sendMessage(new RemotePeer(recipientId), opaqueMessage); + listenableFutureTask.addListener(new FutureTaskListener() { + @Override + public void onSuccess(Boolean result) { + // intentionally left blank + } + + @Override + public void onFailure(ExecutionException exception) { + Throwable error = exception.getCause(); + + Log.i(TAG, "sendOpaqueCallMessage onFailure: ", error); + + Intent intent = new Intent(WebRtcCallService.this, WebRtcCallService.class); + intent.setAction(ACTION_GROUP_MESSAGE_SENT_ERROR); + + WebRtcViewModel.State state = WebRtcViewModel.State.NETWORK_FAILURE; + + if (error instanceof UntrustedIdentityException) { + intent.putExtra(EXTRA_ERROR_IDENTITY_KEY, new IdentityKeyParcelable(((UntrustedIdentityException) error).getIdentityKey())); + state = WebRtcViewModel.State.UNTRUSTED_IDENTITY; + } + + intent.putExtra(EXTRA_ERROR_CALL_STATE, state); + intent.putExtra(EXTRA_REMOTE_PEER, new RemotePeer(recipientId)); + + startService(intent); + } + }); + } + + public void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId) { + SignalExecutors.BOUNDED.execute(() -> ApplicationDependencies.getJobManager().add(GroupCallUpdateSendJob.create(recipient.getId(), groupCallEraId))); + } + + public void peekGroupCall(@NonNull RecipientId id) { + networkExecutor.execute(() -> { + try { + Recipient group = Recipient.resolved(id); + GroupId.V2 groupId = group.requireGroupId().requireV2(); + GroupExternalCredential credential = GroupManager.getGroupExternalCredential(this, groupId); + + List members = Stream.of(GroupManager.getUuidCipherTexts(this, groupId)) + .map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize())) + .toList(); + + //noinspection ConstantConditions + callManager.peekGroupCall(BuildConfig.SIGNAL_SFU_URL, credential.getTokenBytes().toByteArray(), members, peekInfo -> { + long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdFor(group); + + DatabaseFactory.getSmsDatabase(this).updatePreviousGroupCall(threadId, + peekInfo.getEraId(), + peekInfo.getJoinedMembers(), + WebRtcUtil.isCallFull(peekInfo)); + + ApplicationDependencies.getMessageNotifier().updateNotification(this, threadId, true, 0, BubbleUtil.BubbleState.HIDDEN); + + EventBus.getDefault().postSticky(new GroupCallPeekEvent(id, peekInfo.getEraId(), peekInfo.getDeviceCount(), peekInfo.getMaxDevices())); + }); + + } catch (IOException | VerificationFailedException | CallException e) { + Log.e(TAG, "error peeking from active conversation", e); + } + }); + } + + public void updateGroupCallUpdateMessage(@NonNull RecipientId groupId, @Nullable String groupCallEraId, @NonNull Collection joinedMembers, boolean isCallFull) { + SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getSmsDatabase(this).insertOrUpdateGroupCall(groupId, + Recipient.self().getId(), + System.currentTimeMillis(), + groupCallEraId, + joinedMembers, + isCallFull)); + } + + @Override + public void onStartCall(@Nullable Remote remote, @NonNull CallId callId, @NonNull Boolean isOutgoing, @Nullable CallManager.CallMediaType callMediaType) { + Log.i(TAG, "onStartCall(): callId: " + callId + ", outgoing: " + isOutgoing + ", type: " + callMediaType); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer) remote; + if (serviceState.getCallInfoState().getPeer(remotePeer.hashCode()) == null) { + Log.w(TAG, "remotePeer not found in map with key: " + remotePeer.hashCode() + "! Dropping."); + try { + callManager.drop(callId); + } catch (CallException e) { + serviceState = serviceState.getActionProcessor().callFailure(serviceState, "callManager.drop() failed: ", e); + } + } + + remotePeer.setCallId(callId); + + Intent intent = new Intent(this, WebRtcCallService.class); + + if (isOutgoing) { + intent.setAction(ACTION_START_OUTGOING_CALL); + } else { + intent.setAction(ACTION_START_INCOMING_CALL); + } + + intent.putExtra(EXTRA_REMOTE_PEER_KEY, remotePeer.hashCode()); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onCallEvent(@Nullable Remote remote, @NonNull CallEvent event) { + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer) remote; + if (serviceState.getCallInfoState().getPeer(remotePeer.hashCode()) == null) { + throw new AssertionError("remotePeer not found in map!"); + } + + Log.i(TAG, "onCallEvent(): call_id: " + remotePeer.getCallId() + ", state: " + remotePeer.getState() + ", event: " + event); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.putExtra(EXTRA_REMOTE_PEER_KEY, remotePeer.hashCode()); + + switch (event) { + case LOCAL_RINGING: + intent.setAction(ACTION_LOCAL_RINGING); + break; + case REMOTE_RINGING: + intent.setAction(ACTION_REMOTE_RINGING); + break; + case RECONNECTING: + Log.i(TAG, "Reconnecting: NOT IMPLEMENTED"); + break; + case RECONNECTED: + Log.i(TAG, "Reconnected: NOT IMPLEMENTED"); + break; + case LOCAL_CONNECTED: + case REMOTE_CONNECTED: + intent.setAction(ACTION_CALL_CONNECTED); + break; + case REMOTE_VIDEO_ENABLE: + intent.setAction(ACTION_REMOTE_VIDEO_ENABLE) + .putExtra(EXTRA_ENABLE, true); + break; + case REMOTE_VIDEO_DISABLE: + intent.setAction(ACTION_REMOTE_VIDEO_ENABLE) + .putExtra(EXTRA_ENABLE, false); + break; + case ENDED_REMOTE_HANGUP: + intent.setAction(ACTION_ENDED_REMOTE_HANGUP); + break; + case ENDED_REMOTE_HANGUP_NEED_PERMISSION: + intent.setAction(ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION); + break; + case ENDED_REMOTE_HANGUP_ACCEPTED: + intent.setAction(ACTION_ENDED_REMOTE_HANGUP_ACCEPTED); + break; + case ENDED_REMOTE_HANGUP_BUSY: + intent.setAction(ACTION_ENDED_REMOTE_HANGUP_BUSY); + break; + case ENDED_REMOTE_HANGUP_DECLINED: + intent.setAction(ACTION_ENDED_REMOTE_HANGUP_DECLINED); + break; + case ENDED_REMOTE_BUSY: + intent.setAction(ACTION_ENDED_REMOTE_BUSY); + break; + case ENDED_REMOTE_GLARE: + intent.setAction(ACTION_ENDED_REMOTE_GLARE); + break; + case ENDED_TIMEOUT: + intent.setAction(ACTION_ENDED_TIMEOUT); + break; + case ENDED_INTERNAL_FAILURE: + intent.setAction(ACTION_ENDED_INTERNAL_FAILURE); + break; + case ENDED_SIGNALING_FAILURE: + intent.setAction(ACTION_ENDED_SIGNALING_FAILURE); + break; + case ENDED_CONNECTION_FAILURE: + intent.setAction(ACTION_ENDED_CONNECTION_FAILURE); + break; + case RECEIVED_OFFER_EXPIRED: + intent.setAction(ACTION_RECEIVED_OFFER_EXPIRED); + break; + case RECEIVED_OFFER_WHILE_ACTIVE: + case RECEIVED_OFFER_WITH_GLARE: + intent.setAction(ACTION_RECEIVED_OFFER_WHILE_ACTIVE); + break; + case ENDED_LOCAL_HANGUP: + case ENDED_APP_DROPPED_CALL: + case IGNORE_CALLS_FROM_NON_MULTIRING_CALLERS: + Log.i(TAG, "Ignoring event: " + event); + return; + default: + throw new AssertionError("Unexpected event: " + event.toString()); + } + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onCallConcluded(@Nullable Remote remote) { + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + + Log.i(TAG, "onCallConcluded: call_id: " + remotePeer.getCallId()); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(ACTION_CALL_CONCLUDED) + .putExtra(EXTRA_REMOTE_PEER_KEY, remotePeer.hashCode()); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendOffer(@NonNull CallId callId, @Nullable Remote remote, @NonNull Integer remoteDevice, @NonNull Boolean broadcast, @NonNull byte[] opaque, @NonNull CallManager.CallMediaType callMediaType) { + Log.i(TAG, "onSendOffer: id: " + callId.format(remoteDevice) + " type: " + callMediaType.name()); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + String offerType = getOfferTypeFromCallMediaType(callMediaType).getCode(); + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_SEND_OFFER) + .putExtra(EXTRA_CALL_ID, callId.longValue()) + .putExtra(EXTRA_REMOTE_PEER, remotePeer) + .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) + .putExtra(EXTRA_BROADCAST, broadcast) + .putExtra(EXTRA_OFFER_OPAQUE, opaque) + .putExtra(EXTRA_OFFER_TYPE, offerType); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendAnswer(@NonNull CallId callId, @Nullable Remote remote, @NonNull Integer remoteDevice, @NonNull Boolean broadcast, @NonNull byte[] opaque) { + Log.i(TAG, "onSendAnswer: id: " + callId.format(remoteDevice)); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_SEND_ANSWER) + .putExtra(EXTRA_CALL_ID, callId.longValue()) + .putExtra(EXTRA_REMOTE_PEER, remotePeer) + .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) + .putExtra(EXTRA_BROADCAST, broadcast) + .putExtra(EXTRA_ANSWER_OPAQUE, opaque); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendIceCandidates(@NonNull CallId callId, @Nullable Remote remote, @NonNull Integer remoteDevice, @NonNull Boolean broadcast, @NonNull List iceCandidates) { + Log.i(TAG, "onSendIceCandidates: id: " + callId.format(remoteDevice)); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + Intent intent = new Intent(this, WebRtcCallService.class); + + ArrayList iceCandidateParcels = new ArrayList<>(iceCandidates.size()); + for (byte[] iceCandidate : iceCandidates) { + iceCandidateParcels.add(new IceCandidateParcel(iceCandidate)); + } + + intent.setAction(ACTION_SEND_ICE_CANDIDATES) + .putExtra(EXTRA_CALL_ID, callId.longValue()) + .putExtra(EXTRA_REMOTE_PEER, remotePeer) + .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) + .putExtra(EXTRA_BROADCAST, broadcast) + .putParcelableArrayListExtra(EXTRA_ICE_CANDIDATES, iceCandidateParcels); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendHangup(@NonNull CallId callId, @Nullable Remote remote, @NonNull Integer remoteDevice, @NonNull Boolean broadcast, @NonNull CallManager.HangupType hangupType, @NonNull Integer deviceId, @NonNull Boolean useLegacyHangupMessage) { + Log.i(TAG, "onSendHangup: id: " + callId.format(remoteDevice) + " type: " + hangupType.name() + " isLegacy: " + useLegacyHangupMessage); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_SEND_HANGUP) + .putExtra(EXTRA_CALL_ID, callId.longValue()) + .putExtra(EXTRA_REMOTE_PEER, remotePeer) + .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) + .putExtra(EXTRA_BROADCAST, broadcast) + .putExtra(EXTRA_HANGUP_DEVICE_ID, deviceId.intValue()) + .putExtra(EXTRA_HANGUP_IS_LEGACY, useLegacyHangupMessage.booleanValue()) + .putExtra(EXTRA_HANGUP_TYPE, getHangupTypeFromCallHangupType(hangupType).getCode()); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendBusy(@NonNull CallId callId, @Nullable Remote remote, @NonNull Integer remoteDevice, @NonNull Boolean broadcast) { + Log.i(TAG, "onSendBusy: id: " + callId.format(remoteDevice)); + + if (remote instanceof RemotePeer) { + RemotePeer remotePeer = (RemotePeer)remote; + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_SEND_BUSY) + .putExtra(EXTRA_CALL_ID, callId.longValue()) + .putExtra(EXTRA_REMOTE_PEER, remotePeer) + .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) + .putExtra(EXTRA_BROADCAST, broadcast); + + startService(intent); + } else { + throw new AssertionError("Received remote is not instanceof RemotePeer"); + } + } + + @Override + public void onSendCallMessage(@NonNull UUID uuid, @NonNull byte[] opaque) { + Log.i(TAG, "onSendCallMessage:"); + + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_SEND_OPAQUE_MESSAGE) + .putExtra(EXTRA_UUID, uuid.toString()) + .putExtra(EXTRA_OPAQUE_MESSAGE, opaque); + + startService(intent); + } + + @Override + public void onSendHttpRequest(long requestId, @NonNull String url, @NonNull CallManager.HttpMethod httpMethod, @Nullable List headers, @Nullable byte[] body) { + Log.i(TAG, "onSendHttpRequest(): request_id: " + requestId); + networkExecutor.execute(() -> { + List> headerPairs; + if (headers != null) { + headerPairs = Stream.of(headers) + .map(header -> new Pair<>(header.getName(), header.getValue())) + .toList(); + } else { + headerPairs = Collections.emptyList(); + } + + CallingResponse response = messageSender.makeCallingRequest(requestId, url, httpMethod.name(), headerPairs, body); + + Intent intent = new Intent(this, WebRtcCallService.class); + + if (response instanceof CallingResponse.Success) { + CallingResponse.Success success = (CallingResponse.Success) response; + + intent.setAction(ACTION_HTTP_SUCCESS) + .putExtra(EXTRA_HTTP_REQUEST_ID, success.getRequestId()) + .putExtra(EXTRA_HTTP_RESPONSE_STATUS, success.getResponseStatus()) + .putExtra(EXTRA_HTTP_RESPONSE_BODY, success.getResponseBody()); + } else { + intent.setAction(ACTION_HTTP_FAILURE) + .putExtra(EXTRA_HTTP_REQUEST_ID, response.getRequestId()); + } + + startService(intent); + }); + } + + @Override + public void requestMembershipProof(@NonNull GroupCall groupCall) { + Log.i(TAG, "requestMembershipProof():"); + + networkExecutor.execute(() -> { + try { + GroupExternalCredential credential = GroupManager.getGroupExternalCredential(this, serviceState.getCallInfoState().getCallRecipient().getGroupId().get().requireV2()); + + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF) + .putExtra(EXTRA_GROUP_EXTERNAL_TOKEN, credential.getTokenBytes().toByteArray()) + .putExtra(EXTRA_GROUP_CALL_HASH, groupCall.hashCode()); + + startService(intent); + } catch (IOException e) { + Log.w(TAG, "Unable to get group membership proof from service", e); + onEnded(groupCall, GroupCall.GroupCallEndReason.SFU_CLIENT_FAILED_TO_JOIN); + } catch (VerificationFailedException e) { + Log.w(TAG, "Unable to verify group membership proof", e); + onEnded(groupCall, GroupCall.GroupCallEndReason.DEVICE_EXPLICITLY_DISCONNECTED); + } + }); + } + + @Override + public void requestGroupMembers(@NonNull GroupCall groupCall) { + startService(new Intent(this, WebRtcCallService.class).setAction(ACTION_GROUP_REQUEST_UPDATE_MEMBERS)); + } + + @Override + public void onLocalDeviceStateChanged(@NonNull GroupCall groupCall) { + startService(new Intent(this, WebRtcCallService.class).setAction(ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED)); + } + + @Override + public void onRemoteDeviceStatesChanged(@NonNull GroupCall groupCall) { + startService(new Intent(this, WebRtcCallService.class).setAction(ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED)); + } + + @Override + public void onPeekChanged(@NonNull GroupCall groupCall) { + startService(new Intent(this, WebRtcCallService.class).setAction(ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED)); + } + + @Override + public void onEnded(@NonNull GroupCall groupCall, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_GROUP_CALL_ENDED) + .putExtra(EXTRA_GROUP_CALL_HASH, groupCall.hashCode()) + .putExtra(EXTRA_GROUP_CALL_END_REASON, groupCallEndReason.ordinal()); + + startService(intent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallActionProcessorDelegate.java new file mode 100644 index 00000000..a7625b47 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallActionProcessorDelegate.java @@ -0,0 +1,267 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallId; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.CallState; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.Objects; + +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_GLARE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_ACCEPTED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_DECLINED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION; +import static org.thoughtcrime.securesms.service.WebRtcCallService.BUSY_TONE_LENGTH; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_CONNECTING; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_RINGING; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_OUTGOING_RINGING; + +/** + * Encapsulates the shared logic to manage an active 1:1 call. An active call is any call that is being setup + * or ongoing. Other action processors delegate the appropriate action to it but it is not intended + * to be the main processor for the system. + */ +public class ActiveCallActionProcessorDelegate extends WebRtcActionProcessor { + + private static final Map ENDED_ACTION_TO_STATE = new HashMap() {{ + put(ACTION_ENDED_REMOTE_HANGUP_ACCEPTED, WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE); + put(ACTION_ENDED_REMOTE_HANGUP_BUSY, WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE); + put(ACTION_ENDED_REMOTE_HANGUP_DECLINED, WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE); + put(ACTION_ENDED_REMOTE_BUSY, WebRtcViewModel.State.CALL_BUSY); + put(ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION, WebRtcViewModel.State.CALL_NEEDS_PERMISSION); + put(ACTION_ENDED_REMOTE_GLARE, WebRtcViewModel.State.CALL_DISCONNECTED); + }}; + + public ActiveCallActionProcessorDelegate(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + super(webRtcInteractor, tag); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + if (resultReceiver != null) { + resultReceiver.send(1, null); + } + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSendIceCandidates(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + boolean broadcast, + @NonNull ArrayList iceCandidates) + { + Log.i(tag, "handleSendIceCandidates(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + LinkedList iceUpdateMessages = new LinkedList<>(); + for (IceCandidateParcel parcel : iceCandidates) { + iceUpdateMessages.add(parcel.getIceUpdateMessage(callMetadata.getCallId())); + } + + Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdateMessages, true, destinationDeviceId); + + webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleRemoteVideoEnable(@NonNull WebRtcServiceState currentState, boolean enable) { + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + + Log.i(tag, "handleRemoteVideoEnable(): call_id: " + activePeer.getCallId()); + + CallParticipant oldParticipant = Objects.requireNonNull(currentState.getCallInfoState().getRemoteCallParticipant(activePeer.getRecipient())); + CallParticipant newParticipant = oldParticipant.withVideoEnabled(enable); + + return currentState.builder() + .changeCallInfoState() + .putParticipant(activePeer.getRecipient(), newParticipant) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleLocalHangup(): call_id: " + currentState.getCallInfoState().requireActivePeer().getCallId()); + + ApplicationDependencies.getSignalServiceAccountManager().cancelInFlightRequests(); + ApplicationDependencies.getSignalServiceMessageSender().cancelInFlightRequests(); + + try { + webRtcInteractor.getCallManager().hangup(); + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + return terminate(currentState, currentState.getCallInfoState().getActivePeer()); + } catch (CallException e) { + return callFailure(currentState, "hangup() failed: ", e); + } + } + + @Override + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + Log.i(tag, "handleCallConcluded():"); + + if (remotePeer == null) { + return currentState; + } + + Log.i(tag, "delete remotePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); + return currentState.builder() + .changeCallInfoState() + .removeRemotePeer(remotePeer) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + + Log.i(tag, "handleReceivedOfferWhileActive(): call_id: " + remotePeer.getCallId()); + + switch (activePeer.getState()) { + case DIALING: + case REMOTE_RINGING: webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, activePeer); break; + case IDLE: + case ANSWERING: webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, activePeer); break; + case LOCAL_RINGING: webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, activePeer); break; + case CONNECTED: webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, activePeer); break; + default: throw new IllegalStateException(); + } + + if (activePeer.getState() == CallState.IDLE) { + webRtcInteractor.stopForegroundService(); + } + + webRtcInteractor.insertMissedCall(remotePeer, true, remotePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + + return terminate(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, + @NonNull String action, + @NonNull RemotePeer remotePeer) + { + Log.i(tag, "handleEndedRemote(): call_id: " + remotePeer.getCallId() + " action: " + action); + + WebRtcViewModel.State state = currentState.getCallInfoState().getCallState(); + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + boolean remotePeerIsActive = remotePeer.callIdEquals(activePeer); + boolean outgoingBeforeAccept = remotePeer.getState() == CallState.DIALING || remotePeer.getState() == CallState.REMOTE_RINGING; + boolean incomingBeforeAccept = remotePeer.getState() == CallState.ANSWERING || remotePeer.getState() == CallState.LOCAL_RINGING; + + if (remotePeerIsActive && ENDED_ACTION_TO_STATE.containsKey(action)) { + state = Objects.requireNonNull(ENDED_ACTION_TO_STATE.get(action)); + } + + if (action.equals(ACTION_ENDED_REMOTE_HANGUP)) { + if (remotePeerIsActive) { + state = outgoingBeforeAccept ? WebRtcViewModel.State.RECIPIENT_UNAVAILABLE : WebRtcViewModel.State.CALL_DISCONNECTED; + } + + if (incomingBeforeAccept) { + webRtcInteractor.insertMissedCall(remotePeer, true, remotePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + } + } else if (action.equals(ACTION_ENDED_REMOTE_BUSY) && remotePeerIsActive) { + activePeer.receivedBusy(); + + OutgoingRinger ringer = new OutgoingRinger(context); + ringer.start(OutgoingRinger.Type.BUSY); + Util.runOnMainDelayed(ringer::stop, BUSY_TONE_LENGTH); + } else if (action.equals(ACTION_ENDED_REMOTE_GLARE) && incomingBeforeAccept) { + webRtcInteractor.insertMissedCall(remotePeer, true, remotePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(state) + .build(); + + webRtcInteractor.sendMessage(currentState); + + return terminate(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleEnded(): call_id: " + remotePeer.getCallId() + " action: " + action); + + if (remotePeer.callIdEquals(currentState.getCallInfoState().getActivePeer()) && !currentState.getCallInfoState().getCallState().isErrorState()) { + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.NETWORK_FAILURE) + .build(); + + webRtcInteractor.sendMessage(currentState); + } + + if (remotePeer.getState() == CallState.ANSWERING || remotePeer.getState() == CallState.LOCAL_RINGING) { + webRtcInteractor.insertMissedCall(remotePeer, true, remotePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + } + + return terminate(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSetupFailure(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) { + Log.i(tag, "handleSetupFailure(): call_id: " + callId); + + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + + if (activePeer != null && activePeer.getCallId().equals(callId)) { + try { + if (activePeer.getState() == CallState.DIALING || activePeer.getState() == CallState.REMOTE_RINGING) { + webRtcInteractor.getCallManager().hangup(); + } else { + webRtcInteractor.getCallManager().drop(callId); + } + } catch (CallException e) { + return callFailure(currentState, "Unable to drop call due to setup failure", e); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.NETWORK_FAILURE) + .build(); + + webRtcInteractor.sendMessage(currentState); + + if (activePeer.getState() == CallState.ANSWERING || activePeer.getState() == CallState.LOCAL_RINGING) { + webRtcInteractor.insertMissedCall(activePeer, true, activePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + } + + return terminate(currentState, activePeer); + } + + return currentState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java new file mode 100644 index 00000000..90a719d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.media.AudioManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_CONNECTING; + +/** + * Encapsulates the logic to begin a 1:1 call from scratch. Other action processors + * delegate the appropriate action to it but it is not intended to be the main processor for the system. + */ +public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { + + public BeginCallActionProcessorDelegate(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + super(webRtcInteractor, tag); + } + + @Override + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull OfferMessage.Type offerType) + { + remotePeer.setCallStartTimestamp(System.currentTimeMillis()); + currentState = currentState.builder() + .actionProcessor(new OutgoingCallActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callRecipient(remotePeer.getRecipient()) + .callState(WebRtcViewModel.State.CALL_OUTGOING) + .putRemotePeer(remotePeer) + .putParticipant(remotePeer.getRecipient(), + CallParticipant.createRemote( + new CallParticipantId(remotePeer.getRecipient()), + remotePeer.getRecipient(), + null, + new BroadcastVideoSink(currentState.getVideoState().getEglBase()), + true, + false, + 0, + true, + 0, + CallParticipant.DeviceOrdinal.PRIMARY + )) + .build(); + + CallManager.CallMediaType callMediaType = WebRtcUtil.getCallMediaTypeFromOfferType(offerType); + + try { + webRtcInteractor.getCallManager().call(remotePeer, callMediaType, 1); + } catch (CallException e) { + return callFailure(currentState, "Unable to create outgoing call: ", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + remotePeer.answering(); + + Log.i(tag, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + androidAudioManager.setSpeakerphoneOn(false); + + webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer); + webRtcInteractor.retrieveTurnServers(remotePeer); + + return currentState.builder() + .actionProcessor(new IncomingCallActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callRecipient(remotePeer.getRecipient()) + .activePeer(remotePeer) + .callState(WebRtcViewModel.State.CALL_INCOMING) + .putParticipant(remotePeer.getRecipient(), + CallParticipant.createRemote( + new CallParticipantId(remotePeer.getRecipient()), + remotePeer.getRecipient(), + null, + new BroadcastVideoSink(currentState.getVideoState().getEglBase()), + true, + false, + 0, + true, + 0, + CallParticipant.DeviceOrdinal.PRIMARY + )) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java new file mode 100644 index 00000000..61e00d20 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.CallState; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; + +/** + * Encapsulates the shared logic to setup a 1:1 call. Setup primarily includes retrieving turn servers and + * transitioning to the connected state. Other action processors delegate the appropriate action to it but it is + * not intended to be the main processor for the system. + */ +public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { + + public CallSetupActionProcessorDelegate(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + super(webRtcInteractor, tag); + } + + @Override + public @NonNull WebRtcServiceState handleCallConnected(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + if (!remotePeer.callIdEquals(currentState.getCallInfoState().getActivePeer())) { + Log.w(tag, "handleCallConnected(): Ignoring for inactive call."); + return currentState; + } + + Log.i(tag, "handleCallConnected(): call_id: " + remotePeer.getCallId()); + + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + + webRtcInteractor.startAudioCommunication(activePeer.getState() == CallState.REMOTE_RINGING); + webRtcInteractor.setWantsBluetoothConnection(true); + + activePeer.connected(); + + if (currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IN_VIDEO); + } else { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + } + + currentState = currentState.builder() + .actionProcessor(new ConnectedCallActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_CONNECTED) + .callConnectedTime(System.currentTimeMillis()) + .build(); + + webRtcInteractor.unregisterPowerButtonReceiver(); + webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, activePeer); + + try { + CallManager callManager = webRtcInteractor.getCallManager(); + callManager.setCommunicationMode(); + callManager.setAudioEnable(currentState.getLocalDeviceState().isMicrophoneEnabled()); + callManager.setVideoEnable(currentState.getLocalDeviceState().getCameraState().isEnabled()); + callManager.updateBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + } catch (CallException e) { + return callFailure(currentState, "Enabling audio/video failed: ", e); + } + + if (currentState.getCallSetupState().isAcceptWithVideo()) { + currentState = currentState.getActionProcessor().handleSetEnableVideo(currentState, true); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(tag, "handleSetEnableVideo(): enable: " + enable); + + Camera camera = currentState.getVideoState().requireCamera(); + + if (camera.isInitialized()) { + camera.setEnabled(enable); + } + + currentState = currentState.builder() + .changeCallSetupState() + .enableVideoOnCreate(enable) + .commit() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .build(); + + WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); + + return currentState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java new file mode 100644 index 00000000..430f0921 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; + +import java.util.ArrayList; + +/** + * Handles action for a connected/ongoing call. At this point it's mostly responding + * to user actions (local and remote) on video/mic and adjusting accordingly. + */ +public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor { + + private static final String TAG = Log.tag(ConnectedCallActionProcessor.class); + + private final ActiveCallActionProcessorDelegate activeCallDelegate; + + public ConnectedCallActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + activeCallDelegate = new ActiveCallActionProcessorDelegate(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + return activeCallDelegate.handleIsInCallQuery(currentState, resultReceiver); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(TAG, "handleSetEnableVideo(): call_id: " + currentState.getCallInfoState().requireActivePeer().getCallId()); + + try { + webRtcInteractor.getCallManager().setVideoEnable(enable); + } catch (CallException e) { + return callFailure(currentState, "setVideoEnable() failed: ", e); + } + + currentState = currentState.builder() + .changeLocalDeviceState() + .cameraState(currentState.getVideoState().requireCamera().getCameraState()) + .build(); + + if (currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IN_VIDEO); + } else { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + } + + WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getLocalDeviceState().getCameraState().isEnabled()); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + currentState = currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + + try { + webRtcInteractor.getCallManager().setAudioEnable(currentState.getLocalDeviceState().isMicrophoneEnabled()); + } catch (CallException e) { + return callFailure(currentState, "Enabling audio failed: ", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleRemoteVideoEnable(@NonNull WebRtcServiceState currentState, boolean enable) { + return activeCallDelegate.handleRemoteVideoEnable(currentState, enable); + } + + @Override + protected @NonNull WebRtcServiceState handleSendIceCandidates(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + boolean broadcast, + @NonNull ArrayList iceCandidates) + { + return activeCallDelegate.handleSendIceCandidates(currentState, callMetadata, broadcast, iceCandidates); + } + + @Override + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + return activeCallDelegate.handleLocalHangup(currentState); + } + + @Override + protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEndedRemote(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEnded(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleReceivedOfferWhileActive(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + return activeCallDelegate.handleCallConcluded(currentState, remotePeer); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java new file mode 100644 index 00000000..0f7b5bf2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.media.AudioManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.ServiceUtil; + +/** + * Encapsulates the shared logic to deal with local device actions. Other action processors inherit + * the behavior by extending it instead of delegating. It is not intended to be the main processor + * for the system. + */ +public abstract class DeviceAwareActionProcessor extends WebRtcActionProcessor { + + public DeviceAwareActionProcessor(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + super(webRtcInteractor, tag); + } + + @Override + protected @NonNull WebRtcServiceState handleWiredHeadsetChange(@NonNull WebRtcServiceState currentState, boolean present) { + Log.i(tag, "handleWiredHeadsetChange():"); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + + if (present && androidAudioManager.isSpeakerphoneOn()) { + androidAudioManager.setSpeakerphoneOn(false); + androidAudioManager.setBluetoothScoOn(false); + } else if (!present && !androidAudioManager.isSpeakerphoneOn() && !androidAudioManager.isBluetoothScoOn() && currentState.getLocalDeviceState().getCameraState().isEnabled()) { + androidAudioManager.setSpeakerphoneOn(true); + } + + webRtcInteractor.sendMessage(currentState); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleBluetoothChange(@NonNull WebRtcServiceState currentState, boolean available) { + Log.i(tag, "handleBluetoothChange(): " + available); + + return currentState.builder() + .changeLocalDeviceState() + .isBluetoothAvailable(available) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetSpeakerAudio(@NonNull WebRtcServiceState currentState, boolean isSpeaker) { + Log.i(tag, "handleSetSpeakerAudio(): " + isSpeaker); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + + webRtcInteractor.setWantsBluetoothConnection(false); + androidAudioManager.setSpeakerphoneOn(isSpeaker); + + if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + } + + webRtcInteractor.sendMessage(currentState); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetBluetoothAudio(@NonNull WebRtcServiceState currentState, boolean isBluetooth) { + Log.i(tag, "handleSetBluetoothAudio(): " + isBluetooth); + + webRtcInteractor.setWantsBluetoothConnection(isBluetooth); + + if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + } + + webRtcInteractor.sendMessage(currentState); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetCameraFlip(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleSetCameraFlip():"); + + if (currentState.getLocalDeviceState().getCameraState().isEnabled() && currentState.getVideoState().getCamera() != null) { + currentState.getVideoState().getCamera().flip(); + return currentState.builder() + .changeLocalDeviceState() + .cameraState(currentState.getVideoState().getCamera().getCameraState()) + .build(); + } + return currentState; + } + + @Override + public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) { + Log.i(tag, "handleCameraSwitchCompleted():"); + + return currentState.builder() + .changeLocalDeviceState() + .cameraState(newCameraState) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DisconnectingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DisconnectingCallActionProcessor.java new file mode 100644 index 00000000..9426a032 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DisconnectingCallActionProcessor.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +/** + * Handles disconnecting state actions. This primairly entails dealing with final + * clean up in the call concluded action, but also allows for transitioning into idle/setup + * via beginning an outgoing or incoming call. + */ +public class DisconnectingCallActionProcessor extends WebRtcActionProcessor { + + private static final String TAG = Log.tag(DisconnectingCallActionProcessor.class); + + public DisconnectingCallActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleStartIncomingCall():"); + currentState = currentState.builder() + .actionProcessor(new IdleActionProcessor(webRtcInteractor)) + .build(); + return currentState.getActionProcessor().handleStartIncomingCall(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer, @NonNull OfferMessage.Type offerType) { + Log.i(TAG, "handleOutgoingCall():"); + currentState = currentState.builder() + .actionProcessor(new IdleActionProcessor(webRtcInteractor)) + .build(); + return currentState.getActionProcessor().handleOutgoingCall(currentState, remotePeer, offerType); + } + + @Override + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + Log.i(TAG, "handleCallConcluded():"); + + WebRtcServiceStateBuilder builder = currentState.builder() + .actionProcessor(new IdleActionProcessor(webRtcInteractor)); + + if (remotePeer != null) { + Log.i(TAG, "delete remotePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); + + builder.changeCallInfoState() + .removeRemotePeer(remotePeer) + .commit(); + } + + return builder.build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java new file mode 100644 index 00000000..4484fe9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java @@ -0,0 +1,379 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.util.LongSparseArray; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.VideoState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.webrtc.VideoTrack; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Base group call action processor that handles general callbacks around call members + * and call specific setup information that is the same for any group call state. + */ +public class GroupActionProcessor extends DeviceAwareActionProcessor { + public GroupActionProcessor(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + super(webRtcInteractor, tag); + } + + protected @NonNull WebRtcServiceState handleReceivedOffer(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + @NonNull WebRtcData.OfferMetadata offerMetadata, + @NonNull WebRtcData.ReceivedOfferMetadata receivedOfferMetadata) + { + Log.i(tag, "handleReceivedOffer(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + Log.i(tag, "In a group call, send busy back to 1:1 call offer."); + currentState.getActionProcessor().handleSendBusy(currentState, callMetadata, true); + webRtcInteractor.insertMissedCall(callMetadata.getRemotePeer(), true, receivedOfferMetadata.getServerReceivedTimestamp(), offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupRemoteDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupRemoteDeviceStateChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + Map participants = currentState.getCallInfoState().getRemoteCallParticipantsMap(); + + LongSparseArray remoteDevices = groupCall.getRemoteDeviceStates(); + + if (remoteDevices == null) { + Log.w(tag, "Unable to update remote devices with null list."); + return currentState; + } + + WebRtcServiceStateBuilder.CallInfoStateBuilder builder = currentState.builder() + .changeCallInfoState() + .clearParticipantMap(); + + List remoteDeviceStates = new ArrayList<>(remoteDevices.size()); + for (int i = 0; i < remoteDevices.size(); i++) { + remoteDeviceStates.add(remoteDevices.get(remoteDevices.keyAt(i))); + } + Collections.sort(remoteDeviceStates, (a, b) -> Long.compare(a.getAddedTime(), b.getAddedTime())); + + Set seen = new HashSet<>(); + seen.add(Recipient.self()); + + for (GroupCall.RemoteDeviceState device : remoteDeviceStates) { + Recipient recipient = Recipient.externalPush(context, device.getUserId(), null, false); + CallParticipantId callParticipantId = new CallParticipantId(device.getDemuxId(), recipient.getId()); + CallParticipant callParticipant = participants.get(callParticipantId); + + BroadcastVideoSink videoSink; + VideoTrack videoTrack = device.getVideoTrack(); + if (videoTrack != null) { + videoSink = (callParticipant != null && callParticipant.getVideoSink().getEglBase() != null) ? callParticipant.getVideoSink() + : new BroadcastVideoSink(currentState.getVideoState().requireEglBase()); + videoTrack.addSink(videoSink); + } else { + videoSink = new BroadcastVideoSink(null); + } + + builder.putParticipant(callParticipantId, + CallParticipant.createRemote(callParticipantId, + recipient, + null, + videoSink, + Boolean.FALSE.equals(device.getAudioMuted()), + Boolean.FALSE.equals(device.getVideoMuted()), + device.getSpeakerTime(), + device.getMediaKeysReceived(), + device.getAddedTime(), + seen.contains(recipient) ? CallParticipant.DeviceOrdinal.SECONDARY + : CallParticipant.DeviceOrdinal.PRIMARY)); + + seen.add(recipient); + } + + builder.remoteDevicesCount(remoteDevices.size()); + + return builder.build(); + } + + @Override + protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull byte[] groupMembershipToken) { + Log.i(tag, "handleGroupRequestMembershipProof():"); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + + if (groupCall == null || groupCall.hashCode() != groupCallHash) { + return currentState; + } + + try { + groupCall.setMembershipProof(groupMembershipToken); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to set group membership proof", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupRequestUpdateMembers(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupRequestUpdateMembers():"); + + Recipient group = currentState.getCallInfoState().getCallRecipient(); + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + + List members = Stream.of(GroupManager.getUuidCipherTexts(context, group.requireGroupId().requireV2())) + .map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize())) + .toList(); + + try { + groupCall.setGroupMembers(new ArrayList<>(members)); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable set group members", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleUpdateRenderedResolutions(@NonNull WebRtcServiceState currentState) { + Map participants = currentState.getCallInfoState().getRemoteCallParticipantsMap(); + + ArrayList resolutionRequests = new ArrayList<>(participants.size()); + for (Map.Entry entry : participants.entrySet()) { + BroadcastVideoSink videoSink = entry.getValue().getVideoSink(); + BroadcastVideoSink.RequestedSize maxSize = videoSink.getMaxRequestingSize(); + + resolutionRequests.add(new GroupCall.VideoRequest(entry.getKey().getDemuxId(), maxSize.getWidth(), maxSize.getHeight(), null)); + videoSink.newSizeRequested(); + } + + try { + currentState.getCallInfoState().requireGroupCall().requestVideo(resolutionRequests); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to set rendered resolutions", e); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleHttpSuccess(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { + try { + webRtcInteractor.getCallManager().receivedHttpResponse(httpData.getRequestId(), httpData.getStatus(), httpData.getBody() != null ? httpData.getBody() : new byte[0]); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to process received http response", e); + } + return currentState; + } + + protected @NonNull WebRtcServiceState handleHttpFailure(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { + try { + webRtcInteractor.getCallManager().httpRequestFailed(httpData.getRequestId()); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to process received http response", e); + } + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSendOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.OpaqueMessageMetadata opaqueMessageMetadata) { + Log.i(tag, "handleSendOpaqueMessage():"); + + OpaqueMessage opaqueMessage = new OpaqueMessage(opaqueMessageMetadata.getOpaque()); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOpaque(opaqueMessage, true, null); + + webRtcInteractor.sendOpaqueCallMessage(opaqueMessageMetadata.getUuid(), callMessage); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.OpaqueMessageMetadata opaqueMessageMetadata) { + Log.i(tag, "handleReceivedOpaqueMessage():"); + + try { + webRtcInteractor.getCallManager().receivedCallMessage(opaqueMessageMetadata.getUuid(), + opaqueMessageMetadata.getRemoteDeviceId(), + 1, + opaqueMessageMetadata.getOpaque(), + opaqueMessageMetadata.getMessageAgeSeconds()); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to receive opaque message", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupMessageSentError(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull WebRtcViewModel.State errorCallState, + @NonNull Optional identityKey) + { + Log.w(tag, "handleGroupMessageSentError(): error: " + errorCallState); + + if (errorCallState == WebRtcViewModel.State.UNTRUSTED_IDENTITY) { + return currentState.builder() + .changeCallInfoState() + .addIdentityChangedRecipient(remotePeer.getId()) + .build(); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupApproveSafetyNumberChange(@NonNull WebRtcServiceState currentState, + @NonNull List recipientIds) + { + Log.i(tag, "handleGroupApproveSafetyNumberChange():"); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + + if (groupCall != null) { + currentState = currentState.builder() + .changeCallInfoState() + .removeIdentityChangedRecipients(recipientIds) + .build(); + + try { + groupCall.resendMediaKeys(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to resend media keys", e); + } + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupCallEnded(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { + Log.i(tag, "handleGroupCallEnded(): reason: " + groupCallEndReason); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + + if (groupCall == null || groupCall.hashCode() != groupCallHash) { + return currentState; + } + + try { + groupCall.disconnect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to disconnect from group call", e); + } + + if (groupCallEndReason != GroupCall.GroupCallEndReason.DEVICE_EXPLICITLY_DISCONNECTED) { + Log.i(tag, "Group call ended unexpectedly, reinitializing and dropping back to lobby"); + Recipient currentRecipient = currentState.getCallInfoState().getCallRecipient(); + VideoState videoState = currentState.getVideoState(); + + currentState = terminateGroupCall(currentState, false).builder() + .actionProcessor(new GroupNetworkUnavailableActionProcessor(webRtcInteractor)) + .changeVideoState() + .eglBase(videoState.getEglBase()) + .camera(videoState.getCamera()) + .localSink(videoState.getLocalSink()) + .commit() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_PRE_JOIN) + .callRecipient(currentRecipient) + .build(); + + currentState = WebRtcVideoUtil.initializeVanityCamera(WebRtcVideoUtil.reinitializeCamera(context, webRtcInteractor.getCameraEventListener(), currentState)); + + return currentState.getActionProcessor().handlePreJoinCall(currentState, new RemotePeer(currentRecipient.getId())); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + return terminateGroupCall(currentState); + } + + public @NonNull WebRtcServiceState groupCallFailure(@NonNull WebRtcServiceState currentState, @NonNull String message, @NonNull Throwable error) { + Log.w(tag, "groupCallFailure(): " + message, error); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + Recipient recipient = currentState.getCallInfoState().getCallRecipient(); + + if (recipient != null && currentState.getCallInfoState().getGroupCallState().isConnected()) { + webRtcInteractor.sendGroupCallMessage(recipient, WebRtcUtil.getGroupCallEraId(groupCall)); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + try { + if (groupCall != null) { + groupCall.disconnect(); + } + webRtcInteractor.getCallManager().reset(); + } catch (CallException e) { + Log.w(tag, "Unable to reset call manager: ", e); + } + + return terminateGroupCall(currentState); + } + + @Override + protected @NonNull WebRtcServiceState handleOrientationChanged(@NonNull WebRtcServiceState currentState, int orientationDegrees) { + return currentState; + } + + public synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState) { + return terminateGroupCall(currentState, true); + } + + public synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState, boolean terminateVideo) { + webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); + webRtcInteractor.stopForegroundService(); + boolean playDisconnectSound = currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_DISCONNECTED; + webRtcInteractor.stopAudio(playDisconnectSound); + webRtcInteractor.setWantsBluetoothConnection(false); + + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); + + if (terminateVideo) { + WebRtcVideoUtil.deinitializeVideo(currentState); + } + + GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(context, currentState.getCallInfoState().getCallRecipient()); + + return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java new file mode 100644 index 00000000..2999602b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Process actions for when the call has at least once been connected and joined. + */ +public class GroupConnectedActionProcessor extends GroupActionProcessor { + + private static final String TAG = Log.tag(GroupConnectedActionProcessor.class); + + public GroupConnectedActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + if (resultReceiver != null) { + resultReceiver.send(1, null); + } + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupLocalDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupLocalDeviceStateChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + GroupCall.LocalDeviceState device = groupCall.getLocalDeviceState(); + GroupCall.ConnectionState connectionState = device.getConnectionState(); + GroupCall.JoinState joinState = device.getJoinState(); + + Log.i(tag, "local device changed: " + connectionState + " " + joinState); + + WebRtcViewModel.GroupCallState groupCallState = WebRtcUtil.groupCallStateForConnection(connectionState); + + if (connectionState == GroupCall.ConnectionState.CONNECTED || connectionState == GroupCall.ConnectionState.CONNECTING) { + if (joinState == GroupCall.JoinState.JOINED) { + groupCallState = WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; + } else if (joinState == GroupCall.JoinState.JOINING) { + groupCallState = WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING; + } + } + + return currentState.builder().changeCallInfoState() + .groupCallState(groupCallState) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(TAG, "handleSetEnableVideo():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + Camera camera = currentState.getVideoState().requireCamera(); + + try { + groupCall.setOutgoingVideoMuted(!enable); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable set video muted", e); + } + camera.setEnabled(enable); + + currentState = currentState.builder() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .build(); + + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor.getWebRtcCallService(), currentState.getCallSetupState().isEnableVideoOnCreate()); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + try { + currentState.getCallInfoState().requireGroupCall().setOutgoingAudioMuted(muted); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to set audio muted", e); + } + + return currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupJoinedMembershipChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + PeekInfo peekInfo = groupCall.getPeekInfo(); + + if (peekInfo == null) { + return currentState; + } + + if (currentState.getCallSetupState().hasSentJoinedMessage()) { + return currentState; + } + + String eraId = WebRtcUtil.getGroupCallEraId(groupCall); + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId); + + List members = new ArrayList<>(peekInfo.getJoinedMembers()); + if (!members.contains(Recipient.self().requireUuid())) { + members.add(Recipient.self().requireUuid()); + } + webRtcInteractor.updateGroupCallUpdateMessage(currentState.getCallInfoState().getCallRecipient().getId(), eraId, members, WebRtcUtil.isCallFull(peekInfo)); + + return currentState.builder() + .changeCallSetupState() + .sentJoinedMessage(true) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + Log.i(TAG, "handleLocalHangup():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + + try { + groupCall.disconnect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to disconnect from group call", e); + } + + String eraId = WebRtcUtil.getGroupCallEraId(groupCall); + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId); + + List members = Stream.of(currentState.getCallInfoState().getRemoteCallParticipants()).map(p -> p.getRecipient().requireUuid()).toList(); + webRtcInteractor.updateGroupCallUpdateMessage(currentState.getCallInfoState().getCallRecipient().getId(), eraId, members, false); + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + return terminateGroupCall(currentState); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java new file mode 100644 index 00000000..c1481b77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; + +/** + * Process actions to go from lobby to a joined call. + */ +public class GroupJoiningActionProcessor extends GroupActionProcessor { + + private static final String TAG = Log.tag(GroupJoiningActionProcessor.class); + + private final CallSetupActionProcessorDelegate callSetupDelegate; + + public GroupJoiningActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + callSetupDelegate = new CallSetupActionProcessorDelegate(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + if (resultReceiver != null) { + resultReceiver.send(1, null); + } + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleGroupLocalDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupLocalDeviceStateChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + GroupCall.LocalDeviceState device = groupCall.getLocalDeviceState(); + + Log.i(tag, "local device changed: " + device.getConnectionState() + " " + device.getJoinState()); + + WebRtcServiceStateBuilder builder = currentState.builder(); + + switch (device.getConnectionState()) { + case NOT_CONNECTED: + case RECONNECTING: + builder.changeCallInfoState() + .groupCallState(WebRtcUtil.groupCallStateForConnection(device.getConnectionState())) + .commit(); + break; + case CONNECTING: + case CONNECTED: + if (device.getJoinState() == GroupCall.JoinState.JOINED) { + + webRtcInteractor.startAudioCommunication(true); + webRtcInteractor.setWantsBluetoothConnection(true); + + if (currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IN_VIDEO); + } else { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + } + + webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, currentState.getCallInfoState().getCallRecipient()); + + try { + groupCall.setOutgoingVideoMuted(!currentState.getLocalDeviceState().getCameraState().isEnabled()); + groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled()); + groupCall.setBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + } catch (CallException e) { + Log.e(tag, e); + throw new RuntimeException(e); + } + + builder.changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_CONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED) + .callConnectedTime(System.currentTimeMillis()) + .commit() + .actionProcessor(new GroupConnectedActionProcessor(webRtcInteractor)) + .build(); + } else if (device.getJoinState() == GroupCall.JoinState.JOINING) { + builder.changeCallInfoState() + .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) + .commit(); + } else { + builder.changeCallInfoState() + .groupCallState(WebRtcUtil.groupCallStateForConnection(device.getConnectionState())) + .commit(); + } + break; + } + + return builder.build(); + } + + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleLocalHangup():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + try { + groupCall.disconnect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to disconnect from group call", e); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + return terminateGroupCall(currentState); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + Camera camera = currentState.getVideoState().requireCamera(); + + try { + groupCall.setOutgoingVideoMuted(!enable); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to set video muted", e); + } + camera.setEnabled(enable); + + currentState = currentState.builder() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .build(); + + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor.getWebRtcCallService(), currentState.getCallSetupState().isEnableVideoOnCreate()); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + try { + currentState.getCallInfoState().requireGroupCall().setOutgoingAudioMuted(muted); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to set audio muted", e); + } + + return currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupNetworkUnavailableActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupNetworkUnavailableActionProcessor.java new file mode 100644 index 00000000..de157313 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupNetworkUnavailableActionProcessor.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; + +/** + * Processor which is utilized when the network becomes unavailable during a group call. In general, + * this is triggered whenever there is a call ended, and the ending was not the result of direct user + * action. + * + * This class will check the network status when handlePreJoinCall is invoked, and transition to + * GroupPreJoinActionProcessor as network becomes available again. + */ +class GroupNetworkUnavailableActionProcessor extends WebRtcActionProcessor { + + private static final String TAG = Log.tag(GroupNetworkUnavailableActionProcessor.class); + + public GroupNetworkUnavailableActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handlePreJoinCall():"); + + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + + if (activeNetworkInfo != null && activeNetworkInfo.isConnected()) { + GroupPreJoinActionProcessor processor = new GroupPreJoinActionProcessor(webRtcInteractor); + return processor.handlePreJoinCall(currentState.builder().actionProcessor(processor).build(), remotePeer); + } + + byte[] groupId = currentState.getCallInfoState().getCallRecipient().requireGroupId().getDecodedId(); + GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, + BuildConfig.SIGNAL_SFU_URL, + currentState.getVideoState().requireEglBase(), + webRtcInteractor.getGroupCallObserver()); + + return currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.NETWORK_FAILURE) + .groupCall(groupCall) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleCancelPreJoinCall(@NonNull WebRtcServiceState currentState) { + Log.i(TAG, "handleCancelPreJoinCall():"); + + WebRtcVideoUtil.deinitializeVideo(currentState); + + return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + } + + @Override + public @NonNull WebRtcServiceState handleNetworkChanged(@NonNull WebRtcServiceState currentState, boolean available) { + if (available) { + return currentState.builder() + .actionProcessor(new GroupPreJoinActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_PRE_JOIN) + .build(); + } else { + return currentState; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java new file mode 100644 index 00000000..2b4db95c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java @@ -0,0 +1,206 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.media.AudioManager; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +import java.util.List; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_OUTGOING_RINGING; + +/** + * Process actions while the user is in the pre-join lobby for the call. + */ +public class GroupPreJoinActionProcessor extends GroupActionProcessor { + + private static final String TAG = Log.tag(GroupPreJoinActionProcessor.class); + + public GroupPreJoinActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handlePreJoinCall():"); + + byte[] groupId = currentState.getCallInfoState().getCallRecipient().requireGroupId().getDecodedId(); + GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, + BuildConfig.SIGNAL_SFU_URL, + currentState.getVideoState().requireEglBase(), + webRtcInteractor.getGroupCallObserver()); + + try { + groupCall.setOutgoingAudioMuted(true); + groupCall.setOutgoingVideoMuted(true); + groupCall.setBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + + Log.i(TAG, "Connecting to group call: " + currentState.getCallInfoState().getCallRecipient().getId()); + groupCall.connect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to connect to group call", e); + } + + SignalStore.tooltips().markGroupCallingLobbyEntered(); + return currentState.builder() + .changeCallInfoState() + .groupCall(groupCall) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleCancelPreJoinCall(@NonNull WebRtcServiceState currentState) { + Log.i(TAG, "handleCancelPreJoinCall():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + try { + groupCall.disconnect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to disconnect from group call", e); + } + + WebRtcVideoUtil.deinitializeVideo(currentState); + + return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + } + + @Override + protected @NonNull WebRtcServiceState handleGroupLocalDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupLocalDeviceStateChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + GroupCall.LocalDeviceState device = groupCall.getLocalDeviceState(); + + Log.i(tag, "local device changed: " + device.getConnectionState() + " " + device.getJoinState()); + + return currentState.builder() + .changeCallInfoState() + .groupCallState(WebRtcUtil.groupCallStateForConnection(device.getConnectionState())) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupJoinedMembershipChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + PeekInfo peekInfo = groupCall.getPeekInfo(); + + if (peekInfo == null) { + Log.i(tag, "No peek info available"); + return currentState; + } + + List callParticipants = Stream.of(peekInfo.getJoinedMembers()) + .map(uuid -> Recipient.externalPush(context, uuid, null, false)) + .toList(); + + WebRtcServiceStateBuilder.CallInfoStateBuilder builder = currentState.builder() + .changeCallInfoState() + .remoteDevicesCount(peekInfo.getDeviceCount()) + .participantLimit(peekInfo.getMaxDevices()) + .clearParticipantMap(); + + for (Recipient recipient : callParticipants) { + builder.putParticipant(recipient, CallParticipant.createRemote(new CallParticipantId(recipient), recipient, null, new BroadcastVideoSink(null), true, true, 0, false, 0, CallParticipant.DeviceOrdinal.PRIMARY)); + } + + return builder.build(); + } + + @Override + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull OfferMessage.Type offerType) + { + Log.i(TAG, "handleOutgoingCall():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + + currentState = WebRtcVideoUtil.reinitializeCamera(context, webRtcInteractor.getCameraEventListener(), currentState); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + androidAudioManager.setSpeakerphoneOn(false); + + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + webRtcInteractor.initializeAudioForCall(); + webRtcInteractor.setWantsBluetoothConnection(true); + webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, currentState.getCallInfoState().getCallRecipient()); + + try { + groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera()); + groupCall.setOutgoingVideoMuted(!currentState.getLocalDeviceState().getCameraState().isEnabled()); + groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled()); + groupCall.setBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + + groupCall.join(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to join group call", e); + } + + return currentState.builder() + .actionProcessor(new GroupJoiningActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_OUTGOING) + .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(TAG, "handleSetEnableVideo(): Changing for pre-join group call. enable: " + enable); + + currentState.getVideoState().requireCamera().setEnabled(enable); + return currentState.builder() + .changeCallSetupState() + .enableVideoOnCreate(enable) + .commit() + .changeLocalDeviceState() + .cameraState(currentState.getVideoState().requireCamera().getCameraState()) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + Log.i(TAG, "handleSetMuteAudio(): Changing for pre-join group call. muted: " + muted); + + return currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + } + + @Override + public @NonNull WebRtcServiceState handleNetworkChanged(@NonNull WebRtcServiceState currentState, boolean available) { + if (!available) { + return currentState.builder() + .actionProcessor(new GroupNetworkUnavailableActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callState(WebRtcViewModel.State.NETWORK_FAILURE) + .build(); + } else { + return currentState; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java new file mode 100644 index 00000000..69e70e55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.webrtc.CapturerObserver; +import org.webrtc.VideoFrame; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +/** + * Action handler for when the system is at rest. Mainly responsible + * for starting pre-call state, starting an outgoing call, or receiving an + * incoming call. + */ +public class IdleActionProcessor extends WebRtcActionProcessor { + + private static final String TAG = Log.tag(IdleActionProcessor.class); + + private final BeginCallActionProcessorDelegate beginCallDelegate; + + public IdleActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + beginCallDelegate = new BeginCallActionProcessorDelegate(webRtcInteractor, TAG); + } + + protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleStartIncomingCall():"); + + currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState); + return beginCallDelegate.handleStartIncomingCall(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull OfferMessage.Type offerType) + { + Log.i(TAG, "handleOutgoingCall():"); + + currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState); + return beginCallDelegate.handleOutgoingCall(currentState, remotePeer, offerType); + } + + @Override + protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handlePreJoinCall():"); + + boolean isGroupCall = remotePeer.getRecipient().isPushV2Group(); + WebRtcActionProcessor processor = isGroupCall ? new GroupPreJoinActionProcessor(webRtcInteractor) + : new PreJoinActionProcessor(webRtcInteractor); + + currentState = WebRtcVideoUtil.initializeVanityCamera(WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState)); + + currentState = currentState.builder() + .actionProcessor(processor) + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_PRE_JOIN) + .callRecipient(remotePeer.getRecipient()) + .build(); + + return isGroupCall ? currentState.getActionProcessor().handlePreJoinCall(currentState, remotePeer) + : currentState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java new file mode 100644 index 00000000..7071469e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java @@ -0,0 +1,239 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.net.Uri; +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallId; +import org.thoughtcrime.securesms.components.webrtc.OrientationAwareVideoSink; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.CallState; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.VideoState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.webrtc.PeerConnection; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_RINGING; + +/** + * Responsible for setting up and managing the start of an incoming 1:1 call. Transitioned + * to from idle or pre-join and can either move to a connected state (user picks up) or + * a disconnected state (remote hangup, local hangup, etc.). + */ +public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { + + private static final String TAG = Log.tag(IncomingCallActionProcessor.class); + + private final ActiveCallActionProcessorDelegate activeCallDelegate; + private final CallSetupActionProcessorDelegate callSetupDelegate; + + public IncomingCallActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + activeCallDelegate = new ActiveCallActionProcessorDelegate(webRtcInteractor, TAG); + callSetupDelegate = new CallSetupActionProcessorDelegate(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + return activeCallDelegate.handleIsInCallQuery(currentState, resultReceiver); + } + + @Override + protected @NonNull WebRtcServiceState handleSendAnswer(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + @NonNull WebRtcData.AnswerMetadata answerMetadata, + boolean broadcast) + { + Log.i(TAG, "handleSendAnswer(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + AnswerMessage answerMessage = new AnswerMessage(callMetadata.getCallId().longValue(), answerMetadata.getSdp(), answerMetadata.getOpaque()); + Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forAnswer(answerMessage, true, destinationDeviceId); + + webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); + + return currentState; + } + + @Override + public @NonNull WebRtcServiceState handleTurnServerUpdate(@NonNull WebRtcServiceState currentState, + @NonNull List iceServers, + boolean isAlwaysTurn) + { + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + boolean hideIp = !activePeer.getRecipient().isSystemContact() || isAlwaysTurn; + VideoState videoState = currentState.getVideoState(); + CallParticipant callParticipant = Objects.requireNonNull(currentState.getCallInfoState().getRemoteCallParticipant(activePeer.getRecipient())); + + try { + webRtcInteractor.getCallManager().proceed(activePeer.getCallId(), + context, + videoState.requireEglBase(), + new OrientationAwareVideoSink(videoState.requireLocalSink()), + new OrientationAwareVideoSink(callParticipant.getVideoSink()), + videoState.requireCamera(), + iceServers, + hideIp, + NetworkUtil.getCallingBandwidthMode(context), + false); + } catch (CallException e) { + return callFailure(currentState, "Unable to proceed with call: ", e); + } + + webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); + webRtcInteractor.sendMessage(currentState); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleAcceptCall(@NonNull WebRtcServiceState currentState, boolean answerWithVideo) { + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + + Log.i(TAG, "handleAcceptCall(): call_id: " + activePeer.getCallId()); + + DatabaseFactory.getSmsDatabase(context).insertReceivedCall(activePeer.getId(), currentState.getCallSetupState().isRemoteVideoOffer()); + + currentState = currentState.builder() + .changeCallSetupState() + .acceptWithVideo(answerWithVideo) + .build(); + + try { + webRtcInteractor.getCallManager().acceptCall(activePeer.getCallId()); + } catch (CallException e) { + return callFailure(currentState, "accept() failed: ", e); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleDenyCall(@NonNull WebRtcServiceState currentState) { + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + + if (activePeer.getState() != CallState.LOCAL_RINGING) { + Log.w(TAG, "Can only deny from ringing!"); + return currentState; + } + + Log.i(TAG, "handleDenyCall():"); + + try { + webRtcInteractor.getCallManager().hangup(); + DatabaseFactory.getSmsDatabase(context).insertMissedCall(activePeer.getId(), System.currentTimeMillis(), currentState.getCallSetupState().isRemoteVideoOffer()); + return terminate(currentState, activePeer); + } catch (CallException e) { + return callFailure(currentState, "hangup() failed: ", e); + } + } + + protected @NonNull WebRtcServiceState handleLocalRinging(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleLocalRinging(): call_id: " + remotePeer.getCallId()); + + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + Recipient recipient = remotePeer.getRecipient(); + + activePeer.localRinging(); + webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); + + boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient); + if (shouldDisturbUserWithCall) { + webRtcInteractor.startWebRtcCallActivityIfPossible(); + } + + webRtcInteractor.initializeAudioForCall(); + if (shouldDisturbUserWithCall && TextSecurePreferences.isCallNotificationsEnabled(context)) { + Uri ringtone = recipient.resolve().getCallRingtone(); + RecipientDatabase.VibrateState vibrateState = recipient.resolve().getCallVibrate(); + + if (ringtone == null) { + ringtone = TextSecurePreferences.getCallNotificationRingtone(context); + } + + webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientDatabase.VibrateState.ENABLED || (vibrateState == RecipientDatabase.VibrateState.DEFAULT && TextSecurePreferences.isCallNotificationVibrateEnabled(context))); + } + + webRtcInteractor.registerPowerButtonReceiver(); + webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, activePeer); + + return currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_INCOMING) + .build(); + } + + protected @NonNull WebRtcServiceState handleScreenOffChange(@NonNull WebRtcServiceState currentState) { + Log.i(TAG, "Silencing incoming ringer..."); + + webRtcInteractor.silenceIncomingRinger(); + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleRemoteVideoEnable(@NonNull WebRtcServiceState currentState, boolean enable) { + return activeCallDelegate.handleRemoteVideoEnable(currentState, enable); + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleReceivedOfferWhileActive(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEndedRemote(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEnded(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSetupFailure(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) { + return activeCallDelegate.handleSetupFailure(currentState, callId); + } + + @Override + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + return activeCallDelegate.handleCallConcluded(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSendIceCandidates(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + boolean broadcast, + @NonNull ArrayList iceCandidates) + { + return activeCallDelegate.handleSendIceCandidates(currentState, callMetadata, broadcast, iceCandidates); + } + + @Override + public @NonNull WebRtcServiceState handleCallConnected(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + return callSetupDelegate.handleCallConnected(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + return callSetupDelegate.handleSetEnableVideo(currentState, enable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java new file mode 100644 index 00000000..5148fbdb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -0,0 +1,243 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.media.AudioManager; +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallId; +import org.thoughtcrime.securesms.components.webrtc.OrientationAwareVideoSink; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.OfferMetadata; +import org.thoughtcrime.securesms.service.webrtc.state.VideoState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger; +import org.webrtc.PeerConnection; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_OUTGOING_RINGING; + +/** + * Responsible for setting up and managing the start of an outgoing 1:1 call. Transitioned + * to from idle or pre-join and can either move to a connected state (callee picks up) or + * a disconnected state (remote hangup, local hangup, etc.). + */ +public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { + + private static final String TAG = Log.tag(OutgoingCallActionProcessor.class); + + private final ActiveCallActionProcessorDelegate activeCallDelegate; + private final CallSetupActionProcessorDelegate callSetupDelegate; + + public OutgoingCallActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + activeCallDelegate = new ActiveCallActionProcessorDelegate(webRtcInteractor, TAG); + callSetupDelegate = new CallSetupActionProcessorDelegate(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + return activeCallDelegate.handleIsInCallQuery(currentState, resultReceiver); + } + + @Override + protected @NonNull WebRtcServiceState handleStartOutgoingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleStartOutgoingCall():"); + WebRtcServiceStateBuilder builder = currentState.builder(); + + remotePeer.dialing(); + + Log.i(TAG, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + androidAudioManager.setSpeakerphoneOn(false); + WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); + + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); + webRtcInteractor.initializeAudioForCall(); + webRtcInteractor.startOutgoingRinger(OutgoingRinger.Type.RINGING); + webRtcInteractor.setWantsBluetoothConnection(true); + + webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer); + + DatabaseFactory.getSmsDatabase(context).insertOutgoingCall(remotePeer.getId(), currentState.getCallSetupState().isEnableVideoOnCreate()); + + webRtcInteractor.retrieveTurnServers(remotePeer); + + return builder.changeCallInfoState() + .activePeer(remotePeer) + .callState(WebRtcViewModel.State.CALL_OUTGOING) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSendOffer(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata, @NonNull OfferMetadata offerMetadata, boolean broadcast) { + Log.i(TAG, "handleSendOffer(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + OfferMessage offerMessage = new OfferMessage(callMetadata.getCallId().longValue(), offerMetadata.getSdp(), offerMetadata.getOfferType(), offerMetadata.getOpaque()); + Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOffer(offerMessage, true, destinationDeviceId); + + webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); + + return currentState; + } + + @Override + public @NonNull WebRtcServiceState handleTurnServerUpdate(@NonNull WebRtcServiceState currentState, + @NonNull List iceServers, + boolean isAlwaysTurn) + { + try { + VideoState videoState = currentState.getVideoState(); + RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + CallParticipant callParticipant = Objects.requireNonNull(currentState.getCallInfoState().getRemoteCallParticipant(activePeer.getRecipient())); + + webRtcInteractor.getCallManager().proceed(activePeer.getCallId(), + context, + videoState.requireEglBase(), + new OrientationAwareVideoSink(videoState.requireLocalSink()), + new OrientationAwareVideoSink(callParticipant.getVideoSink()), + videoState.requireCamera(), + iceServers, + isAlwaysTurn, + NetworkUtil.getCallingBandwidthMode(context), + currentState.getCallSetupState().isEnableVideoOnCreate()); + } catch (CallException e) { + return callFailure(currentState, "Unable to proceed with call: ", e); + } + + return currentState.builder() + .changeLocalDeviceState() + .cameraState(currentState.getVideoState().requireCamera().getCameraState()) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleRemoteRinging(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleRemoteRinging(): call_id: " + remotePeer.getCallId()); + + currentState.getCallInfoState().requireActivePeer().remoteRinging(); + return currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_RINGING) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedAnswer(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull WebRtcData.AnswerMetadata answerMetadata, + @NonNull WebRtcData.ReceivedAnswerMetadata receivedAnswerMetadata) + { + Log.i(TAG, "handleReceivedAnswer(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + if (answerMetadata.getOpaque() == null) { + return callFailure(currentState, "receivedAnswer() failed: answerMetadata did not contain opaque", null); + } + + try { + byte[] remoteIdentityKey = WebRtcUtil.getPublicKeyBytes(receivedAnswerMetadata.getRemoteIdentityKey()); + byte[] localIdentityKey = WebRtcUtil.getPublicKeyBytes(IdentityKeyUtil.getIdentityKey(context).serialize()); + + webRtcInteractor.getCallManager().receivedAnswer(callMetadata.getCallId(), callMetadata.getRemoteDevice(), answerMetadata.getOpaque(), receivedAnswerMetadata.isMultiRing(), remoteIdentityKey, localIdentityKey); + } catch (CallException | InvalidKeyException e) { + return callFailure(currentState, "receivedAnswer() failed: ", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedBusy(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata) { + Log.i(TAG, "handleReceivedBusy(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + try { + webRtcInteractor.getCallManager().receivedBusy(callMetadata.getCallId(), callMetadata.getRemoteDevice()); + } catch (CallException e) { + return callFailure(currentState, "receivedBusy() failed: ", e); + } + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + return currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleRemoteVideoEnable(@NonNull WebRtcServiceState currentState, boolean enable) { + return activeCallDelegate.handleRemoteVideoEnable(currentState, enable); + } + + @Override + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + return activeCallDelegate.handleLocalHangup(currentState); + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleReceivedOfferWhileActive(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEndedRemote(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + return activeCallDelegate.handleEnded(currentState, action, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSetupFailure(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) { + return activeCallDelegate.handleSetupFailure(currentState, callId); + } + + @Override + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + return activeCallDelegate.handleCallConcluded(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSendIceCandidates(@NonNull WebRtcServiceState currentState, + @NonNull WebRtcData.CallMetadata callMetadata, + boolean broadcast, + @NonNull ArrayList iceCandidates) + { + return activeCallDelegate.handleSendIceCandidates(currentState, callMetadata, broadcast, iceCandidates); + } + + @Override + public @NonNull WebRtcServiceState handleCallConnected(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + return callSetupDelegate.handleCallConnected(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + return callSetupDelegate.handleSetEnableVideo(currentState, enable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PreJoinActionProcessor.java new file mode 100644 index 00000000..4ab7cec7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PreJoinActionProcessor.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +/** + * Handles pre-join call actions. This serves as a more capable idle state as no + * call has actually start so incoming and outgoing calls are allowed. + */ +public class PreJoinActionProcessor extends DeviceAwareActionProcessor { + + private static final String TAG = Log.tag(PreJoinActionProcessor.class); + + private final BeginCallActionProcessorDelegate beginCallDelegate; + + public PreJoinActionProcessor(@NonNull WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + beginCallDelegate = new BeginCallActionProcessorDelegate(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleCancelPreJoinCall(@NonNull WebRtcServiceState currentState) { + Log.i(TAG, "handleCancelPreJoinCall():"); + + WebRtcVideoUtil.deinitializeVideo(currentState); + + return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + } + + protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(TAG, "handleStartIncomingCall():"); + + currentState = WebRtcVideoUtil.reinitializeCamera(context, webRtcInteractor.getCameraEventListener(), currentState) + .builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_INCOMING) + .build(); + + webRtcInteractor.sendMessage(currentState); + return beginCallDelegate.handleStartIncomingCall(currentState, remotePeer); + } + + @Override + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull OfferMessage.Type offerType) + { + Log.i(TAG, "handleOutgoingCall():"); + currentState = WebRtcVideoUtil.reinitializeCamera(context, webRtcInteractor.getCameraEventListener(), currentState); + return beginCallDelegate.handleOutgoingCall(currentState, remotePeer, offerType); + } + + @SuppressWarnings("ConstantConditions") + @Override + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(TAG, "handleSetEnableVideo(): Changing for pre-join call."); + + currentState.getVideoState().getCamera().setEnabled(enable); + return currentState.builder() + .changeCallSetupState() + .enableVideoOnCreate(enable) + .commit() + .changeLocalDeviceState() + .cameraState(currentState.getVideoState().getCamera().getCameraState()) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + return currentState.builder() + .changeLocalDeviceState() + .isMicrophoneEnabled(!muted) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java new file mode 100644 index 00000000..92f7ec93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -0,0 +1,838 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Context; +import android.content.Intent; +import android.os.ResultReceiver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallId; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.components.sensors.Orientation; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.ringrtc.CallState; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.HttpData; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.OfferMetadata; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedOfferMetadata; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.util.TelephonyUtil; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.webrtc.PeerConnection; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ACCEPT_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_BANDWIDTH_MODE_UPDATE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_BLUETOOTH_CHANGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_CALL_CONCLUDED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_CALL_CONNECTED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_CAMERA_SWITCH_COMPLETED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_CANCEL_PRE_JOIN_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_DENY_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_CONNECTION_FAILURE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_INTERNAL_FAILURE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_GLARE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_ACCEPTED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_DECLINED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_SIGNALING_FAILURE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_TIMEOUT; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_FLIP_CAMERA; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_APPROVE_SAFETY_CHANGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_CALL_ENDED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_CALL_PEEK; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_MESSAGE_SENT_ERROR; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_REQUEST_UPDATE_MEMBERS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_HTTP_FAILURE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_HTTP_SUCCESS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_IS_IN_CALL_QUERY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_LOCAL_HANGUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_LOCAL_RINGING; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_MESSAGE_SENT_ERROR; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_MESSAGE_SENT_SUCCESS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_NETWORK_CHANGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ORIENTATION_CHANGED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_OUTGOING_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_PRE_JOIN_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVED_OFFER_EXPIRED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVED_OFFER_WHILE_ACTIVE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_ANSWER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_HANGUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_ICE_CANDIDATES; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_OFFER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_RECEIVE_OPAQUE_MESSAGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_REMOTE_RINGING; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_REMOTE_VIDEO_ENABLE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SCREEN_OFF; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_ANSWER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_BUSY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_HANGUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_ICE_CANDIDATES; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_OFFER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SEND_OPAQUE_MESSAGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SETUP_FAILURE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SET_AUDIO_BLUETOOTH; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SET_AUDIO_SPEAKER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SET_ENABLE_VIDEO; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_SET_MUTE_AUDIO; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_START_INCOMING_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_START_OUTGOING_CALL; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_TURN_SERVER_UPDATE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_WIRED_HEADSET_CHANGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ANSWER_WITH_VIDEO; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_BLUETOOTH; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_IS_ALWAYS_TURN; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_MUTE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_RECIPIENT_IDS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_RESULT_RECEIVER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_SPEAKER; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.AnswerMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.HangupMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.OpaqueMessageMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedAnswerMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getAvailable; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getBroadcastFlag; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getCallId; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getCameraState; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getEnable; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getErrorCallState; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getErrorIdentityKey; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupCallEndReason; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupCallHash; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupMembershipToken; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getIceCandidates; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getIceServers; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getNullableRemotePeerFromMap; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getOfferMessageType; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getOrientationDegrees; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRemotePeer; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRemotePeerFromMap; + +/** + * Base WebRTC action processor and core of the calling state machine. As actions (as intents) + * are sent to the service, they are passed to an instance of the current state's action processor. + * Based on the state of the system, the action processor will either handle the event or do nothing. + * + * For example, the {@link OutgoingCallActionProcessor} responds to the the + * {@link #handleReceivedBusy(WebRtcServiceState, CallMetadata)} event but no others do. + * + * Processing of the actions occur in {@link #processAction(String, Intent, WebRtcServiceState)} and + * result in atomic state updates that are returned to the caller. Part of the state change can be + * the replacement of the current action processor. + */ +public abstract class WebRtcActionProcessor { + + protected final Context context; + protected final WebRtcInteractor webRtcInteractor; + protected final String tag; + + public WebRtcActionProcessor(@NonNull WebRtcInteractor webRtcInteractor, @NonNull String tag) { + this.context = webRtcInteractor.getWebRtcCallService(); + this.webRtcInteractor = webRtcInteractor; + this.tag = tag; + } + + public @NonNull String getTag() { + return tag; + } + + public @NonNull WebRtcServiceState processAction(@NonNull String action, @NonNull Intent intent, @NonNull WebRtcServiceState currentState) { + switch (action) { + case ACTION_IS_IN_CALL_QUERY: return handleIsInCallQuery(currentState, intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)); + + // Pre-Join Actions + case ACTION_PRE_JOIN_CALL: return handlePreJoinCall(currentState, getRemotePeer(intent)); + case ACTION_CANCEL_PRE_JOIN_CALL: return handleCancelPreJoinCall(currentState); + + // Outgoing Call Actions + case ACTION_OUTGOING_CALL: return handleOutgoingCall(currentState, getRemotePeer(intent), getOfferMessageType(intent)); + case ACTION_START_OUTGOING_CALL: return handleStartOutgoingCall(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_SEND_OFFER: return handleSendOffer(currentState, CallMetadata.fromIntent(intent), OfferMetadata.fromIntent(intent), getBroadcastFlag(intent)); + case ACTION_REMOTE_RINGING: return handleRemoteRinging(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_RECEIVE_ANSWER: return handleReceivedAnswer(currentState, CallMetadata.fromIntent(intent), AnswerMetadata.fromIntent(intent), ReceivedAnswerMetadata.fromIntent(intent)); + case ACTION_RECEIVE_BUSY: return handleReceivedBusy(currentState, CallMetadata.fromIntent(intent)); + + // Incoming Call Actions + case ACTION_RECEIVE_OFFER: return handleReceivedOffer(currentState, CallMetadata.fromIntent(intent), OfferMetadata.fromIntent(intent), ReceivedOfferMetadata.fromIntent(intent)); + case ACTION_RECEIVED_OFFER_EXPIRED: return handleReceivedOfferExpired(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_START_INCOMING_CALL: return handleStartIncomingCall(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_ACCEPT_CALL: return handleAcceptCall(currentState, intent.getBooleanExtra(EXTRA_ANSWER_WITH_VIDEO, false)); + case ACTION_LOCAL_RINGING: return handleLocalRinging(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_DENY_CALL: return handleDenyCall(currentState); + case ACTION_SEND_ANSWER: return handleSendAnswer(currentState, CallMetadata.fromIntent(intent), AnswerMetadata.fromIntent(intent), getBroadcastFlag(intent)); + + // Active Call Actions + case ACTION_CALL_CONNECTED: return handleCallConnected(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_RECEIVED_OFFER_WHILE_ACTIVE: return handleReceivedOfferWhileActive(currentState, getRemotePeerFromMap(intent, currentState)); + case ACTION_SEND_BUSY: return handleSendBusy(currentState, CallMetadata.fromIntent(intent), getBroadcastFlag(intent)); + case ACTION_CALL_CONCLUDED: return handleCallConcluded(currentState, getNullableRemotePeerFromMap(intent, currentState)); + case ACTION_REMOTE_VIDEO_ENABLE: return handleRemoteVideoEnable(currentState, getEnable(intent)); + case ACTION_RECEIVE_HANGUP: return handleReceivedHangup(currentState, CallMetadata.fromIntent(intent), HangupMetadata.fromIntent(intent)); + case ACTION_LOCAL_HANGUP: return handleLocalHangup(currentState); + case ACTION_SEND_HANGUP: return handleSendHangup(currentState, CallMetadata.fromIntent(intent), HangupMetadata.fromIntent(intent), getBroadcastFlag(intent)); + case ACTION_MESSAGE_SENT_SUCCESS: return handleMessageSentSuccess(currentState, getCallId(intent)); + case ACTION_MESSAGE_SENT_ERROR: return handleMessageSentError(currentState, getCallId(intent), getErrorCallState(intent), getErrorIdentityKey(intent)); + + // Call Setup Actions + case ACTION_RECEIVE_ICE_CANDIDATES: return handleReceivedIceCandidates(currentState, CallMetadata.fromIntent(intent), getIceCandidates(intent)); + case ACTION_SEND_ICE_CANDIDATES: return handleSendIceCandidates(currentState, CallMetadata.fromIntent(intent), getBroadcastFlag(intent), getIceCandidates(intent)); + case ACTION_TURN_SERVER_UPDATE: return handleTurnServerUpdate(currentState, getIceServers(intent), intent.getBooleanExtra(EXTRA_IS_ALWAYS_TURN, false)); + + // Local Device Actions + case ACTION_SET_ENABLE_VIDEO: return handleSetEnableVideo(currentState, getEnable(intent)); + case ACTION_SET_MUTE_AUDIO: return handleSetMuteAudio(currentState, intent.getBooleanExtra(EXTRA_MUTE, false)); + case ACTION_FLIP_CAMERA: return handleSetCameraFlip(currentState); + case ACTION_SCREEN_OFF: return handleScreenOffChange(currentState); + case ACTION_WIRED_HEADSET_CHANGE: return handleWiredHeadsetChange(currentState, getAvailable(intent)); + case ACTION_SET_AUDIO_SPEAKER: return handleSetSpeakerAudio(currentState, intent.getBooleanExtra(EXTRA_SPEAKER, false)); + case ACTION_SET_AUDIO_BLUETOOTH: return handleSetBluetoothAudio(currentState, intent.getBooleanExtra(EXTRA_BLUETOOTH, false)); + case ACTION_BLUETOOTH_CHANGE: return handleBluetoothChange(currentState, getAvailable(intent)); + case ACTION_CAMERA_SWITCH_COMPLETED: return handleCameraSwitchCompleted(currentState, getCameraState(intent)); + case ACTION_NETWORK_CHANGE: return handleNetworkChanged(currentState, getAvailable(intent)); + case ACTION_BANDWIDTH_MODE_UPDATE: return handleBandwidthModeUpdate(currentState); + case ACTION_ORIENTATION_CHANGED: return handleOrientationChanged(currentState, getOrientationDegrees(intent)); + + // End Call Actions + case ACTION_ENDED_REMOTE_HANGUP: + case ACTION_ENDED_REMOTE_HANGUP_ACCEPTED: + case ACTION_ENDED_REMOTE_HANGUP_BUSY: + case ACTION_ENDED_REMOTE_HANGUP_DECLINED: + case ACTION_ENDED_REMOTE_BUSY: + case ACTION_ENDED_REMOTE_HANGUP_NEED_PERMISSION: + case ACTION_ENDED_REMOTE_GLARE: return handleEndedRemote(currentState, action, getRemotePeerFromMap(intent, currentState)); + + // End Call Failure Actions + case ACTION_ENDED_TIMEOUT: + case ACTION_ENDED_INTERNAL_FAILURE: + case ACTION_ENDED_SIGNALING_FAILURE: + case ACTION_ENDED_CONNECTION_FAILURE: return handleEnded(currentState, action, getRemotePeerFromMap(intent, currentState)); + + // Local Call Failure Actions + case ACTION_SETUP_FAILURE: return handleSetupFailure(currentState, getCallId(intent)); + + // Group Calling + case ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED: return handleGroupLocalDeviceStateChanged(currentState); + case ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED: return handleGroupRemoteDeviceStateChanged(currentState); + case ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED: return handleGroupJoinedMembershipChanged(currentState); + case ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF: return handleGroupRequestMembershipProof(currentState, getGroupCallHash(intent), getGroupMembershipToken(intent)); + case ACTION_GROUP_REQUEST_UPDATE_MEMBERS: return handleGroupRequestUpdateMembers(currentState); + case ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS: return handleUpdateRenderedResolutions(currentState); + case ACTION_GROUP_CALL_ENDED: return handleGroupCallEnded(currentState, getGroupCallHash(intent), getGroupCallEndReason(intent)); + case ACTION_GROUP_CALL_PEEK: return handleGroupCallPeek(currentState, getRemotePeer(intent)); + case ACTION_GROUP_MESSAGE_SENT_ERROR: return handleGroupMessageSentError(currentState, getRemotePeer(intent), getErrorCallState(intent), getErrorIdentityKey(intent)); + case ACTION_GROUP_APPROVE_SAFETY_CHANGE: return handleGroupApproveSafetyNumberChange(currentState, RecipientId.fromSerializedList(intent.getStringExtra(EXTRA_RECIPIENT_IDS))); + + case ACTION_HTTP_SUCCESS: return handleHttpSuccess(currentState, HttpData.fromIntent(intent)); + case ACTION_HTTP_FAILURE: return handleHttpFailure(currentState, HttpData.fromIntent(intent)); + + case ACTION_SEND_OPAQUE_MESSAGE: return handleSendOpaqueMessage(currentState, OpaqueMessageMetadata.fromIntent(intent)); + case ACTION_RECEIVE_OPAQUE_MESSAGE: return handleReceivedOpaqueMessage(currentState, OpaqueMessageMetadata.fromIntent(intent)); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) { + if (resultReceiver != null) { + resultReceiver.send(0, null); + } + return currentState; + } + + //region Pre-Join + + protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handlePreJoinCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleCancelPreJoinCall(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleCancelPreJoinCall not processed"); + return currentState; + } + + //endregion Pre-Join + + //region Outgoing Call + + protected @NonNull WebRtcServiceState handleOutgoingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer, @NonNull OfferMessage.Type offerType) { + Log.i(tag, "handleOutgoingCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleStartOutgoingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleStartOutgoingCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSendOffer(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata, @NonNull OfferMetadata offerMetadata, boolean broadcast) { + Log.i(tag, "handleSendOffer not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleRemoteRinging(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleRemoteRinging not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedAnswer(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull AnswerMetadata answerMetadata, + @NonNull ReceivedAnswerMetadata receivedAnswerMetadata) + { + Log.i(tag, "handleReceivedAnswer not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedBusy(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata) { + Log.i(tag, "handleReceivedBusy not processed"); + return currentState; + } + + //endregion Outgoing call + + //region Incoming call + + protected @NonNull WebRtcServiceState handleReceivedOffer(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull OfferMetadata offerMetadata, + @NonNull ReceivedOfferMetadata receivedOfferMetadata) + { + Log.i(tag, "handleReceivedOffer(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + if (TelephonyUtil.isAnyPstnLineBusy(context)) { + Log.i(tag, "PSTN line is busy."); + currentState = currentState.getActionProcessor().handleSendBusy(currentState, callMetadata, true); + webRtcInteractor.insertMissedCall(callMetadata.getRemotePeer(), true, receivedOfferMetadata.getServerReceivedTimestamp(), offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL); + return currentState; + } + + if (!RecipientUtil.isCallRequestAccepted(context.getApplicationContext(), callMetadata.getRemotePeer().getRecipient())) { + Log.w(tag, "Caller is untrusted."); + currentState = currentState.getActionProcessor().handleSendHangup(currentState, callMetadata, WebRtcData.HangupMetadata.fromType(HangupMessage.Type.NEED_PERMISSION), true); + webRtcInteractor.insertMissedCall(callMetadata.getRemotePeer(), true, receivedOfferMetadata.getServerReceivedTimestamp(), offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL); + return currentState; + } + + if (offerMetadata.getOpaque() == null) { + Log.w(tag, "Opaque data is required."); + currentState = currentState.getActionProcessor().handleSendHangup(currentState, callMetadata, WebRtcData.HangupMetadata.fromType(HangupMessage.Type.NORMAL), true); + webRtcInteractor.insertMissedCall(callMetadata.getRemotePeer(), true, receivedOfferMetadata.getServerReceivedTimestamp(), offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL); + return currentState; + } + + Log.i(tag, "add remotePeer callId: " + callMetadata.getRemotePeer().getCallId() + " key: " + callMetadata.getRemotePeer().hashCode()); + + callMetadata.getRemotePeer().setCallStartTimestamp(receivedOfferMetadata.getServerReceivedTimestamp()); + + currentState = currentState.builder() + .changeCallSetupState() + .isRemoteVideoOffer(offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL) + .commit() + .changeCallInfoState() + .putRemotePeer(callMetadata.getRemotePeer()) + .build(); + + long messageAgeSec = Math.max(receivedOfferMetadata.getServerDeliveredTimestamp() - receivedOfferMetadata.getServerReceivedTimestamp(), 0) / 1000; + Log.i(tag, "messageAgeSec: " + messageAgeSec + ", serverReceivedTimestamp: " + receivedOfferMetadata.getServerReceivedTimestamp() + ", serverDeliveredTimestamp: " + receivedOfferMetadata.getServerDeliveredTimestamp()); + + try { + byte[] remoteIdentityKey = WebRtcUtil.getPublicKeyBytes(receivedOfferMetadata.getRemoteIdentityKey()); + byte[] localIdentityKey = WebRtcUtil.getPublicKeyBytes(IdentityKeyUtil.getIdentityKey(context).serialize()); + + webRtcInteractor.getCallManager().receivedOffer(callMetadata.getCallId(), + callMetadata.getRemotePeer(), + callMetadata.getRemoteDevice(), + offerMetadata.getOpaque(), + messageAgeSec, + WebRtcUtil.getCallMediaTypeFromOfferType(offerMetadata.getOfferType()), + 1, + receivedOfferMetadata.isMultiRing(), + true, + remoteIdentityKey, + localIdentityKey); + } catch (CallException | InvalidKeyException e) { + return callFailure(currentState, "Unable to process received offer: ", e); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedOfferExpired(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer) + { + Log.i(tag, "handleReceivedOfferExpired(): call_id: " + remotePeer.getCallId()); + + webRtcInteractor.insertMissedCall(remotePeer, true, remotePeer.getCallStartTimestamp(), currentState.getCallSetupState().isRemoteVideoOffer()); + + return terminate(currentState, remotePeer); + } + + protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleStartIncomingCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleAcceptCall(@NonNull WebRtcServiceState currentState, boolean answerWithVideo) { + Log.i(tag, "handleAcceptCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleLocalRinging(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleLocalRinging not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleDenyCall(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleDenyCall not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSendAnswer(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull AnswerMetadata answerMetadata, + boolean broadcast) + { + Log.i(tag, "handleSendAnswer not processed"); + return currentState; + } + + //endregion Incoming call + + //region Active call + + protected @NonNull WebRtcServiceState handleCallConnected(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleCallConnected not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleReceivedOfferWhileActive not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSendBusy(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata, boolean broadcast) { + Log.i(tag, "handleSendBusy(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + BusyMessage busyMessage = new BusyMessage(callMetadata.getCallId().longValue()); + Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forBusy(busyMessage, true, destinationDeviceId); + + webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); + + return currentState; + } + + protected @NonNull WebRtcServiceState handleCallConcluded(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + Log.i(tag, "handleCallConcluded not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleRemoteVideoEnable(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(tag, "handleRemoteVideoEnable not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedHangup(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull HangupMetadata hangupMetadata) + { + Log.i(tag, "handleReceivedHangup(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + try { + webRtcInteractor.getCallManager().receivedHangup(callMetadata.getCallId(), callMetadata.getRemoteDevice(), hangupMetadata.getCallHangupType(), hangupMetadata.getDeviceId()); + } catch (CallException e) { + return callFailure(currentState, "receivedHangup() failed: ", e); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleLocalHangup not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSendHangup(@NonNull WebRtcServiceState currentState, + @NonNull CallMetadata callMetadata, + @NonNull HangupMetadata hangupMetadata, + boolean broadcast) + { + Log.i(tag, "handleSendHangup(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice())); + + HangupMessage hangupMessage = new HangupMessage(callMetadata.getCallId().longValue(), hangupMetadata.getType(), hangupMetadata.getDeviceId(), hangupMetadata.isLegacy()); + Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forHangup(hangupMessage, true, destinationDeviceId); + + webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); + + return currentState; + } + + protected @NonNull WebRtcServiceState handleMessageSentSuccess(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) { + try { + webRtcInteractor.getCallManager().messageSent(callId); + } catch (CallException e) { + return callFailure(currentState, "callManager.messageSent() failed: ", e); + } + return currentState; + } + + protected @NonNull WebRtcServiceState handleMessageSentError(@NonNull WebRtcServiceState currentState, + @NonNull CallId callId, + @NonNull WebRtcViewModel.State errorCallState, + @NonNull Optional identityKey) { + Log.w(tag, "handleMessageSentError():"); + + try { + webRtcInteractor.getCallManager().messageSendFailure(callId); + } catch (CallException e) { + currentState = callFailure(currentState, "callManager.messageSendFailure() failed: ", e); + } + + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + if (activePeer == null) { + return currentState; + } + + WebRtcServiceStateBuilder builder = currentState.builder(); + + if (errorCallState == WebRtcViewModel.State.UNTRUSTED_IDENTITY) { + CallParticipant participant = Objects.requireNonNull(currentState.getCallInfoState().getRemoteCallParticipant(activePeer.getRecipient())); + CallParticipant untrusted = participant.withIdentityKey(identityKey.orNull()); + + builder.changeCallInfoState() + .callState(WebRtcViewModel.State.UNTRUSTED_IDENTITY) + .putParticipant(activePeer.getRecipient(), untrusted) + .commit(); + } else { + builder.changeCallInfoState() + .callState(errorCallState) + .commit(); + } + + return builder.build(); + } + + //endregion Active call + + //region Call setup + + protected @NonNull WebRtcServiceState handleSendIceCandidates(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata, boolean broadcast, @NonNull ArrayList iceCandidates) { + Log.i(tag, "handleSendIceCandidates not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedIceCandidates(@NonNull WebRtcServiceState currentState, @NonNull CallMetadata callMetadata, @NonNull ArrayList iceCandidateParcels) { + Log.i(tag, "handleReceivedIceCandidates(): id: " + callMetadata.getCallId().format(callMetadata.getRemoteDevice()) + ", count: " + iceCandidateParcels.size()); + + LinkedList iceCandidates = new LinkedList<>(); + for (IceCandidateParcel parcel : iceCandidateParcels) { + iceCandidates.add(parcel.getIceCandidate()); + } + + try { + webRtcInteractor.getCallManager().receivedIceCandidates(callMetadata.getCallId(), callMetadata.getRemoteDevice(), iceCandidates); + } catch (CallException e) { + return callFailure(currentState, "receivedIceCandidates() failed: ", e); + } + + return currentState; + } + + public @NonNull WebRtcServiceState handleTurnServerUpdate(@NonNull WebRtcServiceState currentState, @NonNull List iceServers, boolean isAlwaysTurn) { + Log.i(tag, "handleTurnServerUpdate not processed"); + return currentState; + } + + //endregion Call setup + + //region Local device + + protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { + Log.i(tag, "handleSetEnableVideo not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) { + Log.i(tag, "handleSetMuteAudio not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetSpeakerAudio(@NonNull WebRtcServiceState currentState, boolean isSpeaker) { + Log.i(tag, "handleSetSpeakerAudio not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetBluetoothAudio(@NonNull WebRtcServiceState currentState, boolean isBluetooth) { + Log.i(tag, "handleSetBluetoothAudio not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetCameraFlip(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleSetCameraFlip not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleScreenOffChange(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleScreenOffChange not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleBluetoothChange(@NonNull WebRtcServiceState currentState, boolean available) { + Log.i(tag, "handleBluetoothChange not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleWiredHeadsetChange(@NonNull WebRtcServiceState currentState, boolean present) { + Log.i(tag, "handleWiredHeadsetChange not processed"); + return currentState; + } + + public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) { + Log.i(tag, "handleCameraSwitchCompleted not processed"); + return currentState; + } + + public @NonNull WebRtcServiceState handleNetworkChanged(@NonNull WebRtcServiceState currentState, boolean available) { + Log.i(tag, "handleNetworkChanged not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleBandwidthModeUpdate(@NonNull WebRtcServiceState currentState) { + try { + webRtcInteractor.getCallManager().updateBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + } catch (CallException e) { + Log.i(tag, "handleBandwidthModeUpdate: could not update bandwidth mode."); + } + + return currentState; + } + + protected @NonNull WebRtcServiceState handleOrientationChanged(@NonNull WebRtcServiceState currentState, int orientationDegrees) { + Camera camera = currentState.getVideoState().getCamera(); + if (camera != null) { + camera.setOrientation(orientationDegrees); + } + + return currentState.builder() + .changeLocalDeviceState() + .setOrientation(Orientation.fromDegrees(orientationDegrees)) + .build(); + } + + //endregion Local device + + //region End call + + protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleEndedRemote not processed"); + return currentState; + } + + //endregion End call + + //region End call failure + + protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull String action, @NonNull RemotePeer remotePeer) { + Log.i(tag, "handleEnded not processed"); + return currentState; + } + + //endregion + + //region Local call failure + + protected @NonNull WebRtcServiceState handleSetupFailure(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) { + Log.i(tag, "handleSetupFailure not processed"); + return currentState; + } + + //endregion + + //region Global call operations + + public @NonNull WebRtcServiceState callFailure(@NonNull WebRtcServiceState currentState, + @Nullable String message, + @Nullable Throwable error) + { + Log.w(tag, "callFailure(): " + message, error); + + WebRtcServiceStateBuilder builder = currentState.builder(); + + if (currentState.getCallInfoState().getActivePeer() != null) { + builder.changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED); + } + + try { + webRtcInteractor.getCallManager().reset(); + } catch (CallException e) { + Log.w(tag, "Unable to reset call manager: ", e); + } + + currentState = builder.changeCallInfoState().clearPeerMap().build(); + return terminate(currentState, currentState.getCallInfoState().getActivePeer()); + } + + public synchronized @NonNull WebRtcServiceState terminate(@NonNull WebRtcServiceState currentState, @Nullable RemotePeer remotePeer) { + Log.i(tag, "terminate():"); + + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + + if (activePeer == null) { + Log.i(tag, "skipping with no active peer"); + return currentState; + } + + if (!activePeer.callIdEquals(remotePeer)) { + Log.i(tag, "skipping remotePeer is not active peer"); + return currentState; + } + + webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); + webRtcInteractor.stopForegroundService(); + boolean playDisconnectSound = (activePeer.getState() == CallState.DIALING) || + (activePeer.getState() == CallState.REMOTE_RINGING) || + (activePeer.getState() == CallState.RECEIVED_BUSY) || + (activePeer.getState() == CallState.CONNECTED); + webRtcInteractor.stopAudio(playDisconnectSound); + webRtcInteractor.setWantsBluetoothConnection(false); + + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); + + return WebRtcVideoUtil.deinitializeVideo(currentState) + .builder() + .changeCallInfoState() + .activePeer(null) + .commit() + .actionProcessor(currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_DISCONNECTED ? new DisconnectingCallActionProcessor(webRtcInteractor) : new IdleActionProcessor(webRtcInteractor)) + .terminate() + .build(); + } + + //endregion + + //region Group Calling + + protected @NonNull WebRtcServiceState handleGroupLocalDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupLocalDeviceStateChanged not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupRemoteDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupRemoteDeviceStateChanged not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupJoinedMembershipChanged not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull byte[] groupMembershipToken) { + Log.i(tag, "handleGroupRequestMembershipProof not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupRequestUpdateMembers(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupRequestUpdateMembers not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleUpdateRenderedResolutions(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleUpdateRenderedResolutions not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupCallEnded(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { + Log.i(tag, "handleGroupCallEnded not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupCallPeek(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { + webRtcInteractor.peekGroupCall(remotePeer.getId()); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupMessageSentError(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeer, + @NonNull WebRtcViewModel.State errorCallState, + @NonNull Optional identityKey) + { + Log.i(tag, "handleGroupMessageSentError not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupApproveSafetyNumberChange(@NonNull WebRtcServiceState currentState, + @NonNull List recipientIds) + { + Log.i(tag, "handleGroupApproveSafetyNumberChange not processed"); + return currentState; + } + + //endregion + + protected @NonNull WebRtcServiceState handleHttpSuccess(@NonNull WebRtcServiceState currentState, @NonNull HttpData httpData) { + try { + webRtcInteractor.getCallManager().receivedHttpResponse(httpData.getRequestId(), httpData.getStatus(), httpData.getBody() != null ? httpData.getBody() : new byte[0]); + } catch (CallException e) { + return callFailure(currentState, "Unable to process received http response", e); + } + return currentState; + } + + protected @NonNull WebRtcServiceState handleHttpFailure(@NonNull WebRtcServiceState currentState, @NonNull HttpData httpData) { + try { + webRtcInteractor.getCallManager().httpRequestFailed(httpData.getRequestId()); + } catch (CallException e) { + return callFailure(currentState, "Unable to process received http response", e); + } + return currentState; + } + + protected @NonNull WebRtcServiceState handleSendOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull OpaqueMessageMetadata opaqueMessageMetadata) { + Log.i(tag, "handleSendOpaqueMessage not processed"); + + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull OpaqueMessageMetadata opaqueMessageMetadata) { + Log.i(tag, "handleReceivedOpaqueMessage not processed"); + + return currentState; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java new file mode 100644 index 00000000..2c04559c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java @@ -0,0 +1,312 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.ringrtc.CallId; +import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +import java.util.UUID; + +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_ERA_ID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_GROUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_SENDER; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_DEVICE_ID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_IS_LEGACY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_TYPE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HTTP_REQUEST_ID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HTTP_RESPONSE_BODY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HTTP_RESPONSE_STATUS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_MESSAGE_AGE_SECONDS; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_SERVER_DELIVERED_TIMESTAMP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_SERVER_RECEIVED_TIMESTAMP; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRecipientId; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRemoteDevice; + +/** + * Collection of classes to ease parsing data from intents and passing said data + * around. + */ +public class WebRtcData { + + /** + * Low-level metadata Information about the call. + */ + static class CallMetadata { + private final @NonNull RemotePeer remotePeer; + private final @NonNull CallId callId; + private final int remoteDevice; + + public static @NonNull CallMetadata fromIntent(@NonNull Intent intent) { + return new CallMetadata(WebRtcIntentParser.getRemotePeer(intent), WebRtcIntentParser.getCallId(intent), WebRtcIntentParser.getRemoteDevice(intent)); + } + + private CallMetadata(@NonNull RemotePeer remotePeer, @NonNull CallId callId, int remoteDevice) { + this.remotePeer = remotePeer; + this.callId = callId; + this.remoteDevice = remoteDevice; + } + + @NonNull RemotePeer getRemotePeer() { + return remotePeer; + } + + @NonNull CallId getCallId() { + return callId; + } + + int getRemoteDevice() { + return remoteDevice; + } + } + + /** + * Metadata for a call offer to be sent or received. + */ + static class OfferMetadata { + private final @Nullable byte[] opaque; + private final @Nullable String sdp; + private final @NonNull OfferMessage.Type offerType; + + static @NonNull OfferMetadata fromIntent(@NonNull Intent intent) { + return new OfferMetadata(WebRtcIntentParser.getOfferOpaque(intent), + WebRtcIntentParser.getOfferSdp(intent), + WebRtcIntentParser.getOfferMessageType(intent)); + } + + private OfferMetadata(@Nullable byte[] opaque, @Nullable String sdp, @NonNull OfferMessage.Type offerType) { + this.opaque = opaque; + this.sdp = sdp; + this.offerType = offerType; + } + + @Nullable byte[] getOpaque() { + return opaque; + } + + @Nullable String getSdp() { + return sdp; + } + + @NonNull OfferMessage.Type getOfferType() { + return offerType; + } + } + + /** + * Additional metadata for a received call. + */ + static class ReceivedOfferMetadata { + private final @NonNull byte[] remoteIdentityKey; + private final long serverReceivedTimestamp; + private final long serverDeliveredTimestamp; + private final boolean isMultiRing; + + static @NonNull ReceivedOfferMetadata fromIntent(@NonNull Intent intent) { + return new ReceivedOfferMetadata(WebRtcIntentParser.getRemoteIdentityKey(intent), + intent.getLongExtra(EXTRA_SERVER_RECEIVED_TIMESTAMP, -1), + intent.getLongExtra(EXTRA_SERVER_DELIVERED_TIMESTAMP, -1), + WebRtcIntentParser.getMultiRingFlag(intent)); + } + + ReceivedOfferMetadata(@NonNull byte[] remoteIdentityKey, long serverReceivedTimestamp, long serverDeliveredTimestamp, boolean isMultiRing) { + this.remoteIdentityKey = remoteIdentityKey; + this.serverReceivedTimestamp = serverReceivedTimestamp; + this.serverDeliveredTimestamp = serverDeliveredTimestamp; + this.isMultiRing = isMultiRing; + } + + @NonNull byte[] getRemoteIdentityKey() { + return remoteIdentityKey; + } + + long getServerReceivedTimestamp() { + return serverReceivedTimestamp; + } + + long getServerDeliveredTimestamp() { + return serverDeliveredTimestamp; + } + + boolean isMultiRing() { + return isMultiRing; + } + } + + /** + * Metadata for an answer to be sent or received. + */ + static class AnswerMetadata { + private final @Nullable byte[] opaque; + private final @Nullable String sdp; + + static @NonNull AnswerMetadata fromIntent(@NonNull Intent intent) { + return new AnswerMetadata(WebRtcIntentParser.getAnswerOpaque(intent), WebRtcIntentParser.getAnswerSdp(intent)); + } + + private AnswerMetadata(@Nullable byte[] opaque, @Nullable String sdp) { + this.opaque = opaque; + this.sdp = sdp; + } + + @Nullable byte[] getOpaque() { + return opaque; + } + + @Nullable String getSdp() { + return sdp; + } + } + + /** + * Additional metadata for a received answer. + */ + static class ReceivedAnswerMetadata { + private final @NonNull byte[] remoteIdentityKey; + private final boolean isMultiRing; + + static @NonNull ReceivedAnswerMetadata fromIntent(@NonNull Intent intent) { + return new ReceivedAnswerMetadata(WebRtcIntentParser.getRemoteIdentityKey(intent), WebRtcIntentParser.getMultiRingFlag(intent)); + } + + ReceivedAnswerMetadata(@NonNull byte[] remoteIdentityKey, boolean isMultiRing) { + this.remoteIdentityKey = remoteIdentityKey; + this.isMultiRing = isMultiRing; + } + + @NonNull byte[] getRemoteIdentityKey() { + return remoteIdentityKey; + } + + boolean isMultiRing() { + return isMultiRing; + } + } + + /** + * Metadata for a remote or local hangup. + */ + static class HangupMetadata { + private final @NonNull HangupMessage.Type type; + private final boolean isLegacy; + private final int deviceId; + + static @NonNull HangupMetadata fromIntent(@NonNull Intent intent) { + return new HangupMetadata(HangupMessage.Type.fromCode(intent.getStringExtra(EXTRA_HANGUP_TYPE)), + intent.getBooleanExtra(EXTRA_HANGUP_IS_LEGACY, true), + intent.getIntExtra(EXTRA_HANGUP_DEVICE_ID, 0)); + } + + static @NonNull HangupMetadata fromType(@NonNull HangupMessage.Type type) { + return new HangupMetadata(type, true, 0); + } + + HangupMetadata(@NonNull HangupMessage.Type type, boolean isLegacy, int deviceId) { + this.type = type; + this.isLegacy = isLegacy; + this.deviceId = deviceId; + } + + @NonNull HangupMessage.Type getType() { + return type; + } + + @NonNull CallManager.HangupType getCallHangupType() { + switch (type) { + case ACCEPTED: return CallManager.HangupType.ACCEPTED; + case BUSY: return CallManager.HangupType.BUSY; + case NORMAL: return CallManager.HangupType.NORMAL; + case DECLINED: return CallManager.HangupType.DECLINED; + case NEED_PERMISSION: return CallManager.HangupType.NEED_PERMISSION; + default: throw new IllegalArgumentException("Unexpected hangup type: " + type); + } + } + + boolean isLegacy() { + return isLegacy; + } + + int getDeviceId() { + return deviceId; + } + } + + /** + * Http response data. + */ + static class HttpData { + private final long requestId; + private final int status; + private final byte[] body; + + static @NonNull HttpData fromIntent(@NonNull Intent intent) { + return new HttpData(intent.getLongExtra(EXTRA_HTTP_REQUEST_ID, -1), + intent.getIntExtra(EXTRA_HTTP_RESPONSE_STATUS, -1), + intent.getByteArrayExtra(EXTRA_HTTP_RESPONSE_BODY)); + } + + HttpData(long requestId, int status, @Nullable byte[] body) { + this.requestId = requestId; + this.status = status; + this.body = body; + } + + long getRequestId() { + return requestId; + } + + int getStatus() { + return status; + } + + @Nullable byte[] getBody() { + return body; + } + } + + /** + * An opaque calling message. + */ + static class OpaqueMessageMetadata { + private final UUID uuid; + private final byte[] opaque; + private final int remoteDeviceId; + private final long messageAgeSeconds; + + static @NonNull OpaqueMessageMetadata fromIntent(@NonNull Intent intent) { + return new OpaqueMessageMetadata(WebRtcIntentParser.getUuid(intent), + WebRtcIntentParser.getOpaque(intent), + getRemoteDevice(intent), + intent.getLongExtra(EXTRA_MESSAGE_AGE_SECONDS, 0)); + } + + OpaqueMessageMetadata(@NonNull UUID uuid, @NonNull byte[] opaque, int remoteDeviceId, long messageAgeSeconds) { + this.uuid = uuid; + this.opaque = opaque; + this.remoteDeviceId = remoteDeviceId; + this.messageAgeSeconds = messageAgeSeconds; + } + + @NonNull UUID getUuid() { + return uuid; + } + + @NonNull byte[] getOpaque() { + return opaque; + } + + int getRemoteDeviceId() { + return remoteDeviceId; + } + + long getMessageAgeSeconds() { + return messageAgeSeconds; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java new file mode 100644 index 00000000..9da8a2f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java @@ -0,0 +1,216 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallId; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.ringrtc.TurnServerInfoParcel; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.webrtc.PeerConnection; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ANSWER_OPAQUE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ANSWER_SDP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_AVAILABLE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_BROADCAST; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_CALL_ID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_CAMERA_STATE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ENABLE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ERROR_CALL_STATE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ERROR_IDENTITY_KEY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_END_REASON; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_HASH; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_EXTERNAL_TOKEN; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ICE_CANDIDATES; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_MULTI_RING; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_OFFER_OPAQUE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_OFFER_SDP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_OFFER_TYPE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_OPAQUE_MESSAGE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ORIENTATION_DEGREES; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_REMOTE_DEVICE; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_REMOTE_IDENTITY_KEY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_REMOTE_PEER_KEY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_TURN_SERVER_INFO; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_UUID; + +/** + * Helper to parse the various attributes out of intents passed to the service. + */ +public final class WebRtcIntentParser { + + private static final String TAG = Log.tag(WebRtcIntentParser.class); + + private WebRtcIntentParser() {} + + public static @NonNull CallId getCallId(@NonNull Intent intent) { + return new CallId(intent.getLongExtra(EXTRA_CALL_ID, -1)); + } + + public static int getRemoteDevice(@NonNull Intent intent) { + return intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1); + } + + public static @NonNull RemotePeer getRemotePeer(@NonNull Intent intent) { + RemotePeer remotePeer = intent.getParcelableExtra(WebRtcCallService.EXTRA_REMOTE_PEER); + if (remotePeer == null) { + throw new AssertionError("No RemotePeer in intent!"); + } + return remotePeer; + } + + public static @NonNull RemotePeer getRemotePeerFromMap(@NonNull Intent intent, @NonNull WebRtcServiceState currentState) { + RemotePeer remotePeer = getNullableRemotePeerFromMap(intent, currentState); + + if (remotePeer == null) { + throw new AssertionError("No RemotePeer in map for key: " + getRemotePeerKey(intent) + "!"); + } + + return remotePeer; + } + + public static @Nullable RemotePeer getNullableRemotePeerFromMap(@NonNull Intent intent, @NonNull WebRtcServiceState currentState) { + return currentState.getCallInfoState().getPeer(getRemotePeerKey(intent)); + } + + public static int getRemotePeerKey(@NonNull Intent intent) { + if (!intent.hasExtra(EXTRA_REMOTE_PEER_KEY)) { + throw new AssertionError("No RemotePeer key in intent!"); + } + + // The default of -1 should never be applied since the key exists. + return intent.getIntExtra(EXTRA_REMOTE_PEER_KEY, -1); + } + + public static boolean getMultiRingFlag(@NonNull Intent intent) { + return intent.getBooleanExtra(EXTRA_MULTI_RING, false); + } + + public static @NonNull byte[] getRemoteIdentityKey(@NonNull Intent intent) { + return Objects.requireNonNull(intent.getByteArrayExtra(EXTRA_REMOTE_IDENTITY_KEY)); + } + + public static @Nullable String getAnswerSdp(@NonNull Intent intent) { + return intent.getStringExtra(EXTRA_ANSWER_SDP); + } + + public static @Nullable String getOfferSdp(@NonNull Intent intent) { + return intent.getStringExtra(EXTRA_OFFER_SDP); + } + + public static @Nullable byte[] getAnswerOpaque(@NonNull Intent intent) { + return intent.getByteArrayExtra(EXTRA_ANSWER_OPAQUE); + } + + public static @Nullable byte[] getOfferOpaque(@NonNull Intent intent) { + return intent.getByteArrayExtra(EXTRA_OFFER_OPAQUE); + } + + public static @NonNull byte[] getOpaque(@NonNull Intent intent) { + return Objects.requireNonNull(intent.getByteArrayExtra(EXTRA_OPAQUE_MESSAGE)); + } + + public static @NonNull UUID getUuid(@NonNull Intent intent) { + return UuidUtil.parseOrThrow(intent.getStringExtra(EXTRA_UUID)); + } + + public static boolean getBroadcastFlag(@NonNull Intent intent) { + return intent.getBooleanExtra(EXTRA_BROADCAST, false); + } + + public static boolean getAvailable(@NonNull Intent intent) { + return intent.getBooleanExtra(EXTRA_AVAILABLE, false); + } + + public static int getOrientationDegrees(@NonNull Intent intent) { + return intent.getIntExtra(EXTRA_ORIENTATION_DEGREES, 0); + } + + public static @NonNull ArrayList getIceCandidates(@NonNull Intent intent) { + return Objects.requireNonNull(intent.getParcelableArrayListExtra(EXTRA_ICE_CANDIDATES)); + } + + public static @NonNull List getIceServers(@NonNull Intent intent) { + TurnServerInfoParcel turnServerInfoParcel = Objects.requireNonNull(intent.getParcelableExtra(EXTRA_TURN_SERVER_INFO)); + List iceServers = new LinkedList<>(); + iceServers.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer()); + for (String url : turnServerInfoParcel.getUrls()) { + Log.i(TAG, "ice_server: " + url); + if (url.startsWith("turn")) { + iceServers.add(PeerConnection.IceServer.builder(url) + .setUsername(turnServerInfoParcel.getUsername()) + .setPassword(turnServerInfoParcel.getPassword()) + .createIceServer()); + } else { + iceServers.add(PeerConnection.IceServer.builder(url).createIceServer()); + } + } + return iceServers; + } + + public static @NonNull OfferMessage.Type getOfferMessageType(@NonNull Intent intent) { + return OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE)); + } + + public static boolean getEnable(@NonNull Intent intent) { + return intent.getBooleanExtra(EXTRA_ENABLE, false); + } + + public static @NonNull byte[] getGroupMembershipToken(@NonNull Intent intent) { + return Objects.requireNonNull(intent.getByteArrayExtra(EXTRA_GROUP_EXTERNAL_TOKEN)); + } + + public static @NonNull CameraState getCameraState(@NonNull Intent intent) { + return Objects.requireNonNull(intent.getParcelableExtra(EXTRA_CAMERA_STATE)); + } + + public static @NonNull WebRtcViewModel.State getErrorCallState(@NonNull Intent intent) { + return (WebRtcViewModel.State) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_ERROR_CALL_STATE)); + } + + public static @NonNull Optional getErrorIdentityKey(@NonNull Intent intent) { + IdentityKeyParcelable identityKeyParcelable = intent.getParcelableExtra(EXTRA_ERROR_IDENTITY_KEY); + if (identityKeyParcelable != null) { + return Optional.fromNullable(identityKeyParcelable.get()); + } + return Optional.absent(); + } + + public static int getGroupCallHash(@NonNull Intent intent) { + return intent.getIntExtra(EXTRA_GROUP_CALL_HASH, 0); + } + + public static @NonNull GroupCall.GroupCallEndReason getGroupCallEndReason(@NonNull Intent intent) { + int ordinal = intent.getIntExtra(EXTRA_GROUP_CALL_END_REASON, -1); + + if (ordinal >= 0 && ordinal < GroupCall.GroupCallEndReason.values().length) { + return GroupCall.GroupCallEndReason.values()[ordinal]; + } + + return GroupCall.GroupCallEndReason.DEVICE_EXPLICITLY_DISCONNECTED; + } + + public static @NonNull RecipientId getRecipientId(@NonNull Intent intent, @NonNull String name) { + return RecipientId.from(Objects.requireNonNull(intent.getStringExtra(name))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java new file mode 100644 index 00000000..465177b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java @@ -0,0 +1,160 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.CameraEventListener; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.webrtc.audio.BluetoothStateManager; +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.util.Collection; +import java.util.UUID; + +/** + * Serves as the bridge between the action processing framework as the WebRTC service. Attempts + * to minimize direct access to various managers by providing a simple proxy to them. Due to the + * heavy use of {@link CallManager} throughout, it was exempted from the rule. + */ +public class WebRtcInteractor { + + @NonNull private final WebRtcCallService webRtcCallService; + @NonNull private final CallManager callManager; + @NonNull private final LockManager lockManager; + @NonNull private final SignalAudioManager audioManager; + @NonNull private final BluetoothStateManager bluetoothStateManager; + @NonNull private final CameraEventListener cameraEventListener; + @NonNull private final GroupCall.Observer groupCallObserver; + + public WebRtcInteractor(@NonNull WebRtcCallService webRtcCallService, + @NonNull CallManager callManager, + @NonNull LockManager lockManager, + @NonNull SignalAudioManager audioManager, + @NonNull BluetoothStateManager bluetoothStateManager, + @NonNull CameraEventListener cameraEventListener, + @NonNull GroupCall.Observer groupCallObserver) + { + this.webRtcCallService = webRtcCallService; + this.callManager = callManager; + this.lockManager = lockManager; + this.audioManager = audioManager; + this.bluetoothStateManager = bluetoothStateManager; + this.cameraEventListener = cameraEventListener; + this.groupCallObserver = groupCallObserver; + } + + @NonNull CameraEventListener getCameraEventListener() { + return cameraEventListener; + } + + @NonNull CallManager getCallManager() { + return callManager; + } + + @NonNull WebRtcCallService getWebRtcCallService() { + return webRtcCallService; + } + + @NonNull GroupCall.Observer getGroupCallObserver() { + return groupCallObserver; + } + + void setWantsBluetoothConnection(boolean enabled) { + bluetoothStateManager.setWantsConnection(enabled); + } + + void updatePhoneState(@NonNull LockManager.PhoneState phoneState) { + lockManager.updatePhoneState(phoneState); + } + + void sendMessage(@NonNull WebRtcServiceState state) { + webRtcCallService.sendMessage(state); + } + + void sendCallMessage(@NonNull RemotePeer remotePeer, @NonNull SignalServiceCallMessage callMessage) { + webRtcCallService.sendCallMessage(remotePeer, callMessage); + } + + void sendOpaqueCallMessage(@NonNull UUID uuid, @NonNull SignalServiceCallMessage callMessage) { + webRtcCallService.sendOpaqueCallMessage(uuid, callMessage); + } + + void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId) { + webRtcCallService.sendGroupCallMessage(recipient, groupCallEraId); + } + + void updateGroupCallUpdateMessage(@NonNull RecipientId groupId, @Nullable String groupCallEraId, @NonNull Collection joinedMembers, boolean isCallFull) { + webRtcCallService.updateGroupCallUpdateMessage(groupId, groupCallEraId, joinedMembers, isCallFull); + } + + void setCallInProgressNotification(int type, @NonNull RemotePeer remotePeer) { + webRtcCallService.setCallInProgressNotification(type, remotePeer.getRecipient()); + } + + void setCallInProgressNotification(int type, @NonNull Recipient recipient) { + webRtcCallService.setCallInProgressNotification(type, recipient); + } + + void retrieveTurnServers(@NonNull RemotePeer remotePeer) { + webRtcCallService.retrieveTurnServers(remotePeer); + } + + void stopForegroundService() { + webRtcCallService.stopForeground(true); + } + + void insertMissedCall(@NonNull RemotePeer remotePeer, boolean signal, long timestamp, boolean isVideoOffer) { + webRtcCallService.insertMissedCall(remotePeer, signal, timestamp, isVideoOffer); + } + + void startWebRtcCallActivityIfPossible() { + webRtcCallService.startCallCardActivityIfPossible(); + } + + void registerPowerButtonReceiver() { + webRtcCallService.registerPowerButtonReceiver(); + } + + void unregisterPowerButtonReceiver() { + webRtcCallService.unregisterPowerButtonReceiver(); + } + + void silenceIncomingRinger() { + audioManager.silenceIncomingRinger(); + } + + void initializeAudioForCall() { + audioManager.initializeAudioForCall(); + } + + void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) { + audioManager.startIncomingRinger(ringtoneUri, vibrate); + } + + void startOutgoingRinger(@NonNull OutgoingRinger.Type type) { + audioManager.startOutgoingRinger(type); + } + + void stopAudio(boolean playDisconnect) { + audioManager.stop(playDisconnect); + } + + void startAudioCommunication(boolean preserveSpeakerphone) { + audioManager.startCommunication(preserveSpeakerphone); + } + + void peekGroupCall(@NonNull RecipientId recipientId) { + webRtcCallService.peekGroupCall(recipientId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java new file mode 100644 index 00000000..52d07a43 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Context; +import android.media.AudioManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +/** + * Calling specific helpers. + */ +public final class WebRtcUtil { + + private WebRtcUtil() {} + + public static @NonNull byte[] getPublicKeyBytes(@NonNull byte[] identityKey) throws InvalidKeyException { + ECPublicKey key = Curve.decodePoint(identityKey, 0); + return key.getPublicKeyBytes(); + } + + public static @NonNull LockManager.PhoneState getInCallPhoneState(@NonNull Context context) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + if (audioManager.isSpeakerphoneOn() || audioManager.isBluetoothScoOn() || audioManager.isWiredHeadsetOn()) { + return LockManager.PhoneState.IN_HANDS_FREE_CALL; + } else { + return LockManager.PhoneState.IN_CALL; + } + } + + public static @NonNull CallManager.CallMediaType getCallMediaTypeFromOfferType(@NonNull OfferMessage.Type offerType) { + return offerType == OfferMessage.Type.VIDEO_CALL ? CallManager.CallMediaType.VIDEO_CALL : CallManager.CallMediaType.AUDIO_CALL; + } + + public static void enableSpeakerPhoneIfNeeded(@NonNull Context context, boolean enable) { + if (!enable) { + return; + } + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + //noinspection deprecation + boolean shouldEnable = !(androidAudioManager.isSpeakerphoneOn() || androidAudioManager.isBluetoothScoOn() || androidAudioManager.isWiredHeadsetOn()); + + if (shouldEnable) { + androidAudioManager.setSpeakerphoneOn(true); + } + } + + public static @NonNull WebRtcViewModel.GroupCallState groupCallStateForConnection(@NonNull GroupCall.ConnectionState connectionState) { + switch (connectionState) { + case CONNECTING: + return WebRtcViewModel.GroupCallState.CONNECTING; + case CONNECTED: + return WebRtcViewModel.GroupCallState.CONNECTED; + case RECONNECTING: + return WebRtcViewModel.GroupCallState.RECONNECTING; + default: + return WebRtcViewModel.GroupCallState.DISCONNECTED; + } + } + + public static @Nullable String getGroupCallEraId(@Nullable GroupCall groupCall) { + if (groupCall == null) { + return null; + } + + PeekInfo peekInfo = groupCall.getPeekInfo(); + return peekInfo != null ? peekInfo.getEraId() : null; + } + + public static boolean isCallFull(@Nullable PeekInfo peekInfo) { + return peekInfo != null && peekInfo.getMaxDevices() != null && peekInfo.getDeviceCount() >= peekInfo.getMaxDevices(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcVideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcVideoUtil.java new file mode 100644 index 00000000..2b113e0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcVideoUtil.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.components.webrtc.OrientationAwareVideoSink; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.ringrtc.CameraEventListener; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +import org.thoughtcrime.securesms.util.Util; +import org.webrtc.CapturerObserver; +import org.webrtc.EglBase; +import org.webrtc.VideoFrame; +import org.webrtc.VideoSink; + +/** + * Helper for initializing, reinitializing, and deinitializing the camera and it's related + * infrastructure. + */ +public final class WebRtcVideoUtil { + + private WebRtcVideoUtil() {} + + public static @NonNull WebRtcServiceState initializeVideo(@NonNull Context context, + @NonNull CameraEventListener cameraEventListener, + @NonNull WebRtcServiceState currentState) + { + final WebRtcServiceStateBuilder builder = currentState.builder(); + + Util.runOnMainSync(() -> { + EglBase eglBase = EglBase.create(); + BroadcastVideoSink localSink = new BroadcastVideoSink(eglBase); + Camera camera = new Camera(context, cameraEventListener, eglBase, CameraState.Direction.FRONT); + + camera.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees()); + + builder.changeVideoState() + .eglBase(eglBase) + .localSink(localSink) + .camera(camera) + .commit() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .commit(); + }); + + return builder.build(); + } + + public static @NonNull WebRtcServiceState reinitializeCamera(@NonNull Context context, + @NonNull CameraEventListener cameraEventListener, + @NonNull WebRtcServiceState currentState) + { + final WebRtcServiceStateBuilder builder = currentState.builder(); + + Util.runOnMainSync(() -> { + Camera camera = currentState.getVideoState().requireCamera(); + camera.setEnabled(false); + camera.dispose(); + + camera = new Camera(context, + cameraEventListener, + currentState.getVideoState().requireEglBase(), + currentState.getLocalDeviceState().getCameraState().getActiveDirection()); + + camera.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees()); + + builder.changeVideoState() + .camera(camera) + .commit() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .commit(); + }); + + return builder.build(); + } + + public static @NonNull WebRtcServiceState deinitializeVideo(@NonNull WebRtcServiceState currentState) { + Camera camera = currentState.getVideoState().getCamera(); + if (camera != null) { + camera.dispose(); + } + + EglBase eglBase = currentState.getVideoState().getEglBase(); + if (eglBase != null) { + eglBase.release(); + } + + return currentState.builder() + .changeVideoState() + .eglBase(null) + .camera(null) + .localSink(null) + .commit() + .changeLocalDeviceState() + .cameraState(CameraState.UNKNOWN) + .build(); + } + + public static @NonNull WebRtcServiceState initializeVanityCamera(@NonNull WebRtcServiceState currentState) { + Camera camera = currentState.getVideoState().requireCamera(); + VideoSink sink = new OrientationAwareVideoSink(currentState.getVideoState().requireLocalSink()); + + if (camera.hasCapturer()) { + camera.initCapturer(new CapturerObserver() { + @Override + public void onFrameCaptured(VideoFrame videoFrame) { + sink.onFrame(videoFrame); + } + + @Override + public void onCapturerStarted(boolean success) {} + + @Override + public void onCapturerStopped() {} + }); + camera.setEnabled(true); + } + + return currentState.builder() + .changeLocalDeviceState() + .cameraState(camera.getCameraState()) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java new file mode 100644 index 00000000..6e2a65a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.service.webrtc.collections; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; + +import com.annimon.stream.ComparatorCompat; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Represents the participants to be displayed in the grid at any given time. + */ +public class ParticipantCollection { + + private static final Comparator LEAST_RECENTLY_ADDED = (a, b) -> Long.compare(a.getAddedToCallTime(), b.getAddedToCallTime()); + private static final Comparator MOST_RECENTLY_SPOKEN = (a, b) -> Long.compare(b.getLastSpoke(), a.getLastSpoke()); + private static final Comparator MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED = ComparatorCompat.chain(MOST_RECENTLY_SPOKEN).thenComparing(LEAST_RECENTLY_ADDED); + + private final int maxGridCellCount; + private final List participants; + + public ParticipantCollection(int maxGridCellCount) { + this(maxGridCellCount, Collections.emptyList()); + } + + private ParticipantCollection(int maxGridCellCount, @NonNull List callParticipants) { + this.maxGridCellCount = maxGridCellCount; + this.participants = Collections.unmodifiableList(callParticipants); + } + + @CheckResult + public @NonNull ParticipantCollection getNext(@NonNull List participants) { + if (participants.isEmpty()) { + return new ParticipantCollection(maxGridCellCount); + } else if (this.participants.isEmpty()) { + List newParticipants = new ArrayList<>(participants); + Collections.sort(newParticipants, participants.size() <= maxGridCellCount ? LEAST_RECENTLY_ADDED : MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED); + + return new ParticipantCollection(maxGridCellCount, newParticipants); + } else { + List newParticipants = new ArrayList<>(participants); + Collections.sort(newParticipants, MOST_RECENTLY_SPOKEN_THEN_LEAST_RECENTLY_ADDED); + + List oldGridParticipantIds = Stream.of(getGridParticipants()) + .map(CallParticipant::getCallParticipantId) + .toList(); + + for (int i = 0; i < oldGridParticipantIds.size(); i++) { + CallParticipantId oldId = oldGridParticipantIds.get(i); + + int newIndex = Stream.of(newParticipants) + .takeUntilIndexed((j, p) -> j >= maxGridCellCount) + .map(CallParticipant::getCallParticipantId) + .toList() + .indexOf(oldId); + + if (newIndex != -1 && newIndex != i) { + Collections.swap(newParticipants, newIndex, Math.min(i, newParticipants.size() - 1)); + } + } + + return new ParticipantCollection(maxGridCellCount, newParticipants); + } + } + + public List getGridParticipants() { + return participants.size() > maxGridCellCount + ? Collections.unmodifiableList(participants.subList(0, maxGridCellCount)) + : Collections.unmodifiableList(participants); + } + + public List getListParticipants() { + return participants.size() > maxGridCellCount + ? Collections.unmodifiableList(participants.subList(maxGridCellCount, participants.size())) + : Collections.emptyList(); + } + + public boolean isEmpty() { + return participants.isEmpty(); + } + + public List getAllParticipants() { + return participants; + } + + public int size() { + return participants.size(); + } + + public @NonNull CallParticipant get(int i) { + return participants.get(i); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.java new file mode 100644 index 00000000..addf90db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.java @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.OptionalLong; + +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * General state of ongoing calls. + */ +public class CallInfoState { + + WebRtcViewModel.State callState; + Recipient callRecipient; + long callConnectedTime; + Map remoteParticipants; + Map peerMap; + RemotePeer activePeer; + GroupCall groupCall; + WebRtcViewModel.GroupCallState groupState; + Set identityChangedRecipients; + OptionalLong remoteDevicesCount; + Long participantLimit; + + public CallInfoState() { + this(WebRtcViewModel.State.IDLE, + Recipient.UNKNOWN, + -1, + Collections.emptyMap(), + Collections.emptyMap(), + null, + null, + WebRtcViewModel.GroupCallState.IDLE, + Collections.emptySet(), + OptionalLong.empty(), + null); + } + + public CallInfoState(@NonNull CallInfoState toCopy) { + this(toCopy.callState, + toCopy.callRecipient, + toCopy.callConnectedTime, + toCopy.remoteParticipants, + toCopy.peerMap, + toCopy.activePeer, + toCopy.groupCall, + toCopy.groupState, + toCopy.identityChangedRecipients, + toCopy.remoteDevicesCount, + toCopy.participantLimit); + } + + public CallInfoState(@NonNull WebRtcViewModel.State callState, + @NonNull Recipient callRecipient, + long callConnectedTime, + @NonNull Map remoteParticipants, + @NonNull Map peerMap, + @Nullable RemotePeer activePeer, + @Nullable GroupCall groupCall, + @NonNull WebRtcViewModel.GroupCallState groupState, + @NonNull Set identityChangedRecipients, + @NonNull OptionalLong remoteDevicesCount, + @Nullable Long participantLimit) + { + this.callState = callState; + this.callRecipient = callRecipient; + this.callConnectedTime = callConnectedTime; + this.remoteParticipants = new LinkedHashMap<>(remoteParticipants); + this.peerMap = new HashMap<>(peerMap); + this.activePeer = activePeer; + this.groupCall = groupCall; + this.groupState = groupState; + this.identityChangedRecipients = new HashSet<>(identityChangedRecipients); + this.remoteDevicesCount = remoteDevicesCount; + this.participantLimit = participantLimit; + } + + public @NonNull Recipient getCallRecipient() { + return callRecipient; + } + + public long getCallConnectedTime() { + return callConnectedTime; + } + + public @NonNull Map getRemoteCallParticipantsMap() { + return new LinkedHashMap<>(remoteParticipants); + } + + public @Nullable CallParticipant getRemoteCallParticipant(@NonNull Recipient recipient) { + return getRemoteCallParticipant(new CallParticipantId(recipient)); + } + + public @Nullable CallParticipant getRemoteCallParticipant(@NonNull CallParticipantId callParticipantId) { + return remoteParticipants.get(callParticipantId); + } + + public @NonNull List getRemoteCallParticipants() { + return new ArrayList<>(remoteParticipants.values()); + } + + public @NonNull WebRtcViewModel.State getCallState() { + return callState; + } + + public @Nullable RemotePeer getPeer(int hashCode) { + return peerMap.get(hashCode); + } + + public @Nullable RemotePeer getActivePeer() { + return activePeer; + } + + public @NonNull RemotePeer requireActivePeer() { + return Objects.requireNonNull(activePeer); + } + + public @Nullable GroupCall getGroupCall() { + return groupCall; + } + + public @NonNull GroupCall requireGroupCall() { + return Objects.requireNonNull(groupCall); + } + + public @NonNull WebRtcViewModel.GroupCallState getGroupCallState() { + return groupState; + } + + public @NonNull Set getIdentityChangedRecipients() { + return identityChangedRecipients; + } + + public OptionalLong getRemoteDevicesCount() { + return remoteDevicesCount; + } + + public @Nullable Long getParticipantLimit() { + return participantLimit; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java new file mode 100644 index 00000000..a6167b91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; + +/** + * Information specific to setting up a call. + */ +public final class CallSetupState { + boolean enableVideoOnCreate; + boolean isRemoteVideoOffer; + boolean acceptWithVideo; + boolean sentJoinedMessage; + + public CallSetupState() { + this(false, false, false, false); + } + + public CallSetupState(@NonNull CallSetupState toCopy) { + this(toCopy.enableVideoOnCreate, toCopy.isRemoteVideoOffer, toCopy.acceptWithVideo, toCopy.sentJoinedMessage); + } + + public CallSetupState(boolean enableVideoOnCreate, boolean isRemoteVideoOffer, boolean acceptWithVideo, boolean sentJoinedMessage) { + this.enableVideoOnCreate = enableVideoOnCreate; + this.isRemoteVideoOffer = isRemoteVideoOffer; + this.acceptWithVideo = acceptWithVideo; + this.sentJoinedMessage = sentJoinedMessage; + } + + public boolean isEnableVideoOnCreate() { + return enableVideoOnCreate; + } + + public boolean isRemoteVideoOffer() { + return isRemoteVideoOffer; + } + + public boolean isAcceptWithVideo() { + return acceptWithVideo; + } + + public boolean hasSentJoinedMessage() { + return sentJoinedMessage; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java new file mode 100644 index 00000000..fea03b53 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.components.sensors.Orientation; +import org.thoughtcrime.securesms.ringrtc.CameraState; + +/** + * Local device specific state. + */ +public final class LocalDeviceState { + CameraState cameraState; + boolean microphoneEnabled; + boolean bluetoothAvailable; + Orientation orientation; + + LocalDeviceState() { + this(CameraState.UNKNOWN, true, false, Orientation.PORTRAIT_BOTTOM_EDGE); + } + + LocalDeviceState(@NonNull LocalDeviceState toCopy) { + this(toCopy.cameraState, toCopy.microphoneEnabled, toCopy.bluetoothAvailable, toCopy.orientation); + } + + LocalDeviceState(@NonNull CameraState cameraState, boolean microphoneEnabled, boolean bluetoothAvailable, @NonNull Orientation orientation) { + this.cameraState = cameraState; + this.microphoneEnabled = microphoneEnabled; + this.bluetoothAvailable = bluetoothAvailable; + this.orientation = orientation; + } + + public @NonNull CameraState getCameraState() { + return cameraState; + } + + public boolean isMicrophoneEnabled() { + return microphoneEnabled; + } + + public boolean isBluetoothAvailable() { + return bluetoothAvailable; + } + + public @NonNull Orientation getOrientation() { + return orientation; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/VideoState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/VideoState.java new file mode 100644 index 00000000..4fd12787 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/VideoState.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.webrtc.EglBase; + +import java.util.Objects; + +/** + * Local device video state and infrastructure. + */ +public final class VideoState { + EglBase eglBase; + BroadcastVideoSink localSink; + Camera camera; + + VideoState() { + this(null, null, null); + } + + VideoState(@NonNull VideoState toCopy) { + this(toCopy.eglBase, toCopy.localSink, toCopy.camera); + } + + VideoState(@Nullable EglBase eglBase, @Nullable BroadcastVideoSink localSink, @Nullable Camera camera) { + this.eglBase = eglBase; + this.localSink = localSink; + this.camera = camera; + } + + public @Nullable EglBase getEglBase() { + return eglBase; + } + + public @NonNull EglBase requireEglBase() { + return Objects.requireNonNull(eglBase); + } + + public @Nullable BroadcastVideoSink getLocalSink() { + return localSink; + } + + public @NonNull BroadcastVideoSink requireLocalSink() { + return Objects.requireNonNull(localSink); + } + + public @Nullable Camera getCamera() { + return camera; + } + + public @NonNull Camera requireCamera() { + return Objects.requireNonNull(camera); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java new file mode 100644 index 00000000..999ba342 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor; + +/** + * Represent the entire state of the call system. + */ +public final class WebRtcServiceState { + + WebRtcActionProcessor actionProcessor; + CallSetupState callSetupState; + CallInfoState callInfoState; + LocalDeviceState localDeviceState; + VideoState videoState; + + public WebRtcServiceState(@NonNull WebRtcActionProcessor actionProcessor) { + this.actionProcessor = actionProcessor; + this.callSetupState = new CallSetupState(); + this.callInfoState = new CallInfoState(); + this.localDeviceState = new LocalDeviceState(); + this.videoState = new VideoState(); + } + + public WebRtcServiceState(@NonNull WebRtcServiceState toCopy) { + this.actionProcessor = toCopy.actionProcessor; + this.callSetupState = new CallSetupState(toCopy.callSetupState); + this.callInfoState = new CallInfoState(toCopy.callInfoState); + this.localDeviceState = new LocalDeviceState(toCopy.localDeviceState); + this.videoState = new VideoState(toCopy.videoState); + } + + public @NonNull WebRtcActionProcessor getActionProcessor() { + return actionProcessor; + } + + public @NonNull CallSetupState getCallSetupState() { + return callSetupState; + } + + public @NonNull CallInfoState getCallInfoState() { + return callInfoState; + } + + public @NonNull LocalDeviceState getLocalDeviceState() { + return localDeviceState; + } + + public @NonNull VideoState getVideoState() { + return videoState; + } + + public @NonNull WebRtcServiceStateBuilder builder() { + return new WebRtcServiceStateBuilder(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java new file mode 100644 index 00000000..0761bd91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java @@ -0,0 +1,278 @@ +package org.thoughtcrime.securesms.service.webrtc.state; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.OptionalLong; + +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.components.sensors.Orientation; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor; +import org.webrtc.EglBase; + +import java.util.Collection; + +/** + * Builder that creates a new {@link WebRtcServiceState} from an existing one and allows + * changes to all normally immutable data. + */ +public class WebRtcServiceStateBuilder { + private WebRtcServiceState toBuild; + + public WebRtcServiceStateBuilder(@NonNull WebRtcServiceState webRtcServiceState) { + toBuild = new WebRtcServiceState(webRtcServiceState); + } + + public @NonNull WebRtcServiceState build() { + return toBuild; + } + + public @NonNull WebRtcServiceStateBuilder actionProcessor(@NonNull WebRtcActionProcessor actionHandler) { + toBuild.actionProcessor = actionHandler; + return this; + } + + public @NonNull CallSetupStateBuilder changeCallSetupState() { + return new CallSetupStateBuilder(); + } + + public @NonNull CallInfoStateBuilder changeCallInfoState() { + return new CallInfoStateBuilder(); + } + + public @NonNull LocalDeviceStateBuilder changeLocalDeviceState() { + return new LocalDeviceStateBuilder(); + } + + public @NonNull VideoStateBuilder changeVideoState() { + return new VideoStateBuilder(); + } + + public @NonNull WebRtcServiceStateBuilder terminate() { + toBuild.callSetupState = new CallSetupState(); + toBuild.localDeviceState = new LocalDeviceState(); + toBuild.videoState = new VideoState(); + + CallInfoState newCallInfoState = new CallInfoState(); + newCallInfoState.peerMap.putAll(toBuild.callInfoState.peerMap); + toBuild.callInfoState = newCallInfoState; + + return this; + } + + public class LocalDeviceStateBuilder { + private LocalDeviceState toBuild; + + public LocalDeviceStateBuilder() { + toBuild = new LocalDeviceState(WebRtcServiceStateBuilder.this.toBuild.localDeviceState); + } + + public @NonNull WebRtcServiceStateBuilder commit() { + WebRtcServiceStateBuilder.this.toBuild.localDeviceState = toBuild; + return WebRtcServiceStateBuilder.this; + } + + public @NonNull WebRtcServiceState build() { + commit(); + return WebRtcServiceStateBuilder.this.build(); + } + + public @NonNull LocalDeviceStateBuilder cameraState(@NonNull CameraState cameraState) { + toBuild.cameraState = cameraState; + return this; + } + + public @NonNull LocalDeviceStateBuilder isMicrophoneEnabled(boolean enabled) { + toBuild.microphoneEnabled = enabled; + return this; + } + + public @NonNull LocalDeviceStateBuilder isBluetoothAvailable(boolean available) { + toBuild.bluetoothAvailable = available; + return this; + } + + public @NonNull LocalDeviceStateBuilder setOrientation(@NonNull Orientation orientation) { + toBuild.orientation = orientation; + return this; + } + } + + public class CallSetupStateBuilder { + private CallSetupState toBuild; + + public CallSetupStateBuilder() { + toBuild = new CallSetupState(WebRtcServiceStateBuilder.this.toBuild.callSetupState); + } + + public @NonNull WebRtcServiceStateBuilder commit() { + WebRtcServiceStateBuilder.this.toBuild.callSetupState = toBuild; + return WebRtcServiceStateBuilder.this; + } + + public @NonNull WebRtcServiceState build() { + commit(); + return WebRtcServiceStateBuilder.this.build(); + } + + public @NonNull CallSetupStateBuilder enableVideoOnCreate(boolean enableVideoOnCreate) { + toBuild.enableVideoOnCreate = enableVideoOnCreate; + return this; + } + + public @NonNull CallSetupStateBuilder isRemoteVideoOffer(boolean isRemoteVideoOffer) { + toBuild.isRemoteVideoOffer = isRemoteVideoOffer; + return this; + } + + public @NonNull CallSetupStateBuilder acceptWithVideo(boolean acceptWithVideo) { + toBuild.acceptWithVideo = acceptWithVideo; + return this; + } + + public @NonNull CallSetupStateBuilder sentJoinedMessage(boolean sentJoinedMessage) { + toBuild.sentJoinedMessage = sentJoinedMessage; + return this; + } + } + + public class VideoStateBuilder { + private VideoState toBuild; + + public VideoStateBuilder() { + toBuild = new VideoState(WebRtcServiceStateBuilder.this.toBuild.videoState); + } + + public @NonNull WebRtcServiceStateBuilder commit() { + WebRtcServiceStateBuilder.this.toBuild.videoState = toBuild; + return WebRtcServiceStateBuilder.this; + } + + public @NonNull WebRtcServiceState build() { + commit(); + return WebRtcServiceStateBuilder.this.build(); + } + + public @NonNull VideoStateBuilder eglBase(@Nullable EglBase eglBase) { + toBuild.eglBase = eglBase; + return this; + } + + public @NonNull VideoStateBuilder localSink(@Nullable BroadcastVideoSink localSink) { + toBuild.localSink = localSink; + return this; + } + + public @NonNull VideoStateBuilder camera(@Nullable Camera camera) { + toBuild.camera = camera; + return this; + } + } + + public class CallInfoStateBuilder { + private CallInfoState toBuild; + + public CallInfoStateBuilder() { + toBuild = new CallInfoState(WebRtcServiceStateBuilder.this.toBuild.callInfoState); + } + + public @NonNull WebRtcServiceStateBuilder commit() { + WebRtcServiceStateBuilder.this.toBuild.callInfoState = toBuild; + return WebRtcServiceStateBuilder.this; + } + + public @NonNull WebRtcServiceState build() { + commit(); + return WebRtcServiceStateBuilder.this.build(); + } + + public @NonNull CallInfoStateBuilder callState(@NonNull WebRtcViewModel.State callState) { + toBuild.callState = callState; + return this; + } + + public @NonNull CallInfoStateBuilder callRecipient(@NonNull Recipient callRecipient) { + toBuild.callRecipient = callRecipient; + return this; + } + + public @NonNull CallInfoStateBuilder callConnectedTime(long callConnectedTime) { + toBuild.callConnectedTime = callConnectedTime; + return this; + } + + public @NonNull CallInfoStateBuilder putParticipant(@NonNull CallParticipantId callParticipantId, @NonNull CallParticipant callParticipant) { + toBuild.remoteParticipants.put(callParticipantId, callParticipant); + return this; + } + + public @NonNull CallInfoStateBuilder putParticipant(@NonNull Recipient recipient, @NonNull CallParticipant callParticipant) { + toBuild.remoteParticipants.put(new CallParticipantId(recipient), callParticipant); + return this; + } + + public @NonNull CallInfoStateBuilder clearParticipantMap() { + toBuild.remoteParticipants.clear(); + return this; + } + + public @NonNull CallInfoStateBuilder putRemotePeer(@NonNull RemotePeer remotePeer) { + toBuild.peerMap.put(remotePeer.hashCode(), remotePeer); + return this; + } + + public @NonNull CallInfoStateBuilder clearPeerMap() { + toBuild.peerMap.clear(); + return this; + } + + public @NonNull CallInfoStateBuilder removeRemotePeer(@NonNull RemotePeer remotePeer) { + toBuild.peerMap.remove(remotePeer.hashCode()); + return this; + } + + public @NonNull CallInfoStateBuilder activePeer(@Nullable RemotePeer activePeer) { + toBuild.activePeer = activePeer; + return this; + } + + public @NonNull CallInfoStateBuilder groupCall(@Nullable GroupCall groupCall) { + toBuild.groupCall = groupCall; + return this; + } + + public @NonNull CallInfoStateBuilder groupCallState(@Nullable WebRtcViewModel.GroupCallState groupState) { + toBuild.groupState = groupState; + return this; + } + + public @NonNull CallInfoStateBuilder addIdentityChangedRecipient(@NonNull RecipientId id) { + toBuild.identityChangedRecipients.add(id); + return this; + } + + public @NonNull CallInfoStateBuilder removeIdentityChangedRecipients(@NonNull Collection ids) { + toBuild.identityChangedRecipients.removeAll(ids); + return this; + } + + public @NonNull CallInfoStateBuilder remoteDevicesCount(long remoteDevicesCount) { + toBuild.remoteDevicesCount = OptionalLong.of(remoteDevicesCount); + return this; + } + + public @NonNull CallInfoStateBuilder participantLimit(@Nullable Long participantLimit) { + toBuild.participantLimit = participantLimit; + return this; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java b/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java new file mode 100644 index 00000000..2ef5c986 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.shakereport; + +import android.app.Activity; +import android.app.Application; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.ShakeDetector; +import org.signal.core.util.logging.Log; +import org.signal.core.util.tracing.Tracer; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository; +import org.thoughtcrime.securesms.sharing.ShareIntents; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.lang.ref.WeakReference; + +/** + * A class that will detect a shake and then prompts the user to submit a debuglog. Basically a + * shortcut to submit a debuglog from anywhere. + */ +public final class ShakeToReport implements ShakeDetector.Listener { + + private static final String TAG = Log.tag(ShakeToReport.class); + + private final Application application; + private final ShakeDetector detector; + + private WeakReference weakActivity; + + public ShakeToReport(@NonNull Application application) { + this.application = application; + this.detector = new ShakeDetector(this); + this.weakActivity = new WeakReference<>(null); + } + + public void enable() { + if (!FeatureFlags.internalUser()) return; + + detector.start(ServiceUtil.getSensorManager(application)); + } + + public void disable() { + if (!FeatureFlags.internalUser()) return; + + detector.stop(); + } + + public void registerActivity(@NonNull Activity activity) { + if (!FeatureFlags.internalUser()) return; + + this.weakActivity = new WeakReference<>(activity); + } + + @Override + public void onShakeDetected() { + Activity activity = weakActivity.get(); + if (activity == null) { + Log.w(TAG, "No registered activity!"); + return; + } + + disable(); + + new AlertDialog.Builder(activity) + .setTitle(R.string.ShakeToReport_shake_detected) + .setMessage(R.string.ShakeToReport_submit_debug_log) + .setNegativeButton(android.R.string.cancel, (d, i) -> { + d.dismiss(); + enableIfVisible(); + }) + .setPositiveButton(R.string.ShakeToReport_submit, (d, i) -> { + d.dismiss(); + submitLog(activity); + }) + .show(); + } + + private void submitLog(@NonNull Activity activity) { + AlertDialog spinner = SimpleProgressDialog.show(activity); + SubmitDebugLogRepository repo = new SubmitDebugLogRepository(); + + Log.i(TAG, "Submitting log..."); + + repo.getLogLines(lines -> { + Log.i(TAG, "Retrieved log lines..."); + + repo.submitLog(lines, Tracer.getInstance().serialize(), url -> { + Log.i(TAG, "Logs uploaded!"); + + Util.runOnMain(() -> { + spinner.dismiss(); + + if (url.isPresent()) { + showPostSubmitDialog(activity, url.get()); + } else { + Toast.makeText(activity, R.string.ShakeToReport_failed_to_submit, Toast.LENGTH_SHORT).show(); + enableIfVisible(); + } + }); + }); + }); + } + + private void showPostSubmitDialog(@NonNull Activity activity, @NonNull String url) { + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle(R.string.ShakeToReport_success) + .setMessage(url) + .setNegativeButton(android.R.string.cancel, (d, i) -> { + d.dismiss(); + enableIfVisible(); + }) + .setPositiveButton(R.string.ShakeToReport_share, (d, i) -> { + d.dismiss(); + enableIfVisible(); + + activity.startActivity(new ShareIntents.Builder(activity) + .setText(url) + .build()); + }) + .show(); + + ((TextView) dialog.findViewById(android.R.id.message)).setTextIsSelectable(true); + } + + private void enableIfVisible() { + if (ApplicationDependencies.getAppForegroundObserver().isForegrounded()) { + enable(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java new file mode 100644 index 00000000..09528fdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.sharing; + +public enum InterstitialContentType { + MEDIA, + TEXT, + NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java new file mode 100644 index 00000000..a9872918 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.sharing; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class MultiShareArgs implements Parcelable { + + private final Set shareContactAndThreads; + private final List media; + private final String draftText; + private final StickerLocator stickerLocator; + private final boolean borderless; + private final Uri dataUri; + private final String dataType; + private final boolean viewOnce; + private final LinkPreview linkPreview; + + private MultiShareArgs(@NonNull Builder builder) { + shareContactAndThreads = builder.shareContactAndThreads; + media = builder.media == null ? new ArrayList<>() : new ArrayList<>(builder.media); + draftText = builder.draftText; + stickerLocator = builder.stickerLocator; + borderless = builder.borderless; + dataUri = builder.dataUri; + dataType = builder.dataType; + viewOnce = builder.viewOnce; + linkPreview = builder.linkPreview; + } + + protected MultiShareArgs(Parcel in) { + shareContactAndThreads = new HashSet<>(Objects.requireNonNull(in.createTypedArrayList(ShareContactAndThread.CREATOR))); + media = in.createTypedArrayList(Media.CREATOR); + draftText = in.readString(); + stickerLocator = in.readParcelable(StickerLocator.class.getClassLoader()); + borderless = in.readByte() != 0; + dataUri = in.readParcelable(Uri.class.getClassLoader()); + dataType = in.readString(); + viewOnce = in.readByte() != 0; + + String linkedPreviewString = in.readString(); + LinkPreview preview; + try { + preview = linkedPreviewString != null ? LinkPreview.deserialize(linkedPreviewString) : null; + } catch (IOException e) { + preview = null; + } + + linkPreview = preview; + } + + public Set getShareContactAndThreads() { + return shareContactAndThreads; + } + + public @NonNull List getMedia() { + return media; + } + + public StickerLocator getStickerLocator() { + return stickerLocator; + } + + public String getDataType() { + return dataType; + } + + public @Nullable String getDraftText() { + return draftText; + } + + public Uri getDataUri() { + return dataUri; + } + + public boolean isBorderless() { + return borderless; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public @Nullable LinkPreview getLinkPreview() { + return linkPreview; + } + + public @NonNull InterstitialContentType getInterstitialContentType() { + if (!requiresInterstitial()) { + return InterstitialContentType.NONE; + } else if (!this.getMedia().isEmpty() || + (this.getDataUri() != null && this.getDataUri() != Uri.EMPTY && this.getDataType() != null)) + { + return InterstitialContentType.MEDIA; + } else if (!TextUtils.isEmpty(this.getDraftText())) { + return InterstitialContentType.TEXT; + } else { + return InterstitialContentType.NONE; + } + } + + + public static final Creator CREATOR = new Creator() { + @Override + public MultiShareArgs createFromParcel(Parcel in) { + return new MultiShareArgs(in); + } + + @Override + public MultiShareArgs[] newArray(int size) { + return new MultiShareArgs[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeTypedList(Stream.of(shareContactAndThreads).toList()); + dest.writeTypedList(media); + dest.writeString(draftText); + dest.writeParcelable(stickerLocator, flags); + dest.writeByte((byte) (borderless ? 1 : 0)); + dest.writeParcelable(dataUri, flags); + dest.writeString(dataType); + dest.writeByte((byte) (viewOnce ? 1 : 0)); + + if (linkPreview != null) { + try { + dest.writeString(linkPreview.serialize()); + } catch (IOException e) { + dest.writeString(""); + } + } else { + dest.writeString(""); + } + } + + public Builder buildUpon() { + return new Builder(shareContactAndThreads).asBorderless(borderless) + .asViewOnce(viewOnce) + .withDataType(dataType) + .withDataUri(dataUri) + .withDraftText(draftText) + .withLinkPreview(linkPreview) + .withMedia(media) + .withStickerLocator(stickerLocator); + } + + private boolean requiresInterstitial() { + return stickerLocator == null && + (!media.isEmpty() || !TextUtils.isEmpty(draftText) || MediaUtil.isImageOrVideoType(dataType)); + } + + public static final class Builder { + + private final Set shareContactAndThreads; + + private List media; + private String draftText; + private StickerLocator stickerLocator; + private boolean borderless; + private Uri dataUri; + private String dataType; + private LinkPreview linkPreview; + private boolean viewOnce; + + public Builder(@NonNull Set shareContactAndThreads) { + this.shareContactAndThreads = shareContactAndThreads; + } + + public @NonNull Builder withMedia(@Nullable List media) { + this.media = media != null ? new ArrayList<>(media) : null; + return this; + } + + public @NonNull Builder withDraftText(@Nullable String draftText) { + this.draftText = draftText; + return this; + } + + public @NonNull Builder withStickerLocator(@Nullable StickerLocator stickerLocator) { + this.stickerLocator = stickerLocator; + return this; + } + + public @NonNull Builder asBorderless(boolean borderless) { + this.borderless = borderless; + return this; + } + + public @NonNull Builder withDataUri(@Nullable Uri dataUri) { + this.dataUri = dataUri; + return this; + } + + public @NonNull Builder withDataType(@Nullable String dataType) { + this.dataType = dataType; + return this; + } + + public @NonNull Builder withLinkPreview(@Nullable LinkPreview linkPreview) { + this.linkPreview = linkPreview; + return this; + } + + public @NonNull Builder asViewOnce(boolean viewOnce) { + this.viewOnce = viewOnce; + return this; + } + + public @NonNull MultiShareArgs build() { + return new MultiShareArgs(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java new file mode 100644 index 00000000..9c181d4e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.sharing; + +import android.app.AlertDialog; +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public final class MultiShareDialogs { + private MultiShareDialogs() { + } + + public static void displayResultDialog(@NonNull Context context, + @NonNull MultiShareSender.MultiShareSendResultCollection resultCollection, + @NonNull Runnable onDismiss) + { + if (resultCollection.containsFailures()) { + displayFailuresDialog(context, onDismiss); + } else { + onDismiss.run(); + } + } + + public static void displayMaxSelectedDialog(@NonNull Context context, int hardLimit) { + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.MultiShareDialogs__you_can_only_share_with_up_to, hardLimit)) + .setPositiveButton(android.R.string.ok, ((dialog, which) -> dialog.dismiss())) + .setCancelable(true) + .show(); + } + + private static void displayFailuresDialog(@NonNull Context context, + @NonNull Runnable onDismiss) + { + new AlertDialog.Builder(context) + .setMessage(R.string.MultiShareDialogs__failed_to_send_to_some_users) + .setPositiveButton(android.R.string.ok, ((dialog, which) -> dialog.dismiss())) + .setOnDismissListener(dialog -> onDismiss.run()) + .setCancelable(true) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java new file mode 100644 index 00000000..a74dfc13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.SlideFactory; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientFormattingException; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.MessageUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * MultiShareSender encapsulates send logic (stolen from {@link org.thoughtcrime.securesms.conversation.ConversationActivity} + * and provides a means to: + * + * 1. Send messages based off a {@link MultiShareArgs} object and + * 1. Parse through the result of the send via a {@link MultiShareSendResultCollection} + */ +public final class MultiShareSender { + + private static final String TAG = Log.tag(MultiShareSender.class); + + private MultiShareSender() { + } + + @MainThread + public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer results) { + SimpleTask.run(() -> sendInternal(multiShareArgs), results::accept); + } + + @WorkerThread + private static MultiShareSendResultCollection sendInternal(@NonNull MultiShareArgs multiShareArgs) { + Context context = ApplicationDependencies.getApplication(); + boolean isMmsEnabled = Util.isMmsCapable(context); + String message = multiShareArgs.getDraftText(); + SlideDeck slideDeck = buildSlideDeck(context, multiShareArgs); + + List results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size()); + + for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) { + Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId()); + + TransportOption transport = resolveTransportOption(context, recipient); + boolean forceSms = recipient.isForceSmsSelection() && transport.isSms(); + int subscriptionId = transport.getSimSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + boolean needsSplit = !transport.isSms() && + message != null && + message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize; + boolean isMediaMessage = !multiShareArgs.getMedia().isEmpty() || + (multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) || + multiShareArgs.getStickerLocator() != null || + multiShareArgs.getLinkPreview() != null || + recipient.isGroup() || + recipient.getEmail().isPresent() || + needsSplit; + + if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.MMS_NOT_ENABLED)); + } else if (isMediaMessage) { + sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId); + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + } else { + sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId() ,forceSms, expiresIn, subscriptionId); + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + } + } + + return new MultiShareSendResultCollection(results); + } + + public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set shareContactAndThreads) { + for (ShareContactAndThread shareContactAndThread : shareContactAndThreads) { + TransportOption option = resolveTransportOption(context, shareContactAndThread.isForceSms()); + if (option.isSms()) { + return option; + } + } + + return TransportOptions.getPushTransportOption(context); + } + + private static @NonNull TransportOption resolveTransportOption(@NonNull Context context, @NonNull Recipient recipient) { + return resolveTransportOption(context, recipient.isForceSmsSelection() || !recipient.isRegistered()); + } + + public static @NonNull TransportOption resolveTransportOption(@NonNull Context context, boolean forceSms) { + if (forceSms) { + TransportOptions options = new TransportOptions(context, false); + options.setDefaultTransport(TransportOption.Type.SMS); + return options.getSelectedTransport(); + } else { + return TransportOptions.getPushTransportOption(context); + } + } + + private static void sendMediaMessage(@NonNull Context context, + @NonNull MultiShareArgs multiShareArgs, + @NonNull Recipient recipient, + @NonNull SlideDeck slideDeck, + @NonNull TransportOption transportOption, + long threadId, + boolean forceSms, + long expiresIn, + boolean isViewOnce, + int subscriptionId) + { + String body = multiShareArgs.getDraftText(); + if (transportOption.isType(TransportOption.Type.TEXTSECURE) && !forceSms && body != null) { + MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(context, body, transportOption.calculateCharacters(body).maxPrimaryMessageSize); + body = splitMessage.getBody(); + + if (splitMessage.getTextSlide().isPresent()) { + slideDeck.addSlide(splitMessage.getTextSlide().get()); + } + } + + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + slideDeck, + body, + System.currentTimeMillis(), + subscriptionId, + expiresIn, + isViewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + null, + Collections.emptyList(), + multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) + : Collections.emptyList(), + Collections.emptyList()); + + MessageSender.send(context, outgoingMediaMessage, threadId, forceSms, null); + } + + private static void sendTextMessage(@NonNull Context context, + @NonNull MultiShareArgs multiShareArgs, + @NonNull Recipient recipient, + long threadId, + boolean forceSms, + long expiresIn, + int subscriptionId) + { + OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, multiShareArgs.getDraftText(), expiresIn, subscriptionId); + + MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null); + } + + private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) { + SlideDeck slideDeck = new SlideDeck(); + if (multiShareArgs.getStickerLocator() != null) { + slideDeck.addSlide(new StickerSlide(context, multiShareArgs.getDataUri(), 0, multiShareArgs.getStickerLocator(), multiShareArgs.getDataType())); + } else if (!multiShareArgs.getMedia().isEmpty()) { + for (Media media : multiShareArgs.getMedia()) { + slideDeck.addSlide(SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight())); + } + } else if (multiShareArgs.getDataUri() != null) { + slideDeck.addSlide(SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0)); + } + + return slideDeck; + } + + public static final class MultiShareSendResultCollection { + private final List results; + + private MultiShareSendResultCollection(List results) { + this.results = results; + } + + public boolean containsFailures() { + return Stream.of(results).anyMatch(result -> result.type != MultiShareSendResult.Type.SUCCESS); + } + } + + private static final class MultiShareSendResult { + private final ShareContactAndThread contactAndThread; + private final Type type; + + private MultiShareSendResult(ShareContactAndThread contactAndThread, Type type) { + this.contactAndThread = contactAndThread; + this.type = type; + } + + public ShareContactAndThread getContactAndThread() { + return contactAndThread; + } + + public Type getType() { + return type; + } + + private enum Type { + MMS_NOT_ENABLED, + SUCCESS + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java new file mode 100644 index 00000000..b6fe18fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -0,0 +1,584 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.sharing; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.core.util.Consumer; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.TransitionManager; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.SearchToolbar; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Entry point for sharing content into the app. + * + * Handles contact selection when necessary, but also serves as an entry point for when the contact + * is known (such as choosing someone in a direct share). + */ +public class ShareActivity extends PassphraseRequiredActivity + implements ContactSelectionListFragment.OnContactSelectedListener, + ContactSelectionListFragment.OnSelectionLimitReachedListener +{ + private static final String TAG = ShareActivity.class.getSimpleName(); + + private static final short RESULT_TEXT_CONFIRMATION = 1; + private static final short RESULT_MEDIA_CONFIRMATION = 2; + + public static final String EXTRA_THREAD_ID = "thread_id"; + public static final String EXTRA_RECIPIENT_ID = "recipient_id"; + public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private ConstraintLayout shareContainer; + private ContactSelectionListFragment contactsFragment; + private SearchToolbar searchToolbar; + private ImageView searchAction; + private View shareConfirm; + private ShareSelectionAdapter adapter; + private boolean disallowMultiShare; + + private ShareViewModel viewModel; + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + setContentView(R.layout.share_activity); + + initializeViewModel(); + initializeMedia(); + initializeIntent(); + initializeToolbar(); + initializeResources(); + initializeSearch(); + handleDestination(); + } + + @Override + public void onResume() { + Log.i(TAG, "onResume()"); + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + public void onStop() { + super.onStop(); + + if (!isFinishing() && !viewModel.isMultiShare()) { + finish(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + @Override + public void onBackPressed() { + if (searchToolbar.isVisible()) searchToolbar.collapse(); + else super.onBackPressed(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (resultCode == RESULT_OK) { + switch (requestCode) { + case RESULT_MEDIA_CONFIRMATION: + case RESULT_TEXT_CONFIRMATION: + viewModel.onSuccessfulShare(); + finish(); + break; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } else { + shareConfirm.setClickable(true); + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + public boolean onBeforeContactSelected(Optional recipientId, String number) { + if (disallowMultiShare) { + Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show(); + return false; + } else { + return viewModel.onContactSelected(new ShareContact(recipientId, number)); + } + } + + @Override + public void onContactDeselected(@NonNull Optional recipientId, String number) { + viewModel.onContactDeselected(new ShareContact(recipientId, number)); + } + + private void animateInSelection() { + TransitionManager.endTransitions(shareContainer); + TransitionManager.beginDelayedTransition(shareContainer); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(shareContainer); + constraintSet.setVisibility(R.id.selection_group, ConstraintSet.VISIBLE); + constraintSet.applyTo(shareContainer); + } + + private void animateOutSelection() { + TransitionManager.endTransitions(shareContainer); + TransitionManager.beginDelayedTransition(shareContainer); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(shareContainer); + constraintSet.setVisibility(R.id.selection_group, ConstraintSet.GONE); + constraintSet.applyTo(shareContainer); + } + + private void initializeIntent() { + if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { + int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_SELF | DisplayMode.FLAG_HIDE_NEW; + + if (TextSecurePreferences.isSmsEnabled(this) && viewModel.isExternalShare()) { + mode |= DisplayMode.FLAG_SMS; + } + + if (FeatureFlags.groupsV1ForcedMigration()) { + mode |= DisplayMode.FLAG_HIDE_GROUPS_V1; + } + + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); + } + + getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); + getIntent().putExtra(ContactSelectionListFragment.RECENTS, true); + getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit()); + getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true); + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_CHIPS, false); + getIntent().putExtra(ContactSelectionListFragment.CAN_SELECT_SELF, true); + } + + private void initializeToolbar() { + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + ActionBar actionBar = getSupportActionBar(); + + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + private void initializeResources() { + searchToolbar = findViewById(R.id.search_toolbar); + searchAction = findViewById(R.id.search_action); + shareConfirm = findViewById(R.id.share_confirm); + shareContainer = findViewById(R.id.container); + contactsFragment = new ContactSelectionListFragment(); + adapter = new ShareSelectionAdapter(); + + RecyclerView contactsRecycler = findViewById(R.id.selected_list); + contactsRecycler.setAdapter(adapter); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.contact_selection_list_fragment, contactsFragment) + .commit(); + + shareConfirm.setOnClickListener(unused -> { + Set shareContacts = viewModel.getShareContacts(); + + if (shareContacts.isEmpty()) throw new AssertionError(); + else if (shareContacts.size() == 1) onConfirmSingleDestination(shareContacts.iterator().next()); + else onConfirmMultipleDestinations(shareContacts); + }); + + viewModel.getSelectedContactModels().observe(this, models -> { + adapter.submitList(models, () -> contactsRecycler.scrollToPosition(models.size() - 1)); + + shareConfirm.setEnabled(!models.isEmpty()); + shareConfirm.setAlpha(models.isEmpty() ? 0.5f : 1f); + if (models.isEmpty()) { + animateOutSelection(); + } else { + animateInSelection(); + } + }); + + viewModel.getSmsShareRestriction().observe(this, smsShareRestriction -> { + final int displayMode; + + switch (smsShareRestriction) { + case NO_RESTRICTIONS: + disallowMultiShare = false; + displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); + + if (displayMode == -1) { + Log.w(TAG, "DisplayMode not set yet."); + return; + } + + if (TextSecurePreferences.isSmsEnabled(this) && viewModel.isExternalShare() && (displayMode & DisplayMode.FLAG_SMS) == 0) { + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode | DisplayMode.FLAG_SMS); + contactsFragment.setQueryFilter(null); + } + break; + case DISALLOW_SMS_CONTACTS: + disallowMultiShare = false; + displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); + + if (displayMode == -1) { + Log.w(TAG, "DisplayMode not set yet."); + return; + } + + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode & ~DisplayMode.FLAG_SMS); + contactsFragment.setQueryFilter(null); + break; + case DISALLOW_MULTI_SHARE: + disallowMultiShare = true; + break; + } + }); + } + + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this, new ShareViewModel.Factory()).get(ShareViewModel.class); + } + + private void initializeSearch() { + //noinspection IntegerDivisionInFloatingPointContext + searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), + searchAction.getY() + (searchAction.getHeight() / 2))); + + searchToolbar.setListener(new SearchToolbar.SearchListener() { + @Override + public void onSearchTextChange(String text) { + if (contactsFragment != null) { + contactsFragment.setQueryFilter(text); + } + } + + @Override + public void onSearchClosed() { + if (contactsFragment != null) { + contactsFragment.resetQueryFilter(); + } + } + }); + } + + private void initializeMedia() { + if (Intent.ACTION_SEND_MULTIPLE.equals(getIntent().getAction())) { + Log.i(TAG, "Multiple media share."); + List uris = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM); + + viewModel.onMultipleMediaShared(uris); + } else if (Intent.ACTION_SEND.equals(getIntent().getAction()) || getIntent().hasExtra(Intent.EXTRA_STREAM)) { + Log.i(TAG, "Single media share."); + Uri uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); + String type = getIntent().getType(); + + viewModel.onSingleMediaShared(uri, type); + } else { + Log.i(TAG, "Internal media share."); + viewModel.onNonExternalShare(); + } + } + + private void handleDestination() { + Intent intent = getIntent(); + long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); + int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); + RecipientId recipientId = null; + + if (intent.hasExtra(EXTRA_RECIPIENT_ID)) { + recipientId = RecipientId.from(intent.getStringExtra(EXTRA_RECIPIENT_ID)); + } + + boolean hasPreexistingDestination = threadId != -1 && recipientId != null && distributionType != -1; + + if (hasPreexistingDestination) { + if (contactsFragment.getView() != null) { + contactsFragment.getView().setVisibility(View.GONE); + } + onSingleDestinationChosen(threadId, recipientId); + } else if (viewModel.isExternalShare()) { + validateAvailableRecipients(); + } + } + + private void onConfirmSingleDestination(@NonNull ShareContact shareContact) { + shareConfirm.setClickable(false); + SimpleTask.run(this.getLifecycle(), + () -> resolveShareContact(shareContact), + result -> onSingleDestinationChosen(result.getThreadId(), result.getRecipientId())); + } + + private void onConfirmMultipleDestinations(@NonNull Set shareContacts) { + shareConfirm.setClickable(false); + SimpleTask.run(this.getLifecycle(), + () -> resolvedShareContacts(shareContacts), + this::onMultipleDestinationsChosen); + } + + private Set resolvedShareContacts(@NonNull Set sharedContacts) { + Set recipients = Stream.of(sharedContacts) + .map(contact -> contact.getRecipientId() + .transform(Recipient::resolved) + .or(() -> Recipient.external(this, contact.getNumber()))) + .collect(Collectors.toSet()); + + Map existingThreads = DatabaseFactory.getThreadDatabase(this) + .getThreadIdsIfExistsFor(Stream.of(recipients) + .map(Recipient::getId) + .toArray(RecipientId[]::new)); + + return Stream.of(recipients) + .map(recipient -> new ShareContactAndThread(recipient.getId(), Util.getOrDefault(existingThreads, recipient.getId(), -1L), recipient.isForceSmsSelection() || !recipient.isRegistered())) + .collect(Collectors.toSet()); + } + + @WorkerThread + private ShareContactAndThread resolveShareContact(@NonNull ShareContact shareContact) { + Recipient recipient; + if (shareContact.getRecipientId().isPresent()) { + recipient = Recipient.resolved(shareContact.getRecipientId().get()); + } else { + Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); + recipient = Recipient.external(this, shareContact.getNumber()); + } + + long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()); + return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered()); + } + + private void validateAvailableRecipients() { + resolveShareData(data -> { + int mode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); + + if (mode == -1) return; + + mode = data.isMmsOrSmsSupported() ? mode | DisplayMode.FLAG_SMS : mode & ~DisplayMode.FLAG_SMS; + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); + + contactsFragment.reset(); + }); + } + + private void resolveShareData(@NonNull Consumer onResolved) { + AtomicReference progressWheel = new AtomicReference<>(); + + if (viewModel.getShareData().getValue() == null) { + progressWheel.set(SimpleProgressDialog.show(this)); + } + + viewModel.getShareData().observe(this, (data) -> { + if (data == null) return; + + if (progressWheel.get() != null) { + progressWheel.get().dismiss(); + progressWheel.set(null); + } + + if (!data.isPresent()) { + Log.w(TAG, "No data to share!"); + Toast.makeText(this, R.string.ShareActivity_multiple_attachments_are_only_supported, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + onResolved.accept(data.get()); + }); + } + + private void onMultipleDestinationsChosen(@NonNull Set shareContactAndThreads) { + if (!viewModel.isExternalShare()) { + openInterstitial(shareContactAndThreads, null); + return; + } + + resolveShareData(data -> openInterstitial(shareContactAndThreads, data)); + } + + private void onSingleDestinationChosen(long threadId, @NonNull RecipientId recipientId) { + if (!viewModel.isExternalShare()) { + openConversation(threadId, recipientId, null); + return; + } + + resolveShareData(data -> openConversation(threadId, recipientId, data)); + } + + private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { + ShareIntents.Args args = ShareIntents.Args.from(getIntent()); + ConversationIntents.Builder builder = ConversationIntents.createBuilder(this, recipientId, threadId) + .withMedia(args.getExtraMedia()) + .withDraftText(args.getExtraText() != null ? args.getExtraText().toString() : null) + .withStickerLocator(args.getExtraSticker()) + .asBorderless(args.isBorderless()); + + if (shareData != null && shareData.isForIntent()) { + Log.i(TAG, "Shared data is a single file."); + builder.withDataUri(shareData.getUri()) + .withDataType(shareData.getMimeType()); + } else if (shareData != null && shareData.isForMedia()) { + Log.i(TAG, "Shared data is set of media."); + builder.withMedia(shareData.getMedia()); + } else if (shareData != null && shareData.isForPrimitive()) { + Log.i(TAG, "Shared data is a primitive type."); + } else if (shareData == null && args.getExtraSticker() != null) { + builder.withDataType(getIntent().getType()); + } else { + Log.i(TAG, "Shared data was not external."); + } + + viewModel.onSuccessfulShare(); + + startActivity(builder.build()); + } + + private void openInterstitial(@NonNull Set shareContactAndThreads, @Nullable ShareData shareData) { + ShareIntents.Args args = ShareIntents.Args.from(getIntent()); + MultiShareArgs.Builder builder = new MultiShareArgs.Builder(shareContactAndThreads) + .withMedia(args.getExtraMedia()) + .withDraftText(args.getExtraText() != null ? args.getExtraText().toString() : null) + .withStickerLocator(args.getExtraSticker()) + .asBorderless(args.isBorderless()); + + if (shareData != null && shareData.isForIntent()) { + Log.i(TAG, "Shared data is a single file."); + builder.withDataUri(shareData.getUri()) + .withDataType(shareData.getMimeType()); + } else if (shareData != null && shareData.isForMedia()) { + Log.i(TAG, "Shared data is set of media."); + builder.withMedia(shareData.getMedia()); + } else if (shareData != null && shareData.isForPrimitive()) { + Log.i(TAG, "Shared data is a primitive type."); + } else if (shareData == null && args.getExtraSticker() != null) { + builder.withDataType(getIntent().getType()); + } else { + Log.i(TAG, "Shared data was not external."); + } + + MultiShareArgs multiShareArgs = builder.build(); + InterstitialContentType interstitialContentType = multiShareArgs.getInterstitialContentType(); + switch (interstitialContentType) { + case TEXT: + startActivityForResult(ShareInterstitialActivity.createIntent(this, multiShareArgs), RESULT_TEXT_CONFIRMATION); + break; + case MEDIA: + List media = new ArrayList<>(multiShareArgs.getMedia()); + if (media.isEmpty()) { + media.add(new Media(multiShareArgs.getDataUri(), + multiShareArgs.getDataType(), + 0, + 0, + 0, + 0, + 0, + false, + Optional.absent(), + Optional.absent(), + Optional.absent())); + } + + startActivityForResult(MediaSendActivity.buildShareIntent(this, + media, + Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(), + multiShareArgs.getDraftText(), + MultiShareSender.getWorstTransportOption(this, multiShareArgs.getShareContactAndThreads())), + RESULT_MEDIA_CONFIRMATION); + break; + default: + //noinspection CodeBlock2Expr + MultiShareSender.send(multiShareArgs, results -> { + MultiShareDialogs.displayResultDialog(this, results, () -> { + viewModel.onSuccessfulShare(); + finish(); + }); + }); + break; + } + } + + @Override + public void onSuggestedLimitReached(int limit) { + } + + @Override + public void onHardLimitReached(int limit) { + MultiShareDialogs.displayMaxSelectedDialog(this, limit); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java new file mode 100644 index 00000000..e3776ba4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.sharing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Objects; + +final class ShareContact { + private final Optional recipientId; + private final String number; + + ShareContact(@NonNull Optional recipientId, @Nullable String number) { + this.recipientId = recipientId; + this.number = number; + } + + public Optional getRecipientId() { + return recipientId; + } + + public String getNumber() { + return number; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ShareContact that = (ShareContact) o; + return recipientId.equals(that.recipientId) && + Objects.equals(number, that.number); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, number); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java new file mode 100644 index 00000000..2408067d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.sharing; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public final class ShareContactAndThread implements Parcelable { + private final RecipientId recipientId; + private final long threadId; + private final boolean forceSms; + + ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms) { + this.recipientId = recipientId; + this.threadId = threadId; + this.forceSms = forceSms; + } + + protected ShareContactAndThread(@NonNull Parcel in) { + recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + threadId = in.readLong(); + forceSms = in.readByte() == 1; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public long getThreadId() { + return threadId; + } + + public boolean isForceSms() { + return forceSms; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ShareContactAndThread that = (ShareContactAndThread) o; + return threadId == that.threadId && + forceSms == that.forceSms && + recipientId.equals(that.recipientId); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, threadId, forceSms); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(recipientId, flags); + dest.writeLong(threadId); + dest.writeByte((byte) (forceSms ? 1 : 0)); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ShareContactAndThread createFromParcel(@NonNull Parcel in) { + return new ShareContactAndThread(in); + } + + @Override + public ShareContactAndThread[] newArray(int size) { + return new ShareContactAndThread[size]; + } + }; + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java new file mode 100644 index 00000000..52910359 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.sharing; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.mediasend.Media; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.List; + +class ShareData { + + private final Optional uri; + private final Optional mimeType; + private final Optional> media; + private final boolean external; + private final boolean isMmsOrSmsSupported; + + static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external, boolean isMmsOrSmsSupported) { + return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.absent(), external, isMmsOrSmsSupported); + } + + static ShareData forPrimitiveTypes() { + return new ShareData(Optional.absent(), Optional.absent(), Optional.absent(), true, true); + } + + static ShareData forMedia(@NonNull List media, boolean isMmsOrSmsSupported) { + return new ShareData(Optional.absent(), Optional.absent(), Optional.of(new ArrayList<>(media)), true, isMmsOrSmsSupported); + } + + private ShareData(Optional uri, Optional mimeType, Optional> media, boolean external, boolean isMmsOrSmsSupported) { + this.uri = uri; + this.mimeType = mimeType; + this.media = media; + this.external = external; + this.isMmsOrSmsSupported = isMmsOrSmsSupported; + } + + boolean isForIntent() { + return uri.isPresent(); + } + + boolean isForPrimitive() { + return !uri.isPresent() && !media.isPresent(); + } + + boolean isForMedia() { + return media.isPresent(); + } + + public @NonNull Uri getUri() { + return uri.get(); + } + + public @NonNull String getMimeType() { + return mimeType.get(); + } + + public @NonNull ArrayList getMedia() { + return media.get(); + } + + public boolean isExternal() { + return external; + } + + public boolean isMmsOrSmsSupported() { + return isMmsOrSmsSupported; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java new file mode 100644 index 00000000..fec7aa42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.stickers.StickerLocator; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public final class ShareIntents { + + private static final String EXTRA_MEDIA = "extra_media"; + private static final String EXTRA_BORDERLESS = "extra_borderless"; + private static final String EXTRA_STICKER = "extra_sticker"; + + private ShareIntents() { + } + + public static final class Args { + + private final CharSequence extraText; + private final ArrayList extraMedia; + private final StickerLocator extraSticker; + private final boolean isBorderless; + + public static Args from(@NonNull Intent intent) { + return new Args(intent.getStringExtra(Intent.EXTRA_TEXT), + intent.getParcelableArrayListExtra(EXTRA_MEDIA), + intent.getParcelableExtra(EXTRA_STICKER), + intent.getBooleanExtra(EXTRA_BORDERLESS, false)); + } + + private Args(@Nullable CharSequence extraText, + @Nullable ArrayList extraMedia, + @Nullable StickerLocator extraSticker, + boolean isBorderless) + { + this.extraText = extraText; + this.extraMedia = extraMedia; + this.extraSticker = extraSticker; + this.isBorderless = isBorderless; + } + + public @Nullable ArrayList getExtraMedia() { + return extraMedia; + } + + public @Nullable CharSequence getExtraText() { + return extraText; + } + + public @Nullable StickerLocator getExtraSticker() { + return extraSticker; + } + + public boolean isBorderless() { + return isBorderless; + } + } + + public static final class Builder { + + private final Context context; + + private String extraText; + private List extraMedia; + private Slide slide; + + public Builder(@NonNull Context context) { + this.context = context; + } + + public @NonNull Builder setText(@NonNull CharSequence extraText) { + this.extraText = extraText.toString(); + return this; + } + + public @NonNull Builder setMedia(@NonNull Collection extraMedia) { + this.extraMedia = new ArrayList<>(extraMedia); + return this; + } + + public @NonNull Builder setSlide(@NonNull Slide slide) { + this.slide = slide; + return this; + } + + public @NonNull Intent build() { + if (slide != null && extraMedia != null) { + throw new IllegalStateException("Cannot create intent with both Slide and [Media]"); + } + + Intent intent = new Intent(context, ShareActivity.class); + + intent.putExtra(Intent.EXTRA_TEXT, extraText); + + if (extraMedia != null) { + intent.putParcelableArrayListExtra(EXTRA_MEDIA, new ArrayList<>(extraMedia)); + } else if (slide != null) { + intent.putExtra(Intent.EXTRA_STREAM, slide.getUri()); + intent.putExtra(EXTRA_BORDERLESS, slide.isBorderless()); + + if (slide.hasSticker()) { + intent.putExtra(EXTRA_STICKER, slide.asAttachment().getSticker()); + intent.setType(slide.asAttachment().getContentType()); + } else { + intent.setType(slide.getContentType()); + } + } + + return intent; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java new file mode 100644 index 00000000..ae3e2c7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.sharing; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaSendConstants; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.UriUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class ShareRepository { + + private static final String TAG = Log.tag(ShareRepository.class); + + /** + * Handles a single URI that may be local or external. + */ + void getResolved(@Nullable Uri uri, @Nullable String mimeType, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try { + callback.onResult(Optional.of(getResolvedInternal(uri, mimeType))); + } catch (IOException e) { + Log.w(TAG, "Failed to resolve!", e); + callback.onResult(Optional.absent()); + } + }); + } + + /** + * Handles multiple URIs that are all assumed to be external images/videos. + */ + void getResolved(@NonNull List uris, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try { + callback.onResult(Optional.fromNullable(getResolvedInternal(uris))); + } catch (IOException e) { + Log.w(TAG, "Failed to resolve!", e); + callback.onResult(Optional.absent()); + } + }); + } + + @WorkerThread + private @NonNull ShareData getResolvedInternal(@Nullable Uri uri, @Nullable String mimeType) throws IOException { + Context context = ApplicationDependencies.getApplication(); + + if (uri == null) { + return ShareData.forPrimitiveTypes(); + } + + if (!UriUtil.isValidExternalUri(context, uri)) { + throw new IOException("Invalid external URI!"); + } + + mimeType = getMimeType(context, uri, mimeType); + + if (PartAuthority.isLocalUri(uri)) { + return ShareData.forIntentData(uri, mimeType, false, false); + } else { + InputStream stream = context.getContentResolver().openInputStream(uri); + + if (stream == null) { + throw new IOException("Failed to open stream!"); + } + + long size = getSize(context, uri); + String fileName = getFileName(context, uri); + + Uri blobUri; + + if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) { + blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .withFileName(fileName) + .createForSingleSessionOnDisk(context); + } else { + blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .withFileName(fileName) + .createForSingleSessionOnDisk(context); + // TODO Convert to multi-session after file drafts are fixed. + } + + return ShareData.forIntentData(blobUri, mimeType, true, isMmsSupported(context, mimeType, size)); + } + } + + private boolean isMmsSupported(@NonNull Context context, @NonNull String mimeType, long size) { + boolean canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED; + if (!TextSecurePreferences.isSmsEnabled(context) || !canReadPhoneState || !Util.isMmsCapable(context)) { + return false; + } + + TransportOptions options = new TransportOptions(context, true); + options.setDefaultTransport(TransportOption.Type.SMS); + MediaConstraints mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.getSelectedTransport().getSimSubscriptionId().or(-1)); + + final boolean canMmsSupportFileSize; + if (MediaUtil.isGif(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getGifMaxSize(context); + } else if (MediaUtil.isVideo(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getVideoMaxSize(context); + } else if (MediaUtil.isImageType(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getImageMaxSize(context); + } else if (MediaUtil.isAudioType(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getAudioMaxSize(context); + } else { + canMmsSupportFileSize = size <= mmsConstraints.getDocumentMaxSize(context); + } + + return canMmsSupportFileSize; + } + + @WorkerThread + private @Nullable ShareData getResolvedInternal(@NonNull List uris) throws IOException { + Context context = ApplicationDependencies.getApplication(); + + Map mimeTypes = Stream.of(uris) + .map(uri -> new Pair<>(uri, getMimeType(context, uri, null))) + .filter(p -> MediaUtil.isImageType(p.second) || MediaUtil.isVideoType(p.second)) + .collect(Collectors.toMap(p -> p.first, p -> p.second)); + + if (mimeTypes.isEmpty()) { + return null; + } + + List media = new ArrayList<>(mimeTypes.size()); + + for (Map.Entry entry : mimeTypes.entrySet()) { + Uri uri = entry.getKey(); + String mimeType = entry.getValue(); + + InputStream stream; + try { + stream = context.getContentResolver().openInputStream(uri); + if (stream == null) { + throw new IOException("Failed to open stream!"); + } + } catch (IOException e) { + Log.w(TAG, "Failed to open: " + uri); + continue; + } + + long size = getSize(context, uri); + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + long duration = getDuration(context, uri); + Uri blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .createForSingleSessionOnDisk(context); + + media.add(new Media(blobUri, + mimeType, + System.currentTimeMillis(), + dimens.first, + dimens.second, + size, + duration, + false, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent(), + Optional.absent())); + + if (media.size() >= MediaSendConstants.MAX_PUSH) { + Log.w(TAG, "Exceeded the attachment limit! Skipping the rest."); + break; + } + } + + if (media.size() > 0) { + boolean isMmsSupported = Stream.of(media) + .allMatch(m -> isMmsSupported(context, m.getMimeType(), m.getSize())); + return ShareData.forMedia(media, isMmsSupported); + } else { + return null; + } + } + + private static @NonNull String getMimeType(@NonNull Context context, @NonNull Uri uri, @Nullable String mimeType) { + String updatedMimeType = MediaUtil.getMimeType(context, uri); + + if (updatedMimeType == null) { + updatedMimeType = MediaUtil.getCorrectedMimeType(mimeType); + } + + return updatedMimeType != null ? updatedMimeType : MediaUtil.UNKNOWN; + } + + private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { + long size = 0; + + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri); + } + + return size; + } + + private static @Nullable String getFileName(@NonNull Context context, @NonNull Uri uri) { + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) { + return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + } + } + + return null; + } + + private static long getDuration(@NonNull Context context, @NonNull Uri uri) { + return 0; + } + + interface Callback { + void onResult(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java new file mode 100644 index 00000000..ba4c5784 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.sharing; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +class ShareSelectionAdapter extends MappingAdapter { + ShareSelectionAdapter() { + registerFactory(ShareSelectionMappingModel.class, + ShareSelectionViewHolder.createFactory(R.layout.share_contact_selection_item)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java new file mode 100644 index 00000000..f0033cbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingModel; + +class ShareSelectionMappingModel implements MappingModel { + + private final ShareContact shareContact; + private final boolean isLast; + + ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isLast) { + this.shareContact = shareContact; + this.isLast = isLast; + } + + @NonNull String getName(@NonNull Context context) { + String name = shareContact.getRecipientId() + .transform(Recipient::resolved) + .transform(recipient -> recipient.isSelf() ? context.getString(R.string.note_to_self) + : recipient.getShortDisplayNameIncludingUsername(context)) + .or(shareContact.getNumber()); + + return isLast ? name : context.getString(R.string.ShareActivity__s_comma, name); + } + + @Override + public boolean areItemsTheSame(@NonNull ShareSelectionMappingModel newItem) { + return newItem.shareContact.equals(shareContact); + } + + @Override + public boolean areContentsTheSame(@NonNull ShareSelectionMappingModel newItem) { + return areItemsTheSame(newItem) && newItem.isLast == isLast; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java new file mode 100644 index 00000000..9e91cef0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.sharing; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingViewHolder; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +public class ShareSelectionViewHolder extends MappingViewHolder { + + protected final @NonNull TextView name; + + public ShareSelectionViewHolder(@NonNull View itemView) { + super(itemView); + + name = findViewById(R.id.recipient_view_name); + } + + @Override + public void bind(@NonNull ShareSelectionMappingModel model) { + name.setText(model.getName(context)); + } + + public static @NonNull MappingAdapter.Factory createFactory(@LayoutRes int layout) { + return new MappingAdapter.LayoutFactory<>(ShareSelectionViewHolder::new, layout); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java new file mode 100644 index 00000000..d16ce923 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public class ShareViewModel extends ViewModel { + + private static final String TAG = Log.tag(ShareViewModel.class); + + private final Context context; + private final ShareRepository shareRepository; + private final MutableLiveData> shareData; + private final MutableLiveData> selectedContacts; + private final LiveData smsShareRestriction; + + private boolean mediaUsed; + private boolean externalShare; + + private ShareViewModel() { + this.context = ApplicationDependencies.getApplication(); + this.shareRepository = new ShareRepository(); + this.shareData = new MutableLiveData<>(); + this.selectedContacts = new DefaultValueLiveData<>(Collections.emptySet()); + this.smsShareRestriction = Transformations.map(selectedContacts, this::updateShareRestriction); + } + + void onSingleMediaShared(@NonNull Uri uri, @Nullable String mimeType) { + externalShare = true; + shareRepository.getResolved(uri, mimeType, shareData::postValue); + } + + void onMultipleMediaShared(@NonNull List uris) { + externalShare = true; + shareRepository.getResolved(uris, shareData::postValue); + } + + boolean isMultiShare() { + return selectedContacts.getValue().size() > 1; + } + + boolean onContactSelected(@NonNull ShareContact selectedContact) { + Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); + if (contacts.add(selectedContact)) { + selectedContacts.setValue(contacts); + return true; + } else { + return false; + } + } + + void onContactDeselected(@NonNull ShareContact selectedContact) { + Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); + if (contacts.remove(selectedContact)) { + selectedContacts.setValue(contacts); + } + } + + @NonNull Set getShareContacts() { + Set contacts = selectedContacts.getValue(); + if (contacts == null) { + return Collections.emptySet(); + } else { + return contacts; + } + } + + @NonNull LiveData>> getSelectedContactModels() { + return Transformations.map(selectedContacts, set -> Stream.of(set) + .>mapIndexed((i, c) -> new ShareSelectionMappingModel(c, i == set.size() - 1)) + .toList()); + } + + @NonNull LiveData getSmsShareRestriction() { + return Transformations.distinctUntilChanged(smsShareRestriction); + } + + void onNonExternalShare() { + externalShare = false; + } + + public void onSuccessfulShare() { + mediaUsed = true; + } + + @NonNull LiveData> getShareData() { + return shareData; + } + + boolean isExternalShare() { + return externalShare; + } + + @Override + protected void onCleared() { + ShareData data = shareData.getValue() != null ? shareData.getValue().orNull() : null; + + if (data != null && data.isExternal() && data.isForIntent() && !mediaUsed) { + Log.i(TAG, "Clearing out unused data."); + BlobProvider.getInstance().delete(context, data.getUri()); + } + } + + private @NonNull SmsShareRestriction updateShareRestriction(@NonNull Set shareContacts) { + if (shareContacts.isEmpty()) { + return SmsShareRestriction.NO_RESTRICTIONS; + } else if (shareContacts.size() == 1) { + ShareContact shareContact = shareContacts.iterator().next(); + Recipient recipient = Recipient.live(shareContact.getRecipientId().get()).get(); + + if (!recipient.isRegistered() || recipient.isForceSmsSelection()) { + return SmsShareRestriction.DISALLOW_MULTI_SHARE; + } else { + return SmsShareRestriction.DISALLOW_SMS_CONTACTS; + } + } else { + return SmsShareRestriction.DISALLOW_SMS_CONTACTS; + } + } + + public static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ShareViewModel()); + } + } + + enum SmsShareRestriction { + NO_RESTRICTIONS, + DISALLOW_SMS_CONTACTS, + DISALLOW_MULTI_SHARE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java new file mode 100644 index 00000000..5b0c9bb4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.LinkPreviewView; +import org.thoughtcrime.securesms.components.SelectionAwareEmojiEditText; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sharing.MultiShareArgs; +import org.thoughtcrime.securesms.sharing.MultiShareDialogs; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +/** + * Handles display and editing of a text message (with possible link preview) before it is forwarded + * to multiple users. + */ +public class ShareInterstitialActivity extends PassphraseRequiredActivity { + + private static final String ARGS = "args"; + + private ShareInterstitialViewModel viewModel; + private LinkPreviewViewModel linkPreviewViewModel; + private CircularProgressButton confirm; + private RecyclerView contactsRecycler; + private Toolbar toolbar; + private LinkPreviewView preview; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final ShareInterstitialSelectionAdapter adapter = new ShareInterstitialSelectionAdapter(); + + public static Intent createIntent(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) { + Intent intent = new Intent(context, ShareInterstitialActivity.class); + + intent.putExtra(ARGS, multiShareArgs); + + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + dynamicTheme.onCreate(this); + setContentView(R.layout.share_interstitial_activity); + + MultiShareArgs args = getIntent().getParcelableExtra(ARGS); + + initializeViewModels(args); + initializeViews(args); + initializeObservers(); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + private void initializeViewModels(@NonNull MultiShareArgs args) { + ShareInterstitialRepository repository = new ShareInterstitialRepository(); + ShareInterstitialViewModel.Factory factory = new ShareInterstitialViewModel.Factory(args, repository); + + viewModel = ViewModelProviders.of(this, factory).get(ShareInterstitialViewModel.class); + + LinkPreviewRepository linkPreviewRepository = new LinkPreviewRepository(); + LinkPreviewViewModel.Factory linkPreviewViewModelFactory = new LinkPreviewViewModel.Factory(linkPreviewRepository); + + linkPreviewViewModel = ViewModelProviders.of(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class); + + boolean hasSms = Stream.of(args.getShareContactAndThreads()) + .anyMatch(c -> { + Recipient recipient = Recipient.resolved(c.getRecipientId()); + return !recipient.isRegistered() || recipient.isForceSmsSelection(); + }); + + if (hasSms) { + linkPreviewViewModel.onTransportChanged(hasSms); + } + } + + private void initializeViews(@NonNull MultiShareArgs args) { + confirm = findViewById(R.id.share_confirm); + toolbar = findViewById(R.id.toolbar); + preview = findViewById(R.id.link_preview); + + confirm.setOnClickListener(unused -> onConfirm()); + + SelectionAwareEmojiEditText text = findViewById(R.id.text); + + toolbar.setNavigationOnClickListener(unused -> finish()); + + text.addTextChangedListener(new AfterTextChanged(editable -> { + linkPreviewViewModel.onTextChanged(this, editable.toString(), text.getSelectionStart(), text.getSelectionEnd()); + viewModel.onDraftTextChanged(editable.toString()); + })); + + //noinspection CodeBlock2Expr + text.setOnSelectionChangedListener(((selStart, selEnd) -> { + linkPreviewViewModel.onTextChanged(this, text.getText().toString(), text.getSelectionStart(), text.getSelectionEnd()); + })); + + preview.setCloseClickedListener(linkPreviewViewModel::onUserCancel); + + int defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius); + preview.setCorners(defaultRadius, defaultRadius); + + text.setText(args.getDraftText()); + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(text); + + contactsRecycler = findViewById(R.id.selected_list); + contactsRecycler.setAdapter(adapter); + + confirm.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + int pad = Math.abs(v.getWidth() + ViewUtil.dpToPx(16)); + ViewUtil.setPaddingEnd(contactsRecycler, pad); + }); + } + + private void initializeObservers() { + viewModel.getRecipients().observe(this, models -> adapter.submitList(models, + () -> contactsRecycler.scrollToPosition(models.size() - 1))); + viewModel.hasDraftText().observe(this, this::handleHasDraftText); + + linkPreviewViewModel.getLinkPreviewState().observe(this, linkPreviewState -> { + preview.setVisibility(View.VISIBLE); + if (linkPreviewState.getError() != null) { + preview.setNoPreview(linkPreviewState.getError()); + viewModel.onLinkPreviewChanged(null); + } else if (linkPreviewState.isLoading()) { + preview.setLoading(); + viewModel.onLinkPreviewChanged(null); + } else if (linkPreviewState.getLinkPreview().isPresent()) { + preview.setLinkPreview(GlideApp.with(this), linkPreviewState.getLinkPreview().get(), true); + viewModel.onLinkPreviewChanged(linkPreviewState.getLinkPreview().get()); + } else if (!linkPreviewState.hasLinks()) { + preview.setVisibility(View.GONE); + viewModel.onLinkPreviewChanged(null); + } + }); + } + + private void handleHasDraftText(boolean hasDraftText) { + confirm.setEnabled(hasDraftText); + confirm.setAlpha(hasDraftText ? 1f : 0.5f); + } + + private void onConfirm() { + confirm.setClickable(false); + confirm.setIndeterminateProgressMode(true); + confirm.setProgress(50); + + viewModel.send(results -> { + MultiShareDialogs.displayResultDialog(this, results, () -> { + setResult(RESULT_OK); + finish(); + }); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java new file mode 100644 index 00000000..27e35ac5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +class ShareInterstitialMappingModel extends RecipientMappingModel { + + private final Recipient recipient; + private final boolean isLast; + + ShareInterstitialMappingModel(@NonNull Recipient recipient, boolean isLast) { + this.recipient = recipient; + this.isLast = isLast; + } + + @Override + public @NonNull String getName(@NonNull Context context) { + String name = recipient.isSelf() ? context.getString(R.string.note_to_self) + : recipient.getShortDisplayNameIncludingUsername(context); + + return isLast ? name : context.getString(R.string.ShareActivity__s_comma, name); + } + + @Override + public @NonNull Recipient getRecipient() { + return recipient; + } + + @Override + public boolean areContentsTheSame(@NonNull ShareInterstitialMappingModel newItem) { + return super.areContentsTheSame(newItem) && isLast == newItem.isLast; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java new file mode 100644 index 00000000..ce741068 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sharing.ShareContactAndThread; + +import java.util.List; +import java.util.Set; + +class ShareInterstitialRepository { + + void loadRecipients(@NonNull Set shareContactAndThreads, Consumer> consumer) { + SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(shareContactAndThreads))); + } + + @WorkerThread + private List resolveRecipients(@NonNull Set shareContactAndThreads) { + return Stream.of(shareContactAndThreads) + .map(ShareContactAndThread::getRecipientId) + .map(Recipient::resolved) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java new file mode 100644 index 00000000..2663234f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; + +class ShareInterstitialSelectionAdapter extends MappingAdapter { + ShareInterstitialSelectionAdapter() { + registerFactory(ShareInterstitialMappingModel.class, RecipientViewHolder.createFactory(R.layout.share_contact_selection_item, null)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java new file mode 100644 index 00000000..61ace906 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.sharing.MultiShareArgs; +import org.thoughtcrime.securesms.sharing.MultiShareSender; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; + +class ShareInterstitialViewModel extends ViewModel { + +private final MultiShareArgs args; + private final MutableLiveData>> recipients; + private final MutableLiveData draftText; + + private LinkPreview linkPreview; + + ShareInterstitialViewModel(@NonNull MultiShareArgs args, @NonNull ShareInterstitialRepository repository) { + this.args = args; + this.recipients = new MutableLiveData<>(); + this.draftText = new DefaultValueLiveData<>(Util.firstNonNull(args.getDraftText(), "")); + + repository.loadRecipients(args.getShareContactAndThreads(), + list -> recipients.postValue(Stream.of(list) + .>mapIndexed((i, r) -> new ShareInterstitialMappingModel(r, i == list.size() - 1)) + .toList())); + + } + + LiveData>> getRecipients() { + return recipients; + } + + LiveData hasDraftText() { + return Transformations.map(draftText, text -> !TextUtils.isEmpty(text)); + } + + void onDraftTextChanged(@NonNull String change) { + draftText.setValue(change); + } + + void onLinkPreviewChanged(@Nullable LinkPreview linkPreview) { + this.linkPreview = linkPreview; + } + + void send(@NonNull Consumer resultsConsumer) { + LinkPreview linkPreview = this.linkPreview; + String draftText = this.draftText.getValue(); + + MultiShareArgs.Builder builder = args.buildUpon() + .withDraftText(draftText) + .withLinkPreview(linkPreview); + + MultiShareSender.send(builder.build(), resultsConsumer); + } + + static class Factory implements ViewModelProvider.Factory { + + private final MultiShareArgs args; + private final ShareInterstitialRepository repository; + + Factory(@NonNull MultiShareArgs args, @NonNull ShareInterstitialRepository repository) { + this.args = args; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new ShareInterstitialViewModel(args, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEncryptedMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEncryptedMessage.java new file mode 100644 index 00000000..66586a68 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEncryptedMessage.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.sms; + +public class IncomingEncryptedMessage extends IncomingTextMessage { + + public IncomingEncryptedMessage(IncomingTextMessage base, String newBody) { + super(base, newBody); + } + + @Override + public boolean isSecureMessage() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEndSessionMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEndSessionMessage.java new file mode 100644 index 00000000..c870ed44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingEndSessionMessage.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.sms; + +public class IncomingEndSessionMessage extends IncomingTextMessage { + + public IncomingEndSessionMessage(IncomingTextMessage base) { + this(base, base.getMessageBody()); + } + + public IncomingEndSessionMessage(IncomingTextMessage base, String newBody) { + super(base, newBody); + } + + @Override + public boolean isEndSession() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java new file mode 100644 index 00000000..ad92536f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.sms; + +import androidx.annotation.NonNull; + +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; + +import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; + +public final class IncomingGroupUpdateMessage extends IncomingTextMessage { + + private final MessageGroupContext groupContext; + + public IncomingGroupUpdateMessage(IncomingTextMessage base, GroupContext groupContext, String body) { + this(base, new MessageGroupContext(groupContext)); + } + + public IncomingGroupUpdateMessage(IncomingTextMessage base, DecryptedGroupV2Context groupV2Context) { + this(base, new MessageGroupContext(groupV2Context)); + } + + public IncomingGroupUpdateMessage(IncomingTextMessage base, MessageGroupContext groupContext) { + super(base, groupContext.getEncodedGroupContext()); + this.groupContext = groupContext; + } + + @Override + public boolean isGroup() { + return true; + } + + public boolean isUpdate() { + return groupContext.isV2Group() || groupContext.requireGroupV1Properties().isUpdate(); + } + + public boolean isGroupV2() { + return groupContext.isV2Group(); + } + + public boolean isQuit() { + return !groupContext.isV2Group() && groupContext.requireGroupV1Properties().isQuit(); + } + + @Override + public boolean isJustAGroupLeave() { + if (isGroupV2() && isUpdate()) { + DecryptedGroupChange decryptedGroupChange = groupContext.requireGroupV2Properties() + .getChange(); + + return changeEditorOnlyWasRemoved(decryptedGroupChange) && + noChangesOtherThanDeletes(decryptedGroupChange); + } + + return false; + } + + protected boolean changeEditorOnlyWasRemoved(@NonNull DecryptedGroupChange decryptedGroupChange) { + return decryptedGroupChange.getDeleteMembersCount() == 1 && + decryptedGroupChange.getDeleteMembers(0).equals(decryptedGroupChange.getEditor()); + } + + protected boolean noChangesOtherThanDeletes(@NonNull DecryptedGroupChange decryptedGroupChange) { + DecryptedGroupChange withoutDeletedMembers = decryptedGroupChange.toBuilder() + .clearDeleteMembers() + .build(); + return DecryptedGroupUtil.changeIsEmpty(withoutDeletedMembers); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityDefaultMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityDefaultMessage.java new file mode 100644 index 00000000..fc9c75e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityDefaultMessage.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.sms; + + +public class IncomingIdentityDefaultMessage extends IncomingTextMessage { + + public IncomingIdentityDefaultMessage(IncomingTextMessage base) { + super(base, ""); + } + + @Override + public boolean isIdentityDefault() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityUpdateMessage.java new file mode 100644 index 00000000..c1f1d79e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityUpdateMessage.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.sms; + +public class IncomingIdentityUpdateMessage extends IncomingTextMessage { + + public IncomingIdentityUpdateMessage(IncomingTextMessage base) { + super(base, ""); + } + + @Override + public boolean isIdentityUpdate() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityVerifiedMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityVerifiedMessage.java new file mode 100644 index 00000000..23a5d519 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingIdentityVerifiedMessage.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.sms; + + +public class IncomingIdentityVerifiedMessage extends IncomingTextMessage { + + public IncomingIdentityVerifiedMessage(IncomingTextMessage base) { + super(base, ""); + } + + @Override + public boolean isIdentityVerified() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java new file mode 100644 index 00000000..22fb7aa8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingJoinedMessage.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.sms; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +public class IncomingJoinedMessage extends IncomingTextMessage { + + public IncomingJoinedMessage(RecipientId sender) { + super(sender, 1, System.currentTimeMillis(), -1, null, Optional.absent(), 0, false); + } + + @Override + public boolean isJoined() { + return true; + } + + @Override + public boolean isSecureMessage() { + return true; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java new file mode 100644 index 00000000..0c978f09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.sms; + +import android.os.Parcel; +import android.os.Parcelable; +import android.telephony.SmsMessage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.List; + +public class IncomingTextMessage implements Parcelable { + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public IncomingTextMessage createFromParcel(Parcel in) { + return new IncomingTextMessage(in); + } + + @Override + public IncomingTextMessage[] newArray(int size) { + return new IncomingTextMessage[size]; + } + }; + private static final String TAG = IncomingTextMessage.class.getSimpleName(); + + private final String message; + private final RecipientId sender; + private final int senderDeviceId; + private final int protocol; + private final String serviceCenterAddress; + private final boolean replyPathPresent; + private final String pseudoSubject; + private final long sentTimestampMillis; + private final long serverTimestampMillis; + @Nullable private final GroupId groupId; + private final boolean push; + private final int subscriptionId; + private final long expiresInMillis; + private final boolean unidentified; + + public IncomingTextMessage(@NonNull RecipientId sender, @NonNull SmsMessage message, int subscriptionId) { + this.message = message.getDisplayMessageBody(); + this.sender = sender; + this.senderDeviceId = SignalServiceAddress.DEFAULT_DEVICE_ID; + this.protocol = message.getProtocolIdentifier(); + this.serviceCenterAddress = message.getServiceCenterAddress(); + this.replyPathPresent = message.isReplyPathPresent(); + this.pseudoSubject = message.getPseudoSubject(); + this.sentTimestampMillis = message.getTimestampMillis(); + this.serverTimestampMillis = -1; + this.subscriptionId = subscriptionId; + this.expiresInMillis = 0; + this.groupId = null; + this.push = false; + this.unidentified = false; + } + + public IncomingTextMessage(@NonNull RecipientId sender, + int senderDeviceId, + long sentTimestampMillis, + long serverTimestampMillis, + String encodedBody, + Optional groupId, + long expiresInMillis, + boolean unidentified) + { + this.message = encodedBody; + this.sender = sender; + this.senderDeviceId = senderDeviceId; + this.protocol = 31337; + this.serviceCenterAddress = "GCM"; + this.replyPathPresent = true; + this.pseudoSubject = ""; + this.sentTimestampMillis = sentTimestampMillis; + this.serverTimestampMillis = serverTimestampMillis; + this.push = true; + this.subscriptionId = -1; + this.expiresInMillis = expiresInMillis; + this.unidentified = unidentified; + this.groupId = groupId.orNull(); + } + + public IncomingTextMessage(Parcel in) { + this.message = in.readString(); + this.sender = in.readParcelable(IncomingTextMessage.class.getClassLoader()); + this.senderDeviceId = in.readInt(); + this.protocol = in.readInt(); + this.serviceCenterAddress = in.readString(); + this.replyPathPresent = (in.readInt() == 1); + this.pseudoSubject = in.readString(); + this.sentTimestampMillis = in.readLong(); + this.serverTimestampMillis = in.readLong(); + this.groupId = GroupId.parseNullableOrThrow(in.readString()); + this.push = (in.readInt() == 1); + this.subscriptionId = in.readInt(); + this.expiresInMillis = in.readLong(); + this.unidentified = in.readInt() == 1; + } + + public IncomingTextMessage(IncomingTextMessage base, String newBody) { + this.message = newBody; + this.sender = base.getSender(); + this.senderDeviceId = base.getSenderDeviceId(); + this.protocol = base.getProtocol(); + this.serviceCenterAddress = base.getServiceCenterAddress(); + this.replyPathPresent = base.isReplyPathPresent(); + this.pseudoSubject = base.getPseudoSubject(); + this.sentTimestampMillis = base.getSentTimestampMillis(); + this.serverTimestampMillis = base.getServerTimestampMillis(); + this.groupId = base.getGroupId(); + this.push = base.isPush(); + this.subscriptionId = base.getSubscriptionId(); + this.expiresInMillis = base.getExpiresIn(); + this.unidentified = base.isUnidentified(); + } + + public IncomingTextMessage(List fragments) { + StringBuilder body = new StringBuilder(); + + for (IncomingTextMessage message : fragments) { + body.append(message.getMessageBody()); + } + + this.message = body.toString(); + this.sender = fragments.get(0).getSender(); + this.senderDeviceId = fragments.get(0).getSenderDeviceId(); + this.protocol = fragments.get(0).getProtocol(); + this.serviceCenterAddress = fragments.get(0).getServiceCenterAddress(); + this.replyPathPresent = fragments.get(0).isReplyPathPresent(); + this.pseudoSubject = fragments.get(0).getPseudoSubject(); + this.sentTimestampMillis = fragments.get(0).getSentTimestampMillis(); + this.serverTimestampMillis = fragments.get(0).getServerTimestampMillis(); + this.groupId = fragments.get(0).getGroupId(); + this.push = fragments.get(0).isPush(); + this.subscriptionId = fragments.get(0).getSubscriptionId(); + this.expiresInMillis = fragments.get(0).getExpiresIn(); + this.unidentified = fragments.get(0).isUnidentified(); + } + + protected IncomingTextMessage(@NonNull RecipientId sender, @Nullable GroupId groupId) + { + this.message = ""; + this.sender = sender; + this.senderDeviceId = SignalServiceAddress.DEFAULT_DEVICE_ID; + this.protocol = 31338; + this.serviceCenterAddress = "Outgoing"; + this.replyPathPresent = true; + this.pseudoSubject = ""; + this.sentTimestampMillis = System.currentTimeMillis(); + this.serverTimestampMillis = sentTimestampMillis; + this.groupId = groupId; + this.push = true; + this.subscriptionId = -1; + this.expiresInMillis = 0; + this.unidentified = false; + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public long getExpiresIn() { + return expiresInMillis; + } + + public long getSentTimestampMillis() { + return sentTimestampMillis; + } + + public long getServerTimestampMillis() { + return serverTimestampMillis; + } + + public String getPseudoSubject() { + return pseudoSubject; + } + + public String getMessageBody() { + return message; + } + + public RecipientId getSender() { + return sender; + } + + public int getSenderDeviceId() { + return senderDeviceId; + } + + public int getProtocol() { + return protocol; + } + + public String getServiceCenterAddress() { + return serviceCenterAddress; + } + + public boolean isReplyPathPresent() { + return replyPathPresent; + } + + public boolean isSecureMessage() { + return false; + } + + public boolean isPreKeyBundle() { + return isLegacyPreKeyBundle() || isContentPreKeyBundle(); + } + + public boolean isLegacyPreKeyBundle() { + return false; + } + + public boolean isContentPreKeyBundle() { + return false; + } + + public boolean isEndSession() { + return false; + } + + public boolean isPush() { + return push; + } + + public @Nullable GroupId getGroupId() { + return groupId; + } + + public boolean isGroup() { + return false; + } + + public boolean isJoined() { + return false; + } + + public boolean isIdentityUpdate() { + return false; + } + + public boolean isIdentityVerified() { + return false; + } + + public boolean isIdentityDefault() { + return false; + } + + /** + * @return True iff the message is only a group leave of a single member. + */ + public boolean isJustAGroupLeave() { + return false; + } + + public boolean isUnidentified() { + return unidentified; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeString(message); + out.writeParcelable(sender, flags); + out.writeInt(senderDeviceId); + out.writeInt(protocol); + out.writeString(serviceCenterAddress); + out.writeInt(replyPathPresent ? 1 : 0); + out.writeString(pseudoSubject); + out.writeLong(sentTimestampMillis); + out.writeString(groupId == null ? null : groupId.toString()); + out.writeInt(push ? 1 : 0); + out.writeInt(subscriptionId); + out.writeLong(expiresInMillis); + out.writeInt(unidentified ? 1 : 0); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java new file mode 100644 index 00000000..5d06e585 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -0,0 +1,596 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.sms; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; +import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob; +import org.thoughtcrime.securesms.jobs.AttachmentCopyJob; +import org.thoughtcrime.securesms.jobs.AttachmentMarkUploadedJob; +import org.thoughtcrime.securesms.jobs.AttachmentUploadJob; +import org.thoughtcrime.securesms.jobs.MmsSendJob; +import org.thoughtcrime.securesms.jobs.ProfileKeySendJob; +import org.thoughtcrime.securesms.jobs.PushGroupSendJob; +import org.thoughtcrime.securesms.jobs.PushMediaSendJob; +import org.thoughtcrime.securesms.jobs.PushTextSendJob; +import org.thoughtcrime.securesms.jobs.ReactionSendJob; +import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob; +import org.thoughtcrime.securesms.jobs.ResumableUploadSpecJob; +import org.thoughtcrime.securesms.jobs.SmsSendJob; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class MessageSender { + + private static final String TAG = MessageSender.class.getSimpleName(); + + /** + * Suitable for a 1:1 conversation or a GV1 group only. + */ + @WorkerThread + public static void sendProfileKey(final Context context, final long threadId) { + ApplicationDependencies.getJobManager().add(ProfileKeySendJob.create(context, threadId)); + } + + public static long send(final Context context, + final OutgoingTextMessage message, + final long threadId, + final boolean forceSms, + final SmsDatabase.InsertListener insertListener) + { + Log.i(TAG, "Sending text message to " + message.getRecipient().getId() + ", thread: " + threadId); + MessageDatabase database = DatabaseFactory.getSmsDatabase(context); + Recipient recipient = message.getRecipient(); + boolean keyExchange = message.isKeyExchange(); + + long allocatedThreadId = DatabaseFactory.getThreadDatabase(context).getOrCreateValidThreadId(recipient, threadId); + long messageId = database.insertMessageOutbox(allocatedThreadId, message, forceSms, System.currentTimeMillis(), insertListener); + + sendTextMessage(context, recipient, forceSms, keyExchange, messageId); + onMessageSent(); + //Moti message sender + return allocatedThreadId; + } + + public static long send(final Context context, + final OutgoingMediaMessage message, + final long threadId, + final boolean forceSms, + final SmsDatabase.InsertListener insertListener) + { + Log.i(TAG, "Sending media message to " + message.getRecipient().getId() + ", thread: " + threadId); + try { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + MessageDatabase database = DatabaseFactory.getMmsDatabase(context); + + long allocatedThreadId = threadDatabase.getOrCreateValidThreadId(message.getRecipient(), threadId, message.getDistributionType()); + Recipient recipient = message.getRecipient(); + long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); + + sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList()); + onMessageSent(); + + return allocatedThreadId; + } catch (MmsException e) { + Log.w(TAG, e); + return threadId; + } + } + + + public static long sendPushWithPreUploadedMedia(final Context context, + final OutgoingMediaMessage message, + final Collection preUploadResults, + final long threadId, + final SmsDatabase.InsertListener insertListener) + { + Log.i(TAG, "Sending media message with pre-uploads to " + message.getRecipient().getId() + ", thread: " + threadId); + Preconditions.checkArgument(message.getAttachments().isEmpty(), "If the media is pre-uploaded, there should be no attachments on the message."); + + try { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + + long allocatedThreadId; + + if (threadId == -1) { + allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType()); + } else { + allocatedThreadId = threadId; + } + + Recipient recipient = message.getRecipient(); + long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, insertListener); + + List attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList(); + List jobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList(); + + attachmentDatabase.updateMessageId(attachmentIds, messageId); + + sendMediaMessage(context, recipient, false, messageId, jobIds); + onMessageSent(); + + return allocatedThreadId; + } catch (MmsException e) { + Log.w(TAG, e); + return threadId; + } + } + + public static void sendMediaBroadcast(@NonNull Context context, @NonNull List messages, @NonNull Collection preUploadResults) { + Log.i(TAG, "Sending media broadcast to " + Stream.of(messages).map(m -> m.getRecipient().getId()).toList()); + Preconditions.checkArgument(messages.size() > 0, "No messages!"); + Preconditions.checkArgument(Stream.of(messages).allMatch(m -> m.getAttachments().isEmpty()), "Messages can't have attachments! They should be pre-uploaded."); + + JobManager jobManager = ApplicationDependencies.getJobManager(); + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + List preUploadAttachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList(); + List preUploadJobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList(); + List messageIds = new ArrayList<>(messages.size()); + List messageDependsOnIds = new ArrayList<>(preUploadJobIds); + + mmsDatabase.beginTransaction(); + try { + OutgoingSecureMediaMessage primaryMessage = messages.get(0); + long primaryThreadId = threadDatabase.getThreadIdFor(primaryMessage.getRecipient(), primaryMessage.getDistributionType()); + long primaryMessageId = mmsDatabase.insertMessageOutbox(primaryMessage, primaryThreadId, false, null); + + attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId); + messageIds.add(primaryMessageId); + + if (messages.size() > 0) { + List secondaryMessages = messages.subList(1, messages.size()); + List> attachmentCopies = new ArrayList<>(); + List preUploadAttachments = Stream.of(preUploadAttachmentIds) + .map(attachmentDatabase::getAttachment) + .toList(); + + for (int i = 0; i < preUploadAttachmentIds.size(); i++) { + attachmentCopies.add(new ArrayList<>(messages.size())); + } + + for (OutgoingSecureMediaMessage secondaryMessage : secondaryMessages) { + long allocatedThreadId = threadDatabase.getThreadIdFor(secondaryMessage.getRecipient(), secondaryMessage.getDistributionType()); + long messageId = mmsDatabase.insertMessageOutbox(secondaryMessage, allocatedThreadId, false, null); + List attachmentIds = new ArrayList<>(preUploadAttachmentIds.size()); + + for (int i = 0; i < preUploadAttachments.size(); i++) { + AttachmentId attachmentId = attachmentDatabase.insertAttachmentForPreUpload(preUploadAttachments.get(i)).getAttachmentId(); + attachmentCopies.get(i).add(attachmentId); + attachmentIds.add(attachmentId); + } + + attachmentDatabase.updateMessageId(attachmentIds, messageId); + messageIds.add(messageId); + } + + for (int i = 0; i < attachmentCopies.size(); i++) { + Job copyJob = new AttachmentCopyJob(preUploadAttachmentIds.get(i), attachmentCopies.get(i)); + jobManager.add(copyJob, preUploadJobIds); + messageDependsOnIds.add(copyJob.getId()); + } + } + + for (int i = 0; i < messageIds.size(); i++) { + long messageId = messageIds.get(i); + OutgoingSecureMediaMessage message = messages.get(i); + Recipient recipient = message.getRecipient(); + + if (isLocalSelfSend(context, recipient, false)) { + sendLocalMediaSelf(context, messageId); + } else if (isGroupPushSend(recipient)) { + jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null, true), messageDependsOnIds, recipient.getId().toQueueKey()); + } else { + jobManager.add(new PushMediaSendJob(messageId, recipient), messageDependsOnIds, recipient.getId().toQueueKey()); + } + } + + onMessageSent(); + mmsDatabase.setTransactionSuccessful(); + } catch (MmsException e) { + Log.w(TAG, "Failed to send messages.", e); + } finally { + mmsDatabase.endTransaction(); + } + } + + /** + * @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't + * be enqueued (like in the case of a local self-send). + */ + public static @Nullable PreUploadResult preUploadPushAttachment(@NonNull Context context, @NonNull Attachment attachment, @Nullable Recipient recipient) { + if (isLocalSelfSend(context, recipient, false)) { + return null; + } + Log.i(TAG, "Pre-uploading attachment for " + (recipient != null ? recipient.getId() : "null")); + + try { + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment databaseAttachment = attachmentDatabase.insertAttachmentForPreUpload(attachment); + + Job compressionJob = AttachmentCompressionJob.fromAttachment(databaseAttachment, false, -1); + Job resumableUploadSpecJob = new ResumableUploadSpecJob(); + Job uploadJob = new AttachmentUploadJob(databaseAttachment.getAttachmentId()); + + ApplicationDependencies.getJobManager() + .startChain(compressionJob) + .then(resumableUploadSpecJob) + .then(uploadJob) + .enqueue(); + + return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), resumableUploadSpecJob.getId(), uploadJob.getId())); + } catch (MmsException e) { + Log.w(TAG, "preUploadPushAttachment() - Failed to upload!", e); + return null; + } + } + + public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) { + MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis()); + + db.addReaction(messageId, reaction); + + try { + ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, false)); + onMessageSent(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring."); + } + } + + public static void sendReactionRemoval(@NonNull Context context, long messageId, boolean isMms, @NonNull ReactionRecord reaction) { + MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + db.deleteReaction(messageId, reaction.getAuthor()); + + try { + ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, true)); + onMessageSent(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendReactionRemoval] Could not find message! Ignoring."); + } + } + + public static void sendRemoteDelete(@NonNull Context context, long messageId, boolean isMms) { + MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + db.markAsRemoteDelete(messageId); + db.markAsSending(messageId); + + try { + ApplicationDependencies.getJobManager().add(RemoteDeleteSendJob.create(context, messageId, isMms)); + onMessageSent(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendRemoteDelete] Could not find message! Ignoring."); + } + } + + public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) { + if (!messageRecord.isMms()) throw new AssertionError("Not Group"); + sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId, Collections.emptyList()); + onMessageSent(); + } + + public static void resend(Context context, MessageRecord messageRecord) { + long messageId = messageRecord.getId(); + boolean forceSms = messageRecord.isForcedSms(); + boolean keyExchange = messageRecord.isKeyExchange(); + Recipient recipient = messageRecord.getRecipient(); + + if (messageRecord.isMms()) { + sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList()); + } else { + sendTextMessage(context, recipient, forceSms, keyExchange, messageId); + } + + onMessageSent(); + } + + public static void onMessageSent() { + EventBus.getDefault().postSticky(MessageSentEvent.INSTANCE); + } + + private static void sendMediaMessage(Context context, Recipient recipient, boolean forceSms, long messageId, @NonNull Collection uploadJobIds) + { + if (isLocalSelfSend(context, recipient, forceSms)) { + sendLocalMediaSelf(context, messageId); + } else if (isGroupPushSend(recipient)) { + sendGroupPush(context, recipient, messageId, null, uploadJobIds); + } else if (!forceSms && isPushMediaSend(context, recipient)) { + sendMediaPush(context, recipient, messageId, uploadJobIds); + } else { + sendMms(context, messageId); + } + } + + private static void sendTextMessage(Context context, Recipient recipient, + boolean forceSms, boolean keyExchange, + long messageId) + { + if (isLocalSelfSend(context, recipient, forceSms)) { + sendLocalTextSelf(context, messageId); + } else if (!forceSms && isPushTextSend(context, recipient, keyExchange)) { + sendTextPush(recipient, messageId); + } else { + sendSms(recipient, messageId); + } + } + + private static void sendTextPush(Recipient recipient, long messageId) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + jobManager.add(new PushTextSendJob(messageId, recipient)); + } + + private static void sendMediaPush(Context context, Recipient recipient, long messageId, @NonNull Collection uploadJobIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (uploadJobIds.size() > 0) { + Job mediaSend = new PushMediaSendJob(messageId, recipient); + jobManager.add(mediaSend, uploadJobIds); + } else { + PushMediaSendJob.enqueue(context, jobManager, messageId, recipient); + } + } + + private static void sendGroupPush(Context context, Recipient recipient, long messageId, RecipientId filterRecipientId, @NonNull Collection uploadJobIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (uploadJobIds.size() > 0) { + Job groupSend = new PushGroupSendJob(messageId, recipient.getId(), filterRecipientId, !uploadJobIds.isEmpty()); + jobManager.add(groupSend, uploadJobIds, uploadJobIds.isEmpty() ? null : recipient.getId().toQueueKey()); + } else { + PushGroupSendJob.enqueue(context, jobManager, messageId, recipient.getId(), filterRecipientId); + } + } + + private static void sendSms(Recipient recipient, long messageId) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + jobManager.add(new SmsSendJob(messageId, recipient)); + } + + private static void sendMms(Context context, long messageId) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + MmsSendJob.enqueue(context, jobManager, messageId); + } + + private static boolean isPushTextSend(Context context, Recipient recipient, boolean keyExchange) { + if (!TextSecurePreferences.isPushRegistered(context)) { + return false; + } + + if (keyExchange) { + return false; + } + + return isPushDestination(context, recipient); + } + + private static boolean isPushMediaSend(Context context, Recipient recipient) { + if (!TextSecurePreferences.isPushRegistered(context)) { + return false; + } + + if (recipient.isGroup()) { + return false; + } + + return isPushDestination(context, recipient); + } + + private static boolean isGroupPushSend(Recipient recipient) { + return recipient.isGroup() && !recipient.isMmsGroup(); + } + + private static boolean isPushDestination(Context context, Recipient destination) { + if (destination.resolve().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { + return true; + } else if (destination.resolve().getRegistered() == RecipientDatabase.RegisteredState.NOT_REGISTERED) { + return false; + } else { + try { + RecipientDatabase.RegisteredState state = DirectoryHelper.refreshDirectoryFor(context, destination, false); + return state == RecipientDatabase.RegisteredState.REGISTERED; + } catch (IOException e1) { + Log.w(TAG, e1); + return false; + } + } + } + + public static boolean isLocalSelfSend(@NonNull Context context, @Nullable Recipient recipient, boolean forceSms) { + return recipient != null && + recipient.isSelf() && + !forceSms && + TextSecurePreferences.isPushRegistered(context) && + !TextSecurePreferences.isMultiDevice(context); + } + + private static void sendLocalMediaSelf(Context context, long messageId) { + try { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); + MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + OutgoingMediaMessage message = mmsDatabase.getOutgoingMessage(messageId); + SyncMessageId syncId = new SyncMessageId(Recipient.self().getId(), message.getSentTimeMillis()); + List attachments = new LinkedList<>(); + + + attachments.addAll(message.getAttachments()); + + attachments.addAll(Stream.of(message.getLinkPreviews()) + .map(LinkPreview::getThumbnail) + .filter(Optional::isPresent) + .map(Optional::get) + .toList()); + + attachments.addAll(Stream.of(message.getSharedContacts()) + .map(Contact::getAvatar).withoutNulls() + .map(Contact.Avatar::getAttachment).withoutNulls() + .toList()); + + List compressionJobs = Stream.of(attachments) + .map(a -> AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1)) + .toList(); + + List fakeUploadJobs = Stream.of(attachments) + .map(a -> new AttachmentMarkUploadedJob(messageId, ((DatabaseAttachment) a).getAttachmentId())) + .toList(); + + ApplicationDependencies.getJobManager().startChain(compressionJobs) + .then(fakeUploadJobs) + .enqueue(); + + mmsDatabase.markAsSent(messageId, true); + mmsDatabase.markUnidentified(messageId, true); + + mmsSmsDatabase.incrementDeliveryReceiptCount(syncId, System.currentTimeMillis()); + mmsSmsDatabase.incrementReadReceiptCount(syncId, System.currentTimeMillis()); + mmsSmsDatabase.incrementViewedReceiptCount(syncId, System.currentTimeMillis()); + + if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) { + mmsDatabase.markExpireStarted(messageId); + expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn()); + } + } catch (NoSuchMessageException | MmsException e) { + Log.w(TAG, "Failed to update self-sent message.", e); + } + } + + private static void sendLocalTextSelf(Context context, long messageId) { + try { + ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); + SmsMessageRecord message = smsDatabase.getSmsMessage(messageId); + SyncMessageId syncId = new SyncMessageId(Recipient.self().getId(), message.getDateSent()); + + smsDatabase.markAsSent(messageId, true); + smsDatabase.markUnidentified(messageId, true); + + mmsSmsDatabase.incrementDeliveryReceiptCount(syncId, System.currentTimeMillis()); + mmsSmsDatabase.incrementReadReceiptCount(syncId, System.currentTimeMillis()); + + if (message.getExpiresIn() > 0) { + smsDatabase.markExpireStarted(messageId); + expirationManager.scheduleDeletion(message.getId(), message.isMms(), message.getExpiresIn()); + } + } catch (NoSuchMessageException e) { + Log.w(TAG, "Failed to update self-sent message.", e); + } + } + + public static class PreUploadResult implements Parcelable { + private final AttachmentId attachmentId; + private final Collection jobIds; + + PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection jobIds) { + this.attachmentId = attachmentId; + this.jobIds = jobIds; + } + + private PreUploadResult(Parcel in) { + this.attachmentId = in.readParcelable(AttachmentId.class.getClassLoader()); + this.jobIds = ParcelUtil.readStringCollection(in); + } + + public @NonNull AttachmentId getAttachmentId() { + return attachmentId; + } + + public @NonNull Collection getJobIds() { + return jobIds; + } + + public static final Creator CREATOR = new Creator() { + @Override + public PreUploadResult createFromParcel(Parcel in) { + return new PreUploadResult(in); + } + + @Override + public PreUploadResult[] newArray(int size) { + return new PreUploadResult[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(attachmentId, flags); + ParcelUtil.writeStringCollection(dest, jobIds); + } + } + + public enum MessageSentEvent { + INSTANCE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEncryptedMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEncryptedMessage.java new file mode 100644 index 00000000..9f2bfcb8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEncryptedMessage.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.sms; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class OutgoingEncryptedMessage extends OutgoingTextMessage { + + public OutgoingEncryptedMessage(Recipient recipient, String body, long expiresIn) { + super(recipient, body, expiresIn, -1); + } + + private OutgoingEncryptedMessage(OutgoingEncryptedMessage base, String body) { + super(base, body); + } + + @Override + public boolean isSecureMessage() { + return true; + } + + @Override + public OutgoingTextMessage withBody(String body) { + return new OutgoingEncryptedMessage(this, body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEndSessionMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEndSessionMessage.java new file mode 100644 index 00000000..cbba0475 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingEndSessionMessage.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.sms; + +public class OutgoingEndSessionMessage extends OutgoingTextMessage { + + public OutgoingEndSessionMessage(OutgoingTextMessage base) { + this(base, base.getMessageBody()); + } + + public OutgoingEndSessionMessage(OutgoingTextMessage message, String body) { + super(message, body); + } + + @Override + public boolean isEndSession() { + return true; + } + + @Override + public OutgoingTextMessage withBody(String body) { + return new OutgoingEndSessionMessage(this, body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityDefaultMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityDefaultMessage.java new file mode 100644 index 00000000..30bc27a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityDefaultMessage.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.sms; + + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class OutgoingIdentityDefaultMessage extends OutgoingTextMessage { + + public OutgoingIdentityDefaultMessage(Recipient recipient) { + this(recipient, ""); + } + + private OutgoingIdentityDefaultMessage(Recipient recipient, String body) { + super(recipient, body, -1); + } + + @Override + public boolean isIdentityDefault() { + return true; + } + + public OutgoingTextMessage withBody(String body) { + return new OutgoingIdentityDefaultMessage(getRecipient()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityVerifiedMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityVerifiedMessage.java new file mode 100644 index 00000000..23d83334 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingIdentityVerifiedMessage.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.sms; + + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class OutgoingIdentityVerifiedMessage extends OutgoingTextMessage { + + public OutgoingIdentityVerifiedMessage(Recipient recipient) { + this(recipient, ""); + } + + private OutgoingIdentityVerifiedMessage(Recipient recipient, String body) { + super(recipient, body, -1); + } + + @Override + public boolean isIdentityVerified() { + return true; + } + + @Override + public OutgoingTextMessage withBody(String body) { + return new OutgoingIdentityVerifiedMessage(getRecipient(), body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingKeyExchangeMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingKeyExchangeMessage.java new file mode 100644 index 00000000..557814c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingKeyExchangeMessage.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.sms; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public class OutgoingKeyExchangeMessage extends OutgoingTextMessage { + + public OutgoingKeyExchangeMessage(Recipient recipient, String message) { + super(recipient, message, -1); + } + + private OutgoingKeyExchangeMessage(OutgoingKeyExchangeMessage base, String body) { + super(base, body); + } + + @Override + public boolean isKeyExchange() { + return true; + } + + @Override + public OutgoingTextMessage withBody(String body) { + return new OutgoingKeyExchangeMessage(this, body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingPrekeyBundleMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingPrekeyBundleMessage.java new file mode 100644 index 00000000..ae7f9329 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingPrekeyBundleMessage.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.sms; + + +public class OutgoingPrekeyBundleMessage extends OutgoingTextMessage { + + public OutgoingPrekeyBundleMessage(OutgoingTextMessage message, String body) { + super(message, body); + } + + @Override + public boolean isPreKeyBundle() { + return true; + } + + @Override + public OutgoingTextMessage withBody(String body) { + return new OutgoingPrekeyBundleMessage(this, body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java new file mode 100644 index 00000000..e74cb8ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.sms; + +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class OutgoingTextMessage { + + private final Recipient recipient; + private final String message; + private final int subscriptionId; + private final long expiresIn; + + public OutgoingTextMessage(Recipient recipient, String message, int subscriptionId) { + this(recipient, message, 0, subscriptionId); + } + + public OutgoingTextMessage(Recipient recipient, String message, long expiresIn, int subscriptionId) { + this.recipient = recipient; + this.message = message; + this.expiresIn = expiresIn; + this.subscriptionId = subscriptionId; + } + + protected OutgoingTextMessage(OutgoingTextMessage base, String body) { + this.recipient = base.getRecipient(); + this.subscriptionId = base.getSubscriptionId(); + this.expiresIn = base.getExpiresIn(); + this.message = body; + } + + public long getExpiresIn() { + return expiresIn; + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public String getMessageBody() { + return message; + } + + public Recipient getRecipient() { + return recipient; + } + + public boolean isKeyExchange() { + return false; + } + + public boolean isSecureMessage() { + return false; + } + + public boolean isEndSession() { + return false; + } + + public boolean isPreKeyBundle() { + return false; + } + + public boolean isIdentityVerified() { + return false; + } + + public boolean isIdentityDefault() { + return false; + } + + public static OutgoingTextMessage from(SmsMessageRecord record) { + if (record.isSecure()) { + return new OutgoingEncryptedMessage(record.getRecipient(), record.getBody(), record.getExpiresIn()); + } else if (record.isKeyExchange()) { + return new OutgoingKeyExchangeMessage(record.getRecipient(), record.getBody()); + } else if (record.isEndSession()) { + return new OutgoingEndSessionMessage(new OutgoingTextMessage(record.getRecipient(), record.getBody(), 0, -1)); + } else { + return new OutgoingTextMessage(record.getRecipient(), record.getBody(), record.getExpiresIn(), record.getSubscriptionId()); + } + } + + public OutgoingTextMessage withBody(String body) { + return new OutgoingTextMessage(this, body); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java b/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java new file mode 100644 index 00000000..b6cb7e5e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.sms; + +import android.content.Context; +import android.os.Looper; +import android.telephony.PhoneStateListener; +import android.telephony.ServiceState; +import android.telephony.TelephonyManager; + +public class TelephonyServiceState { + + public boolean isConnected(Context context) { + ListenThread listenThread = new ListenThread(context); + listenThread.start(); + + return listenThread.get(); + } + + private static class ListenThread extends Thread { + + private final Context context; + + private boolean complete; + private boolean result; + + public ListenThread(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void run() { + Looper looper = initializeLooper(); + ListenCallback callback = new ListenCallback(looper); + + TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(callback, PhoneStateListener.LISTEN_SERVICE_STATE); + + Looper.loop(); + + telephonyManager.listen(callback, PhoneStateListener.LISTEN_NONE); + + set(callback.isConnected()); + } + + private Looper initializeLooper() { + Looper looper = Looper.myLooper(); + + if (looper == null) { + Looper.prepare(); + } + + return Looper.myLooper(); + } + + public synchronized boolean get() { + while (!complete) { + try { + wait(); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + return result; + } + + private synchronized void set(boolean result) { + this.result = result; + this.complete = true; + notifyAll(); + } + } + + private static class ListenCallback extends PhoneStateListener { + + private final Looper looper; + private volatile boolean connected; + + public ListenCallback(Looper looper) { + this.looper = looper; + } + + @Override + public void onServiceStateChanged(ServiceState serviceState) { + this.connected = (serviceState.getState() == ServiceState.STATE_IN_SERVICE); + looper.quit(); + } + + public boolean isConnected() { + return connected; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/BlessedPacks.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/BlessedPacks.java new file mode 100644 index 00000000..00884f62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/BlessedPacks.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.internal.util.JsonUtil; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * Maintains a list of "blessed" sticker packs that essentially serve as defaults. + */ +public final class BlessedPacks { + + public static final Pack ZOZO = new Pack("fb535407d2f6497ec074df8b9c51dd1d", "17e971c134035622781d2ee249e6473b774583750b68c11bb82b7509c68b6dfd"); + public static final Pack BANDIT = new Pack("9acc9e8aba563d26a4994e69263e3b25", "5a6dff3948c28efb9b7aaf93ecc375c69fc316e78077ed26867a14d10a0f6a12"); + public static final Pack SWOON_HANDS = new Pack("e61fa0867031597467ccc036cc65d403", "13ae7b1a7407318280e9b38c1261ded38e0e7138b9f964a6ccbb73e40f737a9b"); + public static final Pack SWOON_FACES = new Pack("cca32f5b905208b7d0f1e17f23fdc185", "8bf8e95f7a45bdeafe0c8f5b002ef01ab95b8f1b5baac4019ccd6b6be0b1837a"); + public static final Pack DAY_BY_DAY = new Pack("cfc50156556893ef9838069d3890fe49", "5f5beab7d382443cb00a1e48eb95297b6b8cadfd0631e5d0d9dc949e6999ff4b"); + + private static final Set BLESSED_PACK_IDS = new HashSet() {{ + add(ZOZO.getPackId()); + add(BANDIT.getPackId()); + add(SWOON_HANDS.getPackId()); + add(SWOON_FACES.getPackId()); + add(DAY_BY_DAY.getPackId()); + }}; + + public static boolean contains(@NonNull String packId) { + return BLESSED_PACK_IDS.contains(packId); + } + + public static class Pack { + @JsonProperty private final String packId; + @JsonProperty private final String packKey; + + public Pack(@NonNull @JsonProperty("packId") String packId, + @NonNull @JsonProperty("packKey") String packKey) + { + this.packId = packId; + this.packKey = packKey; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public @NonNull String toJson() { + return JsonUtil.toJson(this); + } + + public static @NonNull Pack fromJson(@NonNull String json) { + try { + return JsonUtil.fromJson(json, Pack.class); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java new file mode 100644 index 00000000..f7473b7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageAdapter.java @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.stickers; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.glide.cache.ApngOptions; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adapter for a specific page in the sticker keyboard. Shows the stickers in a grid. + * @see StickerKeyboardPageFragment + */ +final class StickerKeyboardPageAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List stickers; + private final boolean allowApngAnimation; + + private int stickerSize; + + StickerKeyboardPageAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean allowApngAnimation) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.allowApngAnimation = allowApngAnimation; + this.stickers = new ArrayList<>(); + + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return stickers.get(position).getRowId(); + } + + @Override + public @NonNull StickerKeyboardPageViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new StickerKeyboardPageViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.sticker_keyboard_page_list_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull StickerKeyboardPageViewHolder viewHolder, int i) { + viewHolder.bind(glideRequests, eventListener, stickers.get(i), stickerSize, allowApngAnimation); + } + + @Override + public void onViewRecycled(@NonNull StickerKeyboardPageViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return stickers.size(); + } + + void setStickers(@NonNull List stickers, @Px int stickerSize) { + this.stickers.clear(); + this.stickers.addAll(stickers); + + this.stickerSize = stickerSize; + + notifyDataSetChanged(); + } + + void setStickerSize(@Px int stickerSize) { + this.stickerSize = stickerSize; + notifyDataSetChanged(); + } + + static class StickerKeyboardPageViewHolder extends RecyclerView.ViewHolder { + + private final ImageView image; + + private StickerRecord currentSticker; + + public StickerKeyboardPageViewHolder(@NonNull View itemView) { + super(itemView); + image = itemView.findViewById(R.id.sticker_keyboard_page_image); + } + + public void bind(@NonNull GlideRequests glideRequests, + @Nullable EventListener eventListener, + @NonNull StickerRecord sticker, + @Px int size, + boolean allowApngAnimation) + { + currentSticker = sticker; + + itemView.getLayoutParams().height = size; + itemView.getLayoutParams().width = size; + itemView.requestLayout(); + + glideRequests.load(new DecryptableUri(sticker.getUri())) + .set(ApngOptions.ANIMATE, allowApngAnimation) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(image); + + if (eventListener != null) { + image.setOnClickListener(v -> eventListener.onStickerClicked(sticker)); + image.setOnLongClickListener(v -> { + eventListener.onStickerLongClicked(v); + return true; + }); + } else { + image.setOnClickListener(null); + image.setOnLongClickListener(null); + } + } + + void recycle() { + image.setOnClickListener(null); + } + + @Nullable StickerRecord getCurrentSticker() { + return currentSticker; + } + } + + interface EventListener { + void onStickerClicked(@NonNull StickerRecord sticker); + void onStickerLongClicked(@NonNull View targetView); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageFragment.java new file mode 100644 index 00000000..a615a01f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageFragment.java @@ -0,0 +1,166 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.stickers.StickerKeyboardPageAdapter.StickerKeyboardPageViewHolder; +import org.thoughtcrime.securesms.util.DeviceProperties; +import org.whispersystems.libsignal.util.Pair; + +/** + * An individual page of stickers in the {@link StickerKeyboardProvider}. + */ +public final class StickerKeyboardPageFragment extends Fragment implements StickerKeyboardPageAdapter.EventListener, + StickerRolloverTouchListener.RolloverStickerRetriever +{ + + private static final String TAG = Log.tag(StickerKeyboardPageFragment.class); + + private static final String KEY_PACK_ID = "pack_id"; + + public static final String RECENT_PACK_ID = StickerKeyboardPageViewModel.RECENT_PACK_ID; + + private RecyclerView list; + private StickerKeyboardPageAdapter adapter; + private GridLayoutManager layoutManager; + + private StickerKeyboardPageViewModel viewModel; + private EventListener eventListener; + private StickerRolloverTouchListener listTouchListener; + + private String packId; + + public static StickerKeyboardPageFragment newInstance(@NonNull String packId) { + Bundle args = new Bundle(); + args.putString(KEY_PACK_ID, packId); + + StickerKeyboardPageFragment fragment = new StickerKeyboardPageFragment(); + fragment.setArguments(args); + fragment.packId = packId; + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.sticker_keyboard_page, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + GlideRequests glideRequests = GlideApp.with(this); + + this.list = view.findViewById(R.id.sticker_keyboard_list); + this.adapter = new StickerKeyboardPageAdapter(glideRequests, this, DeviceProperties.shouldAllowApngStickerAnimation(requireContext())); + this.layoutManager = new GridLayoutManager(requireContext(), 2); + this.listTouchListener = new StickerRolloverTouchListener(requireContext(), glideRequests, eventListener, this); + this.packId = getArguments().getString(KEY_PACK_ID); + + list.setLayoutManager(layoutManager); + list.setAdapter(adapter); + list.addOnItemTouchListener(listTouchListener); + + initViewModel(packId); + onScreenWidthChanged(getScreenWidth()); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + @Override + public void onStickerClicked(@NonNull StickerRecord sticker) { + if (eventListener != null) { + eventListener.onStickerSelected(sticker); + } + } + + @Override + public void onStickerLongClicked(@NonNull View targetView) { + if (listTouchListener != null) { + listTouchListener.enterHoverMode(list, targetView); + } + } + + @Override + public @Nullable Pair getStickerDataFromView(@NonNull View view) { + if (list != null) { + StickerKeyboardPageViewHolder holder = (StickerKeyboardPageViewHolder) list.getChildViewHolder(view); + if (holder != null && holder.getCurrentSticker() != null) { + return new Pair<>(new DecryptableStreamUriLoader.DecryptableUri(holder.getCurrentSticker().getUri()), + holder.getCurrentSticker().getEmoji()); + } + } + return null; + } + + public void setEventListener(@NonNull EventListener eventListener) { + this.eventListener = eventListener; + } + + public @NonNull String getPackId() { + return packId; + } + + private void initViewModel(@NonNull String packId) { + StickerKeyboardRepository repository = new StickerKeyboardRepository(DatabaseFactory.getStickerDatabase(requireContext())); + viewModel = ViewModelProviders.of(this, new StickerKeyboardPageViewModel.Factory(requireActivity().getApplication(), repository)).get(StickerKeyboardPageViewModel.class); + + viewModel.getStickers(packId).observe(getViewLifecycleOwner(), stickerRecords -> { + if (stickerRecords == null) return; + + adapter.setStickers(stickerRecords, calculateStickerSize(getScreenWidth())); + }); + } + + private void onScreenWidthChanged(@Px int newWidth) { + if (layoutManager != null) { + layoutManager.setSpanCount(calculateColumnCount(newWidth)); + adapter.setStickerSize(calculateStickerSize(newWidth)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + requireActivity().getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } + + private int calculateColumnCount(@Px int screenWidth) { + float modifier = getResources().getDimensionPixelOffset(R.dimen.sticker_page_item_padding); + float divisor = getResources().getDimensionPixelOffset(R.dimen.sticker_page_item_divisor); + return (int) ((screenWidth - modifier) / divisor); + } + + private int calculateStickerSize(@Px int screenWidth) { + float multiplier = getResources().getDimensionPixelOffset(R.dimen.sticker_page_item_multiplier); + int columnCount = calculateColumnCount(screenWidth); + + return (int) ((screenWidth - ((columnCount + 1) * multiplier)) / columnCount); + } + + interface EventListener extends StickerRolloverTouchListener.RolloverEventListener { + void onStickerSelected(@NonNull StickerRecord sticker); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageViewModel.java new file mode 100644 index 00000000..4c664c59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardPageViewModel.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.stickers; + +import android.app.Application; +import android.database.ContentObserver; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.util.Throttler; + +import java.util.List; + +final class StickerKeyboardPageViewModel extends ViewModel { + + static final String RECENT_PACK_ID = "RECENT"; + + private final Application application; + private final StickerKeyboardRepository repository; + private final MutableLiveData> stickers; + private final Throttler observerThrottler; + private final ContentObserver observer; + + private String packId; + + private StickerKeyboardPageViewModel(@NonNull Application application, @NonNull StickerKeyboardRepository repository) { + this.application = application; + this.repository = repository; + this.stickers = new MutableLiveData<>(); + this.observerThrottler = new Throttler(500); + this.observer = new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + observerThrottler.publish(() -> getStickers(packId)); + } + }; + + application.getContentResolver().registerContentObserver(DatabaseContentProviders.Sticker.CONTENT_URI, true, observer); + } + + LiveData> getStickers(@NonNull String packId) { + this.packId = packId; + + if (RECENT_PACK_ID.equals(packId)) { + repository.getRecentStickers(stickers::postValue); + } else { + repository.getStickersForPack(packId, stickers::postValue); + } + + return stickers; + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(observer); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + private final Application application; + private final StickerKeyboardRepository repository; + + Factory(@NonNull Application application, @NonNull StickerKeyboardRepository repository) { + this.application = application; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new StickerKeyboardPageViewModel(application, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardProvider.java new file mode 100644 index 00000000..f595ef98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardProvider.java @@ -0,0 +1,270 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.glide.cache.ApngOptions; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.stickers.StickerKeyboardPageFragment.EventListener; +import org.thoughtcrime.securesms.stickers.StickerKeyboardRepository.PackListResult; +import org.thoughtcrime.securesms.util.DeviceProperties; +import org.thoughtcrime.securesms.util.Throttler; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A provider to select stickers in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}. + */ +public final class StickerKeyboardProvider implements MediaKeyboardProvider, + MediaKeyboardProvider.AddObserver, + StickerKeyboardPageFragment.EventListener +{ + private static final int UNSET = -1; + + private final Context context; + private final StickerEventListener eventListener; + private final StickerPagerAdapter pagerAdapter; + private final Throttler stickerThrottler; + + private Controller controller; + private Presenter presenter; + private boolean isSoloProvider; + private StickerKeyboardViewModel viewModel; + private int currentPosition; + + public StickerKeyboardProvider(@NonNull FragmentActivity activity, + @NonNull StickerEventListener eventListener) + { + this.context = activity; + this.eventListener = eventListener; + this.pagerAdapter = new StickerPagerAdapter(activity.getSupportFragmentManager(), this); + this.stickerThrottler = new Throttler(100); + this.currentPosition = UNSET; + + initViewModel(activity); + } + + @Override + public int getProviderIconView(boolean selected) { + if (selected) { + return R.layout.sticker_keyboard_icon_selected; + } else { + return R.layout.sticker_keyboard_icon; + } + } + + @Override + public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) { + this.presenter = presenter; + this.isSoloProvider = isSoloProvider; + + PackListResult result = viewModel.getPacks().getValue(); + + if (result != null) { + present(presenter, result, false); + } + } + + @Override + public void setController(@Nullable Controller controller) { + this.controller = controller; + } + + @Override + public void onAddClicked() { + eventListener.onStickerManagementClicked(); + } + + @Override + public void onStickerSelected(@NonNull StickerRecord sticker) { + stickerThrottler.publish(() -> eventListener.onStickerSelected(sticker)); + } + + @Override + public void onStickerPopupStarted() { + if (controller != null) { + controller.setViewPagerEnabled(false); + } + } + + @Override + public void onStickerPopupEnded() { + if (controller != null) { + controller.setViewPagerEnabled(true); + } + } + + @Override + public void setCurrentPosition(int currentPosition) { + this.currentPosition = currentPosition; + } + + private void initViewModel(@NonNull FragmentActivity activity) { + StickerKeyboardRepository repository = new StickerKeyboardRepository(DatabaseFactory.getStickerDatabase(activity)); + viewModel = ViewModelProviders.of(activity, new StickerKeyboardViewModel.Factory(activity.getApplication(), repository)).get(StickerKeyboardViewModel.class); + + viewModel.getPacks().observe(activity, result -> { + if (result == null) return; + + int previousCount = pagerAdapter.getCount(); + + pagerAdapter.setPacks(result.getPacks()); + + if (presenter != null) { + present(presenter, result, previousCount != pagerAdapter.getCount()); + } + }); + } + + private void present(@NonNull Presenter presenter, @NonNull PackListResult result, boolean calculateStartingIndex) { + if (result.getPacks().isEmpty() && presenter.isVisible()) { + context.startActivity(StickerManagementActivity.getIntent(context)); + presenter.requestDismissal(); + return; + } + + int startingIndex = currentPosition; + + if (calculateStartingIndex || startingIndex == UNSET) { + startingIndex = !result.hasRecents() && result.getPacks().size() > 0 ? 1 : 0; + } + + presenter.present(this, pagerAdapter, new IconProvider(context, result.getPacks(), DeviceProperties.shouldAllowApngStickerAnimation(context)), null, this, null, startingIndex); + + if (isSoloProvider && result.getPacks().isEmpty()) { + context.startActivity(StickerManagementActivity.getIntent(context)); + } + } + + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof StickerKeyboardProvider; + } + + private static class StickerPagerAdapter extends FragmentStatePagerAdapter { + + private final List packs; + private final Map itemPositions; + private final EventListener eventListener; + + public StickerPagerAdapter(@NonNull FragmentManager fm, @NonNull EventListener eventListener) { + super(fm); + this.eventListener = eventListener; + this.packs = new ArrayList<>(); + this.itemPositions = new HashMap<>(); + } + + @Override + public int getItemPosition(@NonNull Object object) { + String packId = ((StickerKeyboardPageFragment) object).getPackId(); + + if (itemPositions.containsKey(packId)) { + //noinspection ConstantConditions + return itemPositions.get(packId); + } + + return POSITION_NONE; + } + + @Override + public Fragment getItem(int i) { + StickerKeyboardPageFragment fragment; + + if (i == 0) { + fragment = StickerKeyboardPageFragment.newInstance(StickerKeyboardPageFragment.RECENT_PACK_ID); + } else { + StickerPackRecord pack = packs.get(i - 1); + fragment = StickerKeyboardPageFragment.newInstance(pack.getPackId()); + } + + fragment.setEventListener(eventListener); + + return fragment; + } + + @Override + public int getCount() { + return packs.isEmpty() ? 0 : packs.size() + 1; + } + + void setPacks(@NonNull List packs) { + itemPositions.clear(); + + if (areListsEqual(this.packs, packs)) { + itemPositions.put(StickerKeyboardPageFragment.RECENT_PACK_ID, 0); + for (int i = 0; i < packs.size(); i++) { + itemPositions.put(packs.get(i).getPackId(), i + 1); + } + } + + this.packs.clear(); + this.packs.addAll(packs); + + notifyDataSetChanged(); + } + + boolean areListsEqual(@NonNull List a, @NonNull List b) { + if (a.size() != b.size()) return false; + + for (int i = 0; i < a.size(); i++) { + if (!a.get(i).equals(b.get(i))) { + return false; + } + } + + return true; + } + } + + private static class IconProvider implements TabIconProvider { + + private final Context context; + private final List packs; + private final boolean allowApngAnimation; + + private IconProvider(@NonNull Context context, List packs, boolean allowApngAnimation) { + this.context = context; + this.packs = packs; + this.allowApngAnimation = allowApngAnimation; + } + + @Override + public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) { + if (index == 0) { + Drawable icon = ContextCompat.getDrawable(context, R.drawable.ic_recent_20); + imageView.setImageDrawable(icon); + } else { + Uri uri = packs.get(index - 1).getCover().getUri(); + + glideRequests.load(new DecryptableStreamUriLoader.DecryptableUri(uri)) + .set(ApngOptions.ANIMATE, allowApngAnimation) + .into(imageView); + } + } + } + + public interface StickerEventListener { + void onStickerSelected(@NonNull StickerRecord sticker); + void onStickerManagementClicked(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardRepository.java new file mode 100644 index 00000000..bad0d2e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardRepository.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.stickers; + +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase.StickerPackRecordReader; +import org.thoughtcrime.securesms.database.StickerDatabase.StickerRecordReader; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; + +import java.util.ArrayList; +import java.util.List; + +final class StickerKeyboardRepository { + + private static final int RECENT_LIMIT = 24; + + private final StickerDatabase stickerDatabase; + + StickerKeyboardRepository(@NonNull StickerDatabase stickerDatabase) { + this.stickerDatabase = stickerDatabase; + } + + void getPackList(@NonNull Callback callback) { + SignalExecutors.BOUNDED.execute(() -> { + List packs = new ArrayList<>(); + + try (StickerPackRecordReader reader = new StickerPackRecordReader(stickerDatabase.getInstalledStickerPacks())) { + StickerPackRecord pack; + while ((pack = reader.getNext()) != null) { + packs.add(pack); + } + } + + boolean hasRecents; + + try (Cursor recentsCursor = stickerDatabase.getRecentlyUsedStickers(1)) { + hasRecents = recentsCursor != null && recentsCursor.moveToFirst(); + } + + callback.onComplete(new PackListResult(packs, hasRecents)); + }); + } + + void getStickersForPack(@NonNull String packId, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + List stickers = new ArrayList<>(); + + try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getStickersForPack(packId))) { + StickerRecord sticker; + while ((sticker = reader.getNext()) != null) { + stickers.add(sticker); + } + } + + callback.onComplete(stickers); + }); + } + + void getRecentStickers(@NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + List stickers = new ArrayList<>(); + + try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getRecentlyUsedStickers(RECENT_LIMIT))) { + StickerRecord sticker; + while ((sticker = reader.getNext()) != null) { + stickers.add(sticker); + } + } + + callback.onComplete(stickers); + }); + } + + static class PackListResult { + + private final List packs; + private final boolean hasRecents; + + PackListResult(List packs, boolean hasRecents) { + this.packs = packs; + this.hasRecents = hasRecents; + } + + List getPacks() { + return packs; + } + + boolean hasRecents() { + return hasRecents; + } + } + + interface Callback { + void onComplete(T result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardViewModel.java new file mode 100644 index 00000000..febcfb5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerKeyboardViewModel.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.stickers; + +import android.app.Application; +import android.database.ContentObserver; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.stickers.StickerKeyboardRepository.PackListResult; +import org.thoughtcrime.securesms.util.Throttler; + +final class StickerKeyboardViewModel extends ViewModel { + + private final Application application; + private final MutableLiveData packs; + private final Throttler observerThrottler; + private final ContentObserver observer; + + private StickerKeyboardViewModel(@NonNull Application application, @NonNull StickerKeyboardRepository repository) { + this.application = application; + this.packs = new MutableLiveData<>(); + this.observerThrottler = new Throttler(500); + this.observer = new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + observerThrottler.publish(() -> repository.getPackList(packs::postValue)); + } + }; + + repository.getPackList(packs::postValue); + application.getContentResolver().registerContentObserver(DatabaseContentProviders.StickerPack.CONTENT_URI, true, observer); + } + + @NonNull LiveData getPacks() { + return packs; + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(observer); + } + + public static final class Factory extends ViewModelProvider.NewInstanceFactory { + private final Application application; + private final StickerKeyboardRepository repository; + + public Factory(@NonNull Application application, @NonNull StickerKeyboardRepository repository) { + this.application = application; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new StickerKeyboardViewModel(application, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.java new file mode 100644 index 00000000..ccf47457 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.stickers; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class StickerLocator implements Parcelable { + + private final String packId; + private final String packKey; + private final int stickerId; + private final String emoji; + + public StickerLocator(@NonNull String packId, @NonNull String packKey, int stickerId, @Nullable String emoji) { + this.packId = packId; + this.packKey = packKey; + this.stickerId = stickerId; + this.emoji = emoji; + } + + private StickerLocator(Parcel in) { + packId = in.readString(); + packKey = in.readString(); + stickerId = in.readInt(); + emoji = in.readString(); + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public int getStickerId() { + return stickerId; + } + + public @Nullable String getEmoji() { + return emoji; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(packId); + dest.writeString(packKey); + dest.writeInt(stickerId); + dest.writeString(emoji); + } + + public static final Creator CREATOR = new Creator() { + @Override + public StickerLocator createFromParcel(Parcel in) { + return new StickerLocator(in); + } + + @Override + public StickerLocator[] newArray(int size) { + return new StickerLocator[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java new file mode 100644 index 00000000..3694ec89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.util.DeviceProperties; +import org.thoughtcrime.securesms.util.DynamicTheme; + +/** + * Allows the user to view and manage (install, uninstall, etc) their stickers. + */ +public final class StickerManagementActivity extends PassphraseRequiredActivity implements StickerManagementAdapter.EventListener { + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + + private RecyclerView list; + private StickerManagementAdapter adapter; + private StickerManagementViewModel viewModel; + + public static Intent getIntent(@NonNull Context context) { + return new Intent(context, StickerManagementActivity.class); + } + + @Override + protected void onPreCreate() { + super.onPreCreate(); + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.sticker_management_activity); + + initView(); + initToolbar(); + initViewModel(); + } + + @Override + protected void onStart() { + super.onStart(); + viewModel.onVisible(); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onStickerPackClicked(@NonNull String packId, @NonNull String packKey) { + startActivity(StickerPackPreviewActivity.getIntent(packId, packKey)); + } + + @Override + public void onStickerPackUninstallClicked(@NonNull String packId, @NonNull String packKey) { + viewModel.onStickerPackUninstallClicked(packId, packKey); + } + + @Override + public void onStickerPackInstallClicked(@NonNull String packId, @NonNull String packKey) { + viewModel.onStickerPackInstallClicked(packId, packKey); + } + + @Override + public void onStickerPackShareClicked(@NonNull String packId, @NonNull String packKey) { + Intent composeIntent = new Intent(this, ShareActivity.class); + composeIntent.putExtra(Intent.EXTRA_TEXT, StickerUrl.createShareLink(packId, packKey)); + startActivity(composeIntent); + finish(); + } + + private void initView() { + this.list = findViewById(R.id.sticker_management_list); + this.adapter = new StickerManagementAdapter(GlideApp.with(this), this, DeviceProperties.shouldAllowApngStickerAnimation(this)); + + list.setLayoutManager(new LinearLayoutManager(this)); + list.setAdapter(adapter); + new ItemTouchHelper(new StickerManagementItemTouchHelper(new ItemTouchCallback())).attachToRecyclerView(list); + } + + private void initToolbar() { + getSupportActionBar().setTitle(R.string.StickerManagementActivity_stickers); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + private void initViewModel() { + StickerManagementRepository repository = new StickerManagementRepository(this); + viewModel = ViewModelProviders.of(this, new StickerManagementViewModel.Factory(getApplication(), repository)).get(StickerManagementViewModel.class); + + viewModel.init(); + viewModel.getStickerPacks().observe(this, packResult -> { + if (packResult == null) return; + + adapter.setPackLists(packResult.getInstalledPacks(), packResult.getAvailablePacks(), packResult.getBlessedPacks()); + }); + } + + private class ItemTouchCallback implements StickerManagementItemTouchHelper.Callback { + @Override + public boolean onMove(int start, int end) { + return adapter.onMove(start, end); + } + + @Override + public boolean isMovable(int position) { + return adapter.isMovable(position); + } + + @Override + public void onMoveCommitted() { + viewModel.onOrderChanged(adapter.getInstalledPacksInOrder()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java new file mode 100644 index 00000000..6773910a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java @@ -0,0 +1,354 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.text.SpannableString; +import android.text.style.ImageSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.glide.cache.ApngOptions; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.adapter.SectionedRecyclerViewAdapter; +import org.thoughtcrime.securesms.util.adapter.StableIdGenerator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class StickerManagementAdapter extends SectionedRecyclerViewAdapter { + + private static final String TAG_YOUR_STICKERS = "YourStickers"; + private static final String TAG_MESSAGE_STICKERS = "MessageStickers"; + private static final String TAG_BLESSED_STICKERS = "BlessedStickers"; + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final boolean allowApngAnimation; + + private final List sections = new ArrayList(3) {{ + StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS, + R.string.StickerManagementAdapter_installed_stickers, + R.string.StickerManagementAdapter_no_stickers_installed, + new ArrayList<>(), + 0); + StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS, + R.string.StickerManagementAdapter_stickers_you_received, + R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here, + new ArrayList<>(), + yourStickers.size()); + + add(yourStickers); + add(messageStickers); + }}; + + StickerManagementAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean allowApngAnimation) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.allowApngAnimation = allowApngAnimation; + } + + @Override + protected @NonNull List getSections() { + return sections; + } + + @Override + protected @NonNull RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.sticker_management_header_item, parent, false)); + } + + @Override + protected @NonNull RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent) { + return new StickerViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.sticker_management_sticker_item, parent, false)); + } + + @Override + protected @NonNull RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup) { + return new EmptyViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.sticker_management_empty_item, viewGroup, false)); + } + + @Override + public void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull StickerSection section, int localPosition) { + section.bindViewHolder(viewHolder, localPosition, glideRequests, eventListener, allowApngAnimation); + } + + @Override + public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof StickerViewHolder) { + ((StickerViewHolder) holder).recycle(); + } + } + + boolean onMove(int start, int end) { + StickerSection installed = sections.get(0); + + if (!installed.isContent(start)) { + return false; + } + + if (!installed.isContent(end)) { + return false; + } + + installed.swap(start, end); + notifyItemMoved(start, end); + return true; + } + + boolean isMovable(int position) { + return sections.get(0).isContent(position); + } + + @NonNull List getInstalledPacksInOrder() { + return sections.get(0).records; + } + + void setPackLists(@NonNull List installedPacks, + @NonNull List availablePacks, + @NonNull List blessedPacks) + { + StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS, + R.string.StickerManagementAdapter_installed_stickers, + R.string.StickerManagementAdapter_no_stickers_installed, + installedPacks, + 0); + StickerSection blessedStickers = new StickerSection(TAG_BLESSED_STICKERS, + R.string.StickerManagementAdapter_signal_artist_series, + 0, + blessedPacks, + yourStickers.size()); + StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS, + R.string.StickerManagementAdapter_stickers_you_received, + R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here, + availablePacks, + yourStickers.size() + (blessedPacks.isEmpty() ? 0 : blessedStickers.size())); + + sections.clear(); + sections.add(yourStickers); + + if (!blessedPacks.isEmpty()) { + sections.add(blessedStickers); + } + + sections.add(messageStickers); + + notifyDataSetChanged(); + } + + public static class StickerSection extends SectionedRecyclerViewAdapter.Section { + + private static final String STABLE_ID_HEADER = "header"; + private static final String STABLE_ID_TEXT = "text"; + + private final String tag; + private final int titleResId; + private final int emptyResId; + private final List records; + + StickerSection(@NonNull String tag, + @StringRes int titleResId, + @StringRes int emptyResId, + @NonNull List records, + int offset) + { + super(offset); + + this.tag = tag; + this.titleResId = titleResId; + this.emptyResId = emptyResId; + this.records = records; + } + + @Override + public boolean hasEmptyState() { + return true; + } + + @Override + public int getContentSize() { + return records.size(); + } + + @Override + public long getItemId(@NonNull StableIdGenerator idGenerator, int globalPosition) { + int localPosition = getLocalPosition(globalPosition); + + if (localPosition == 0) { + return idGenerator.getId(tag + "_" + STABLE_ID_HEADER); + } else if (records.isEmpty()) { + return idGenerator.getId(tag + "_" + STABLE_ID_TEXT); + } else { + return idGenerator.getId(records.get(localPosition - 1).getPackId()); + } + } + + void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, + int localPosition, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + boolean allowApngAnimation) + { + if (localPosition == 0) { + ((HeaderViewHolder) viewHolder).bind(titleResId); + } else if (records.isEmpty()) { + ((EmptyViewHolder) viewHolder).bind(emptyResId); + } else { + ((StickerViewHolder) viewHolder).bind(glideRequests, eventListener, records.get(localPosition - 1), localPosition == records.size(), allowApngAnimation); + } + } + + void swap(int start, int end) { + int localStart = getLocalPosition(start) - 1; + int localEnd = getLocalPosition(end) - 1; + + if (localStart < localEnd) { + for (int i = localStart; i < localEnd; i++) { + Collections.swap(records, i, i + 1); + } + } else { + for (int i = localStart; i > localEnd; i--) { + Collections.swap(records, i, i - 1); + } + } + } + } + + static class StickerViewHolder extends RecyclerView.ViewHolder { + + private final ImageView cover; + private final EmojiTextView title; + private final TextView author; + private final View divider; + private final View actionButton; + private final ImageView actionButtonImage; + private final View shareButton; + private final ImageView shareButtonImage; + private final CharSequence blessedBadge; + + StickerViewHolder(@NonNull View itemView) { + super(itemView); + + this.cover = itemView.findViewById(R.id.sticker_management_cover); + this.title = itemView.findViewById(R.id.sticker_management_title); + this.author = itemView.findViewById(R.id.sticker_management_author); + this.divider = itemView.findViewById(R.id.sticker_management_divider); + this.actionButton = itemView.findViewById(R.id.sticker_management_action_button); + this.actionButtonImage = itemView.findViewById(R.id.sticker_management_action_button_image); + this.shareButton = itemView.findViewById(R.id.sticker_management_share_button); + this.shareButtonImage = itemView.findViewById(R.id.sticker_management_share_button_image); + this.blessedBadge = buildBlessedBadge(itemView.getContext()); + } + + void bind(@NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener, + @NonNull StickerPackRecord stickerPack, + boolean lastInList, + boolean allowApngAnimation) + { + title.setText(stickerPack.getTitle().or(itemView.getResources().getString(R.string.StickerManagementAdapter_untitled))); + author.setText(stickerPack.getAuthor().or(itemView.getResources().getString(R.string.StickerManagementAdapter_unknown))); + divider.setVisibility(lastInList ? View.GONE : View.VISIBLE); + + if (BlessedPacks.contains(stickerPack.getPackId())) { + title.setOverflowText(blessedBadge); + } else { + title.setOverflowText(null); + } + + glideRequests.load(new DecryptableUri(stickerPack.getCover().getUri())) + .transition(DrawableTransitionOptions.withCrossFade()) + .set(ApngOptions.ANIMATE, allowApngAnimation) + .into(cover); + + if (stickerPack.isInstalled()) { + actionButtonImage.setImageResource(R.drawable.ic_x); + actionButton.setOnClickListener(v -> eventListener.onStickerPackUninstallClicked(stickerPack.getPackId(), stickerPack.getPackKey())); + + shareButton.setVisibility(View.VISIBLE); + shareButtonImage.setVisibility(View.VISIBLE); + shareButton.setOnClickListener(v -> eventListener.onStickerPackShareClicked(stickerPack.getPackId(), stickerPack.getPackKey())); + } else { + actionButtonImage.setImageResource(R.drawable.ic_arrow_down); + actionButton.setOnClickListener(v -> eventListener.onStickerPackInstallClicked(stickerPack.getPackId(), stickerPack.getPackKey())); + + shareButton.setVisibility(View.GONE); + shareButtonImage.setVisibility(View.GONE); + shareButton.setOnClickListener(null); + } + + itemView.setOnClickListener(v -> eventListener.onStickerPackClicked(stickerPack.getPackId(), stickerPack.getPackKey())); + } + + void recycle() { + actionButton.setOnClickListener(null); + shareButton.setOnClickListener(null); + itemView.setOnClickListener(null); + } + + private static @NonNull CharSequence buildBlessedBadge(@NonNull Context context) { + SpannableString badgeSpan = new SpannableString(" "); + Drawable badge = ContextCompat.getDrawable(context, R.drawable.ic_check_circle_white_18dp); + + badge.setBounds(0, 0, badge.getIntrinsicWidth(), badge.getIntrinsicHeight()); + badge.setColorFilter(ContextCompat.getColor(context, R.color.core_ultramarine), PorterDuff.Mode.MULTIPLY); + badgeSpan.setSpan(new ImageSpan(badge), 1, badgeSpan.length(), 0); + + return badgeSpan; + } + } + + static class HeaderViewHolder extends RecyclerView.ViewHolder { + + private final TextView titleView; + + HeaderViewHolder(@NonNull View itemView) { + super(itemView); + + this.titleView = itemView.findViewById(R.id.sticker_management_header); + } + + void bind(@StringRes int title) { + titleView.setText(title); + } + } + + static class EmptyViewHolder extends RecyclerView.ViewHolder { + + private final TextView text; + + EmptyViewHolder(@NonNull View itemView) { + super(itemView); + + this.text = itemView.findViewById(R.id.sticker_management_empty_text); + } + + void bind(@StringRes int title) { + text.setText(title); + } + } + + interface EventListener { + void onStickerPackClicked(@NonNull String packId, @NonNull String packKey); + void onStickerPackUninstallClicked(@NonNull String packId, @NonNull String packKey); + void onStickerPackInstallClicked(@NonNull String packId, @NonNull String packKey); + void onStickerPackShareClicked(@NonNull String packId, @NonNull String packKey); + } + + private static class NoSectionException extends IllegalStateException {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementItemTouchHelper.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementItemTouchHelper.java new file mode 100644 index 00000000..186402d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementItemTouchHelper.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +public class StickerManagementItemTouchHelper extends ItemTouchHelper.Callback { + + private final Callback callback; + + public StickerManagementItemTouchHelper(Callback callback) { + this.callback = callback; + } + + @Override + public boolean isLongPressDragEnabled() { + return true; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (callback.isMovable(viewHolder.getAdapterPosition())) { + int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + return makeMovementFlags(dragFlags, 0); + } else { + return 0; + } + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + return callback.onMove(viewHolder.getAdapterPosition(), target.getAdapterPosition()); + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + callback.onMoveCommitted(); + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + + public interface Callback { + /** + * @return True if both the start and end positions are valid, and therefore the move will occur. + */ + boolean onMove(int start, int end); + void onMoveCommitted(); + boolean isMovable(int position); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.java new file mode 100644 index 00000000..656c6ee4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.java @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase.StickerPackRecordReader; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackOperationJob; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; +import java.util.List; + +final class StickerManagementRepository { + + private final Context context; + private final StickerDatabase stickerDatabase; + private final AttachmentDatabase attachmentDatabase; + + StickerManagementRepository(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.stickerDatabase = DatabaseFactory.getStickerDatabase(context); + this.attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + } + + void deleteOrphanedStickerPacks() { + SignalExecutors.SERIAL.execute(stickerDatabase::deleteOrphanedPacks); + } + + void fetchUnretrievedReferencePacks() { + SignalExecutors.SERIAL.execute(() -> { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + try (Cursor cursor = attachmentDatabase.getUnavailableStickerPacks()) { + while (cursor != null && cursor.moveToNext()) { + String packId = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.STICKER_PACK_ID)); + String packKey = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.STICKER_PACK_KEY)); + + jobManager.add(StickerPackDownloadJob.forReference(packId, packKey)); + } + } + }); + } + + void getStickerPacks(@NonNull Callback callback) { + SignalExecutors.SERIAL.execute(() -> { + List installedPacks = new ArrayList<>(); + List availablePacks = new ArrayList<>(); + List blessedPacks = new ArrayList<>(); + + try (StickerPackRecordReader reader = new StickerPackRecordReader(stickerDatabase.getAllStickerPacks())) { + StickerPackRecord record; + while ((record = reader.getNext()) != null) { + if (record.isInstalled()) { + installedPacks.add(record); + } else if (BlessedPacks.contains(record.getPackId())) { + blessedPacks.add(record); + } else { + availablePacks.add(record); + } + } + } + + callback.onComplete(new PackResult(installedPacks, availablePacks, blessedPacks)); + }); + } + + void uninstallStickerPack(@NonNull String packId, @NonNull String packKey) { + SignalExecutors.SERIAL.execute(() -> { + stickerDatabase.uninstallPack(packId); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(new MultiDeviceStickerPackOperationJob(packId, packKey, MultiDeviceStickerPackOperationJob.Type.REMOVE)); + } + }); + } + + void installStickerPack(@NonNull String packId, @NonNull String packKey, boolean notify) { + SignalExecutors.SERIAL.execute(() -> { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (stickerDatabase.isPackAvailableAsReference(packId)) { + stickerDatabase.markPackAsInstalled(packId, notify); + } + + jobManager.add(StickerPackDownloadJob.forInstall(packId, packKey, notify)); + + if (TextSecurePreferences.isMultiDevice(context)) { + jobManager.add(new MultiDeviceStickerPackOperationJob(packId, packKey, MultiDeviceStickerPackOperationJob.Type.INSTALL)); + } + }); + } + + void setPackOrder(@NonNull List packsInOrder) { + SignalExecutors.SERIAL.execute(() -> { + stickerDatabase.updatePackOrder(packsInOrder); + }); + } + + static class PackResult { + + private final List installedPacks; + private final List availablePacks; + private final List blessedPacks; + + PackResult(@NonNull List installedPacks, + @NonNull List availablePacks, + @NonNull List blessedPacks) + { + this.installedPacks = installedPacks; + this.availablePacks = availablePacks; + this.blessedPacks = blessedPacks; + } + + @NonNull List getInstalledPacks() { + return installedPacks; + } + + @NonNull List getAvailablePacks() { + return availablePacks; + } + + @NonNull List getBlessedPacks() { + return blessedPacks; + } + } + + interface Callback { + void onComplete(T result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModel.java new file mode 100644 index 00000000..d33b3aab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModel.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.stickers; + +import android.app.Application; +import android.database.ContentObserver; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.stickers.StickerManagementRepository.PackResult; + +import java.util.List; + +final class StickerManagementViewModel extends ViewModel { + + private final Application application; + private final StickerManagementRepository repository; + private final MutableLiveData packs; + private final ContentObserver observer; + + private StickerManagementViewModel(@NonNull Application application, @NonNull StickerManagementRepository repository) { + this.application = application; + this.repository = repository; + this.packs = new MutableLiveData<>(); + this.observer = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + repository.deleteOrphanedStickerPacks(); + repository.getStickerPacks(packs::postValue); + } + }; + + application.getContentResolver().registerContentObserver(DatabaseContentProviders.StickerPack.CONTENT_URI, true, observer); + } + + void init() { + repository.deleteOrphanedStickerPacks(); + repository.fetchUnretrievedReferencePacks(); + } + + void onVisible() { + repository.deleteOrphanedStickerPacks(); + } + + @NonNull LiveData getStickerPacks() { + repository.getStickerPacks(packs::postValue); + return packs; + } + + void onStickerPackUninstallClicked(@NonNull String packId, @NonNull String packKey) { + repository.uninstallStickerPack(packId, packKey); + } + + void onStickerPackInstallClicked(@NonNull String packId, @NonNull String packKey) { + repository.installStickerPack(packId, packKey, false); + } + + void onOrderChanged(List packsInOrder) { + repository.setPackOrder(packsInOrder); + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(observer); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final Application application; + private final StickerManagementRepository repository; + + Factory(@NonNull Application application, @NonNull StickerManagementRepository repository) { + this.application = application; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new StickerManagementViewModel(application, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManifest.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManifest.java new file mode 100644 index 00000000..d44e4a10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManifest.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.stickers; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Local model that represents the data present in the libsignal model + * {@link org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest}. + */ +public final class StickerManifest { + + private final String packId; + private final String packKey; + private final Optional title; + private final Optional author; + private final Optional cover; + private final List stickers; + + public StickerManifest(@NonNull String packId, + @NonNull String packKey, + @NonNull Optional title, + @NonNull Optional author, + @NonNull Optional cover, + @NonNull List stickers) + { + this.packId = packId; + this.packKey = packKey; + this.title = title; + this.author = author; + this.cover = cover; + this.stickers = new ArrayList<>(stickers); + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public @NonNull Optional getTitle() { + return title; + } + + public @NonNull Optional getAuthor() { + return author; + } + + public @NonNull Optional getCover() { + return cover; + } + + public @NonNull List getStickers() { + return stickers; + } + + public static class Sticker { + private final String packId; + private final String packKey; + private final int id; + private final String emoji; + private final String contentType; + private final Optional uri; + + public Sticker(@NonNull String packId, @NonNull String packKey, int id, @NonNull String emoji, @Nullable String contentType) { + this(packId, packKey, id, emoji, contentType, null); + } + + public Sticker(@NonNull String packId, @NonNull String packKey, int id, @NonNull String emoji, @Nullable String contentType, @Nullable Uri uri) { + this.packId = packId; + this.packKey = packKey; + this.id = id; + this.emoji = emoji; + this.contentType = contentType; + this.uri = Optional.fromNullable(uri); + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public int getId() { + return id; + } + + public String getEmoji() { + return emoji; + } + + public @Nullable String getContentType() { + return contentType; + } + + public Optional getUri() { + return uri; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackInstallEvent.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackInstallEvent.java new file mode 100644 index 00000000..22b2505d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackInstallEvent.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; + +public class StickerPackInstallEvent { + private final Object iconGlideModel; + + public StickerPackInstallEvent(@NonNull Object iconGlideModel) { + this.iconGlideModel = iconGlideModel; + } + + public @NonNull Object getIconGlideModel() { + return iconGlideModel; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java new file mode 100644 index 00000000..a7864ed8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java @@ -0,0 +1,258 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Bundle; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.glide.cache.ApngOptions; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.stickers.StickerManifest.Sticker; +import org.thoughtcrime.securesms.util.DeviceProperties; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +/** + * Shows the contents of a pack and allows the user to install it (if not installed) or remove it + * (if installed). This is also the handler for sticker pack deep links. + */ +public final class StickerPackPreviewActivity extends PassphraseRequiredActivity + implements StickerRolloverTouchListener.RolloverEventListener, + StickerRolloverTouchListener.RolloverStickerRetriever, + StickerPackPreviewAdapter.EventListener +{ + + private static final String TAG = Log.tag(StickerPackPreviewActivity.class); + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + private StickerPackPreviewViewModel viewModel; + + private ImageView coverImage; + private TextView stickerTitle; + private TextView stickerAuthor; + private View installButton; + private View removeButton; + private RecyclerView stickerList; + private View shareButton; + private View shareButtonImage; + + private StickerPackPreviewAdapter adapter; + private GridLayoutManager layoutManager; + private StickerRolloverTouchListener touchListener; + + public static Intent getIntent(@NonNull String packId, @NonNull String packKey) { + Intent intent = new Intent(Intent.ACTION_VIEW, StickerUrl.createActionUri(packId, packKey)); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.addCategory(Intent.CATEGORY_BROWSABLE); + return intent; + } + + @Override + protected void onPreCreate() { + super.onPreCreate(); + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.sticker_preview_activity); + + Optional> stickerParams = StickerUrl.parseExternalUri(getIntent().getData()); + + if (!stickerParams.isPresent()) { + Log.w(TAG, "Invalid URI!"); + presentError(); + return; + } + + String packId = stickerParams.get().first(); + String packKey = stickerParams.get().second(); + + initToolbar(); + initView(); + initViewModel(packId, packKey); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + @Override + public void onStickerLongPress(@NonNull View view) { + if (touchListener != null) { + touchListener.enterHoverMode(stickerList, view); + } + } + + @Override + public void onStickerPopupStarted() { + } + + @Override + public void onStickerPopupEnded() { + } + + @Override + public @Nullable Pair getStickerDataFromView(@NonNull View view) { + if (stickerList != null) { + StickerPackPreviewAdapter.StickerViewHolder holder = (StickerPackPreviewAdapter.StickerViewHolder) stickerList.getChildViewHolder(view); + if (holder != null) { + return new Pair<>(holder.getCurrentGlideModel(), holder.getCurrentEmoji()); + } + } + return null; + } + + private void initView() { + this.coverImage = findViewById(R.id.sticker_install_cover); + this.stickerTitle = findViewById(R.id.sticker_install_title); + this.stickerAuthor = findViewById(R.id.sticker_install_author); + this.installButton = findViewById(R.id.sticker_install_button); + this.removeButton = findViewById(R.id.sticker_install_remove_button); + this.stickerList = findViewById(R.id.sticker_install_list); + this.shareButton = findViewById(R.id.sticker_install_share_button); + this.shareButtonImage = findViewById(R.id.sticker_install_share_button_image); + + this.adapter = new StickerPackPreviewAdapter(GlideApp.with(this), this, DeviceProperties.shouldAllowApngStickerAnimation(this)); + this.layoutManager = new GridLayoutManager(this, 2); + this.touchListener = new StickerRolloverTouchListener(this, GlideApp.with(this), this, this); + onScreenWidthChanged(getScreenWidth()); + + stickerList.setLayoutManager(layoutManager); + stickerList.addOnItemTouchListener(touchListener); + stickerList.setAdapter(adapter); + } + + private void initToolbar() { + Toolbar toolbar = findViewById(R.id.sticker_install_toolbar); + + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.StickerPackPreviewActivity_stickers); + + toolbar.setNavigationOnClickListener(v -> onBackPressed()); + } + + private void initViewModel(@NonNull String packId, @NonNull String packKey) { + viewModel = ViewModelProviders.of(this, new StickerPackPreviewViewModel.Factory(getApplication(), + new StickerPackPreviewRepository(this), + new StickerManagementRepository(this))) + .get(StickerPackPreviewViewModel.class); + + viewModel.getStickerManifest(packId, packKey).observe(this, manifest -> { + if (manifest == null) return; + + if (manifest.isPresent()) { + presentManifest(manifest.get().getManifest()); + presentButton(manifest.get().isInstalled()); + presentShareButton(manifest.get().isInstalled(), manifest.get().getManifest().getPackId(), manifest.get().getManifest().getPackKey()); + } else { + presentError(); + } + }); + } + + private void presentManifest(@NonNull StickerManifest manifest) { + stickerTitle.setText(manifest.getTitle().or(getString(R.string.StickerPackPreviewActivity_untitled))); + stickerAuthor.setText(manifest.getAuthor().or(getString(R.string.StickerPackPreviewActivity_unknown))); + adapter.setStickers(manifest.getStickers()); + + Sticker first = manifest.getStickers().isEmpty() ? null : manifest.getStickers().get(0); + Sticker cover = manifest.getCover().or(Optional.fromNullable(first)).orNull(); + + if (cover != null) { + Object model = cover.getUri().isPresent() ? new DecryptableStreamUriLoader.DecryptableUri(cover.getUri().get()) + : new StickerRemoteUri(cover.getPackId(), cover.getPackKey(), cover.getId()); + GlideApp.with(this).load(model) + .transition(DrawableTransitionOptions.withCrossFade()) + .set(ApngOptions.ANIMATE, DeviceProperties.shouldAllowApngStickerAnimation(this)) + .into(coverImage); + } else { + coverImage.setImageDrawable(null); + } + } + + private void presentButton(boolean installed) { + if (installed) { + removeButton.setVisibility(View.VISIBLE); + removeButton.setOnClickListener(v -> { + viewModel.onRemoveClicked(); + finish(); + }); + installButton.setVisibility(View.GONE); + installButton.setOnClickListener(null); + } else { + installButton.setVisibility(View.VISIBLE); + installButton.setOnClickListener(v -> { + viewModel.onInstallClicked(); + finish(); + }); + removeButton.setVisibility(View.GONE); + removeButton.setOnClickListener(null); + } + } + + private void presentShareButton(boolean installed, @NonNull String packId, @NonNull String packKey) { + if (installed) { + shareButton.setVisibility(View.VISIBLE); + shareButtonImage.setVisibility(View.VISIBLE); + shareButton.setOnClickListener(v -> { + Intent composeIntent = new Intent(this, ShareActivity.class); + composeIntent.putExtra(Intent.EXTRA_TEXT, StickerUrl.createShareLink(packId, packKey)); + startActivity(composeIntent); + finish(); + }); + } else { + shareButton.setVisibility(View.GONE); + shareButtonImage.setVisibility(View.GONE); + shareButton.setOnClickListener(null); + } + } + + private void presentError() { + Toast.makeText(this, R.string.StickerPackPreviewActivity_failed_to_load_sticker_pack, Toast.LENGTH_SHORT).show(); + finish(); + } + + private void onScreenWidthChanged(int screenWidth) { + if (layoutManager != null) { + int availableWidth = screenWidth - (2 * getResources().getDimensionPixelOffset(R.dimen.sticker_preview_gutter_size)); + layoutManager.setSpanCount(availableWidth / getResources().getDimensionPixelOffset(R.dimen.sticker_preview_sticker_size)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewAdapter.java new file mode 100644 index 00000000..cda45f78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewAdapter.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.stickers; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.glide.cache.ApngOptions; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +public final class StickerPackPreviewAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List list; + private final boolean allowApngAnimation; + + public StickerPackPreviewAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean allowApngAnimation) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.allowApngAnimation = allowApngAnimation; + this.list = new ArrayList<>(); + } + + @Override + public @NonNull StickerViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new StickerViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.sticker_preview_list_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull StickerViewHolder stickerViewHolder, int i) { + stickerViewHolder.bind(glideRequests, list.get(i), eventListener, allowApngAnimation); + } + + @Override + public int getItemCount() { + return list.size(); + } + + @Override + public void onViewRecycled(@NonNull StickerViewHolder holder) { + holder.recycle(); + } + + void setStickers(List stickers) { + list.clear(); + list.addAll(stickers); + notifyDataSetChanged(); + } + + static class StickerViewHolder extends RecyclerView.ViewHolder { + + private final ImageView image; + + private Object currentGlideModel; + private String currentEmoji; + + private StickerViewHolder(@NonNull View itemView) { + super(itemView); + this.image = itemView.findViewById(R.id.sticker_install_item_image); + } + + void bind(@NonNull GlideRequests glideRequests, + @NonNull StickerManifest.Sticker sticker, + @NonNull EventListener eventListener, + boolean allowApngAnimation) + { + currentEmoji = sticker.getEmoji(); + currentGlideModel = sticker.getUri().isPresent() ? new DecryptableStreamUriLoader.DecryptableUri(sticker.getUri().get()) + : new StickerRemoteUri(sticker.getPackId(), sticker.getPackKey(), sticker.getId()); + glideRequests.load(currentGlideModel) + .transition(DrawableTransitionOptions.withCrossFade()) + .set(ApngOptions.ANIMATE, allowApngAnimation) + .into(image); + + image.setOnLongClickListener(v -> { + eventListener.onStickerLongPress(v); + return true; + }); + } + + void recycle() { + image.setOnLongClickListener(null); + } + + @Nullable Object getCurrentGlideModel() { + return currentGlideModel; + } + + @Nullable String getCurrentEmoji() { + return currentEmoji; + } + } + + interface EventListener { + void onStickerLongPress(@NonNull View view); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java new file mode 100644 index 00000000..81148623 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewRepository.java @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.model.StickerPackRecord; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public final class StickerPackPreviewRepository { + + private static final String TAG = Log.tag(StickerPackPreviewRepository.class); + + private final StickerDatabase stickerDatabase; + private final SignalServiceMessageReceiver receiver; + + public StickerPackPreviewRepository(@NonNull Context context) { + this.receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + this.stickerDatabase = DatabaseFactory.getStickerDatabase(context); + } + + public void getStickerManifest(@NonNull String packId, + @NonNull String packKey, + @NonNull Callback> callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + Optional localManifest = getManifestFromDatabase(packId); + + if (localManifest.isPresent()) { + Log.d(TAG, "Found manifest locally."); + callback.onComplete(localManifest); + } else { + Log.d(TAG, "Looking for manifest remotely."); + callback.onComplete(getManifestRemote(packId, packKey)); + } + }); + } + + @WorkerThread + private Optional getManifestFromDatabase(@NonNull String packId) { + StickerPackRecord record = stickerDatabase.getStickerPack(packId); + + if (record != null && record.isInstalled()) { + StickerManifest.Sticker cover = toSticker(record.getCover()); + List stickers = getStickersFromDatabase(packId); + + StickerManifest manifest = new StickerManifest(record.getPackId(), + record.getPackKey(), + record.getTitle(), + record.getAuthor(), + Optional.of(cover), + stickers); + + return Optional.of(new StickerManifestResult(manifest, record.isInstalled())); + } + + return Optional.absent(); + } + + @WorkerThread + private Optional getManifestRemote(@NonNull String packId, @NonNull String packKey) { + try { + byte[] packIdBytes = Hex.fromStringCondensed(packId); + byte[] packKeyBytes = Hex.fromStringCondensed(packKey); + SignalServiceStickerManifest remoteManifest = receiver.retrieveStickerManifest(packIdBytes, packKeyBytes); + StickerManifest localManifest = new StickerManifest(packId, + packKey, + remoteManifest.getTitle(), + remoteManifest.getAuthor(), + toOptionalSticker(packId, packKey, remoteManifest.getCover()), + Stream.of(remoteManifest.getStickers()) + .map(s -> toSticker(packId, packKey, s)) + .toList()); + + return Optional.of(new StickerManifestResult(localManifest, false)); + } catch (IOException | InvalidMessageException e) { + Log.w(TAG, "Failed to retrieve pack manifest.", e); + } + + return Optional.absent(); + } + + @WorkerThread + private List getStickersFromDatabase(@NonNull String packId) { + List stickers = new ArrayList<>(); + + try (Cursor cursor = stickerDatabase.getStickersForPack(packId)) { + StickerDatabase.StickerRecordReader reader = new StickerDatabase.StickerRecordReader(cursor); + + StickerRecord record; + while ((record = reader.getNext()) != null) { + stickers.add(toSticker(record)); + } + } + + return stickers; + } + + + private Optional toOptionalSticker(@NonNull String packId, + @NonNull String packKey, + @NonNull Optional remoteSticker) + { + return remoteSticker.isPresent() ? Optional.of(toSticker(packId, packKey, remoteSticker.get())) + : Optional.absent(); + } + + private StickerManifest.Sticker toSticker(@NonNull String packId, + @NonNull String packKey, + @NonNull SignalServiceStickerManifest.StickerInfo remoteSticker) + { + return new StickerManifest.Sticker(packId, packKey, remoteSticker.getId(), remoteSticker.getEmoji(), remoteSticker.getContentType()); + } + + private StickerManifest.Sticker toSticker(@NonNull StickerRecord record) { + return new StickerManifest.Sticker(record.getPackId(), record.getPackKey(), record.getStickerId(), record.getEmoji(), record.getContentType(), record.getUri()); + } + + static class StickerManifestResult { + private final StickerManifest manifest; + private final boolean isInstalled; + + StickerManifestResult(StickerManifest manifest, boolean isInstalled) { + this.manifest = manifest; + this.isInstalled = isInstalled; + } + + public StickerManifest getManifest() { + return manifest; + } + + public boolean isInstalled() { + return isInstalled; + } + } + + interface Callback { + void onComplete(T result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModel.java new file mode 100644 index 00000000..c8b80414 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewViewModel.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.stickers; + +import android.app.Application; +import android.database.ContentObserver; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.stickers.StickerPackPreviewRepository.StickerManifestResult; +import org.whispersystems.libsignal.util.guava.Optional; + +final class StickerPackPreviewViewModel extends ViewModel { + + private final Application application; + private final StickerPackPreviewRepository previewRepository; + private final StickerManagementRepository managementRepository; + private final MutableLiveData> stickerManifest; + private final ContentObserver packObserver; + + private String packId; + private String packKey; + + private StickerPackPreviewViewModel(@NonNull Application application, + @NonNull StickerPackPreviewRepository previewRepository, + @NonNull StickerManagementRepository managementRepository) + { + this.application = application; + this.previewRepository = previewRepository; + this.managementRepository = managementRepository; + this.stickerManifest = new MutableLiveData<>(); + this.packObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + if (!TextUtils.isEmpty(packId) && !TextUtils.isEmpty(packKey)) { + previewRepository.getStickerManifest(packId, packKey, stickerManifest::postValue); + } + } + }; + + application.getContentResolver().registerContentObserver(DatabaseContentProviders.StickerPack.CONTENT_URI, true, packObserver); + } + + LiveData> getStickerManifest(@NonNull String packId, @NonNull String packKey) { + this.packId = packId; + this.packKey = packKey; + + previewRepository.getStickerManifest(packId, packKey, stickerManifest::postValue); + + return stickerManifest; + } + + void onInstallClicked() { + managementRepository.installStickerPack(packId, packKey, true); + } + + void onRemoveClicked() { + managementRepository.uninstallStickerPack(packId, packKey); + } + + @Override + protected void onCleared() { + application.getContentResolver().unregisterContentObserver(packObserver); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + private final Application application; + private final StickerPackPreviewRepository previewRepository; + private final StickerManagementRepository managementRepository; + + Factory(@NonNull Application application, + @NonNull StickerPackPreviewRepository previewRepository, + @NonNull StickerManagementRepository managementRepository) + { + this.application = application; + this.previewRepository = previewRepository; + this.managementRepository = managementRepository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new StickerPackPreviewViewModel(application, previewRepository, managementRepository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewPopup.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewPopup.java new file mode 100644 index 00000000..c84eafe8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewPopup.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; + + +/** + * A popup that shows a given sticker fullscreen. + */ +final class StickerPreviewPopup extends PopupWindow { + + private final GlideRequests glideRequests; + private final ImageView image; + private final TextView emojiText; + + StickerPreviewPopup(@NonNull Context context, @NonNull GlideRequests glideRequests) { + super(LayoutInflater.from(context).inflate(R.layout.sticker_preview_popup, null), + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + this.glideRequests = glideRequests; + this.image = getContentView().findViewById(R.id.sticker_popup_image); + this.emojiText = getContentView().findViewById(R.id.sticker_popup_emoji); + + setTouchable(false); + } + + void presentSticker(@NonNull Object stickerGlideModel, @Nullable String emoji) { + emojiText.setText(emoji); + glideRequests.load(stickerGlideModel) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(image); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUri.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUri.java new file mode 100644 index 00000000..5b5733ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUri.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.Key; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Objects; + +/** + * Used as a model to be given to Glide for a sticker that isn't present locally. + */ +public final class StickerRemoteUri implements Key { + + private final String packId; + private final String packKey; + private final int stickerId; + + public StickerRemoteUri(@NonNull String packId, @NonNull String packKey, int stickerId) { + this.packId = packId; + this.packKey = packKey; + this.stickerId = stickerId; + } + + public @NonNull String getPackId() { + return packId; + } + + public @NonNull String getPackKey() { + return packKey; + } + + public int getStickerId() { + return stickerId; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(packId.getBytes()); + messageDigest.update(packKey.getBytes()); + messageDigest.update(ByteBuffer.allocate(4).putInt(stickerId).array()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StickerRemoteUri that = (StickerRemoteUri) o; + return stickerId == that.stickerId && + Objects.equals(packId, that.packId) && + Objects.equals(packKey, that.packKey); + } + + @Override + public int hashCode() { + return Objects.hash(packId, packKey, stickerId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriFetcher.java new file mode 100644 index 00000000..bf98240c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriFetcher.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.InvalidMessageException; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Downloads a sticker remotely. Used with Glide. + */ +public final class StickerRemoteUriFetcher implements DataFetcher { + + private static final String TAG = Log.tag(StickerRemoteUriFetcher.class); + + private final SignalServiceMessageReceiver receiver; + private final StickerRemoteUri stickerUri; + + public StickerRemoteUriFetcher(@NonNull SignalServiceMessageReceiver receiver, @NonNull StickerRemoteUri stickerUri) { + this.receiver = receiver; + this.stickerUri = stickerUri; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + byte[] packIdBytes = Hex.fromStringCondensed(stickerUri.getPackId()); + byte[] packKeyBytes = Hex.fromStringCondensed(stickerUri.getPackKey()); + InputStream stream = receiver.retrieveSticker(packIdBytes, packKeyBytes, stickerUri.getStickerId()); + + callback.onDataReady(stream); + } catch (IOException | InvalidMessageException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + + } + + @Override + public void cancel() { + Log.d(TAG, "Canceled."); + } + + @Override + public @NonNull Class getDataClass() { + return InputStream.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.REMOTE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriLoader.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriLoader.java new file mode 100644 index 00000000..aa6e3e93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRemoteUriLoader.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.stickers; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; + +import java.io.InputStream; + +/** + * Glide loader to fetch a sticker remotely. + */ +public final class StickerRemoteUriLoader implements ModelLoader { + + private final SignalServiceMessageReceiver receiver; + + public StickerRemoteUriLoader(@NonNull SignalServiceMessageReceiver receiver) { + this.receiver = receiver; + } + + + @Override + public @NonNull LoadData buildLoadData(@NonNull StickerRemoteUri sticker, int width, int height, @NonNull Options options) { + return new LoadData<>(sticker, new StickerRemoteUriFetcher(receiver, sticker)); + } + + @Override + public boolean handles(@NonNull StickerRemoteUri sticker) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new StickerRemoteUriLoader(ApplicationDependencies.getSignalServiceMessageReceiver()); + } + + @Override + public void teardown() { + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRolloverTouchListener.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRolloverTouchListener.java new file mode 100644 index 00000000..15bd613b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerRolloverTouchListener.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.whispersystems.libsignal.util.Pair; + +import java.lang.ref.WeakReference; + +public class StickerRolloverTouchListener implements RecyclerView.OnItemTouchListener { + private final StickerPreviewPopup popup; + private final RolloverEventListener eventListener; + private final RolloverStickerRetriever stickerRetriever; + + private WeakReference currentView; + private boolean hoverMode; + + StickerRolloverTouchListener(@NonNull Context context, + @NonNull GlideRequests glideRequests, + @NonNull RolloverEventListener eventListener, + @NonNull RolloverStickerRetriever stickerRetriever) + { + this.eventListener = eventListener; + this.stickerRetriever = stickerRetriever; + this.popup = new StickerPreviewPopup(context, glideRequests); + this.currentView = new WeakReference<>(null); + + popup.setAnimationStyle(R.style.StickerPopupAnimation); + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { + return hoverMode; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + hoverMode = false; + popup.dismiss(); + eventListener.onStickerPopupEnded(); + currentView.clear(); + break; + default: + for (int i = 0, len = recyclerView.getChildCount(); i < len; i++) { + View child = recyclerView.getChildAt(i); + + if (ViewUtil.isPointInsideView(recyclerView, motionEvent.getRawX(), motionEvent.getRawY()) && + ViewUtil.isPointInsideView(child, motionEvent.getRawX(), motionEvent.getRawY()) && + child != currentView.get()) + { + showStickerForView(recyclerView, child); + currentView = new WeakReference<>(child); + break; + } + } + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean b) { + } + + void enterHoverMode(@NonNull RecyclerView recyclerView, View targetView) { + this.hoverMode = true; + showStickerForView(recyclerView, targetView); + } + + private void showStickerForView(@NonNull RecyclerView recyclerView, @NonNull View view) { + Pair stickerData = stickerRetriever.getStickerDataFromView(view); + + if (stickerData != null) { + if (!popup.isShowing()) { + popup.showAtLocation(recyclerView, Gravity.NO_GRAVITY, 0, 0); + eventListener.onStickerPopupStarted(); + } + popup.presentSticker(stickerData.first(), stickerData.second()); + } + } + + public interface RolloverEventListener { + void onStickerPopupStarted(); + void onStickerPopupEnded(); + } + + public interface RolloverStickerRetriever { + @Nullable Pair getStickerDataFromView(@NonNull View view); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java new file mode 100644 index 00000000..e48b3b3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.stickers; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.CursorList; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.StickerDatabase.StickerRecordReader; +import org.thoughtcrime.securesms.database.model.StickerRecord; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public final class StickerSearchRepository { + + private final StickerDatabase stickerDatabase; + private final AttachmentDatabase attachmentDatabase; + + public StickerSearchRepository(@NonNull Context context) { + this.stickerDatabase = DatabaseFactory.getStickerDatabase(context); + this.attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + } + + public void searchByEmoji(@NonNull String emoji, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + String searchEmoji = EmojiUtil.getCanonicalRepresentation(emoji); + List out = new ArrayList<>(); + Set possible = EmojiUtil.getAllRepresentations(searchEmoji); + + for (String candidate : possible) { + try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate))) { + StickerRecord record = null; + while ((record = reader.getNext()) != null) { + out.add(record); + } + } + } + + callback.onResult(out); + }); + } + + public void getStickerFeatureAvailability(@NonNull Callback callback) { + SignalExecutors.BOUNDED.execute(() -> { + try (Cursor cursor = stickerDatabase.getAllStickerPacks("1")) { + if (cursor != null && cursor.moveToFirst()) { + callback.onResult(true); + } else { + callback.onResult(attachmentDatabase.hasStickerAttachments()); + } + } + }); + } + + private static class StickerModelBuilder implements CursorList.ModelBuilder { + @Override + public StickerRecord build(@NonNull Cursor cursor) { + return new StickerRecordReader(cursor).getCurrent(); + } + } + + public interface Callback { + void onResult(T result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerUrl.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerUrl.java new file mode 100644 index 00000000..005276ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerUrl.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.stickers; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.Hex; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Manages creating and parsing the various sticker pack URLs. + */ +public class StickerUrl { + + private static final Pattern STICKER_URL_PATTERN = Pattern.compile("^https://signal\\.art/addstickers/#pack_id=(.*)&pack_key=(.*)$"); + + public static Optional> parseExternalUri(@Nullable Uri uri) { + if (uri == null) return Optional.absent(); + + return parseActionUri(uri).or(parseShareLink(uri.toString())); + } + + public static Optional> parseActionUri(@Nullable Uri uri) { + if (uri == null) return Optional.absent(); + + String packId = uri.getQueryParameter("pack_id"); + String packKey = uri.getQueryParameter("pack_key"); + + if (TextUtils.isEmpty(packId) || TextUtils.isEmpty(packKey) || !isValidHex(packId) || !isValidHex(packKey)) { + return Optional.absent(); + } + + return Optional.of(new Pair<>(packId, packKey)); + } + + public static @NonNull Uri createActionUri(@NonNull String packId, @NonNull String packKey) { + return Uri.parse(String.format("sgnl://addstickers?pack_id=%s&pack_key=%s", packId, packKey)); + } + + public static boolean isValidShareLink(@Nullable String url) { + return parseShareLink(url).isPresent(); + } + + public static @NonNull Optional> parseShareLink(@Nullable String url) { + if (url == null) return Optional.absent(); + + Matcher matcher = STICKER_URL_PATTERN.matcher(url); + + if (matcher.matches() && matcher.groupCount() == 2) { + String packId = matcher.group(1); + String packKey = matcher.group(2); + + if (isValidHex(packId) && isValidHex(packKey)) { + return Optional.of(new Pair<>(packId, packKey)); + } + } + + return Optional.absent(); + } + + public static String createShareLink(@NonNull String packId, @NonNull String packKey) { + return "https://signal.art/addstickers/#pack_id=" + packId + "&pack_key=" + packKey; + } + + private static boolean isValidHex(String value) { + try { + Hex.fromStringCondensed(value); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java new file mode 100644 index 00000000..e940796f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord.PinnedConversation; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +class AccountConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(AccountConflictMerger.class); + + private final Optional local; + + AccountConflictMerger(Optional local) { + this.local = local; + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalAccountRecord record) { + return local; + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + Set invalid = new HashSet<>(remoteRecords); + if (remoteRecords.size() > 0) { + invalid.remove(remoteRecords.iterator().next()); + } + + if (invalid.size() > 0) { + Log.w(TAG, "Found invalid account entries! Count: " + invalid.size()); + } + + return invalid; + } + + @Override + public @NonNull SignalAccountRecord merge(@NonNull SignalAccountRecord remote, @NonNull SignalAccountRecord local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + byte[] unknownFields = remote.serializeUnknownFields(); + String avatarUrlPath = remote.getAvatarUrlPath().or(local.getAvatarUrlPath()).or(""); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + boolean noteToSelfArchived = remote.isNoteToSelfArchived(); + boolean noteToSelfForcedUnread = remote.isNoteToSelfForcedUnread(); + boolean readReceipts = remote.isReadReceiptsEnabled(); + boolean typingIndicators = remote.isTypingIndicatorsEnabled(); + boolean sealedSenderIndicators = remote.isSealedSenderIndicatorsEnabled(); + boolean linkPreviews = remote.isLinkPreviewsEnabled(); + boolean unlisted = remote.isPhoneNumberUnlisted(); + List pinnedConversations = remote.getPinnedConversations(); + AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode(); + boolean preferContactAvatars = remote.isPreferContactAvatars(); + boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); + boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalAccountRecord.Builder(keyGenerator.generate()) + .setUnknownFields(unknownFields) + .setGivenName(givenName) + .setFamilyName(familyName) + .setAvatarUrlPath(avatarUrlPath) + .setProfileKey(profileKey) + .setNoteToSelfArchived(noteToSelfArchived) + .setNoteToSelfForcedUnread(noteToSelfForcedUnread) + .setReadReceiptsEnabled(readReceipts) + .setTypingIndicatorsEnabled(typingIndicators) + .setSealedSenderIndicatorsEnabled(sealedSenderIndicators) + .setLinkPreviewsEnabled(linkPreviews) + .setUnlistedPhoneNumber(unlisted) + .setPhoneNumberSharingMode(phoneNumberSharingMode) + .setUnlistedPhoneNumber(unlisted) + .setPinnedConversations(pinnedConversations) + .setPreferContactAvatars(preferContactAvatars) + .build(); + } + } + + private static boolean doParamsMatch(@NonNull SignalAccountRecord contact, + @Nullable byte[] unknownFields, + @NonNull String givenName, + @NonNull String familyName, + @NonNull String avatarUrlPath, + @Nullable byte[] profileKey, + boolean noteToSelfArchived, + boolean noteToSelfForcedUnread, + boolean readReceipts, + boolean typingIndicators, + boolean sealedSenderIndicators, + boolean linkPreviewsEnabled, + AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode, + boolean unlistedPhoneNumber, + @NonNull List pinnedConversations, + boolean preferContactAvatars) + { + return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && + Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + contact.isNoteToSelfArchived() == noteToSelfArchived && + contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread && + contact.isReadReceiptsEnabled() == readReceipts && + contact.isTypingIndicatorsEnabled() == typingIndicators && + contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && + contact.isLinkPreviewsEnabled() == linkPreviewsEnabled && + contact.getPhoneNumberSharingMode() == phoneNumberSharingMode && + contact.isPhoneNumberUnlisted() == unlistedPhoneNumber && + contact.isPreferContactAvatars() == preferContactAvatars && + Objects.equals(contact.getPinnedConversations(), pinnedConversations); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java new file mode 100644 index 00000000..414f7c99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +class ContactConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(ContactConflictMerger.class); + + private final Map localByUuid = new HashMap<>(); + private final Map localByE164 = new HashMap<>(); + + private final Recipient self; + + ContactConflictMerger(@NonNull Collection localOnly, @NonNull Recipient self) { + for (SignalContactRecord contact : localOnly) { + if (contact.getAddress().getUuid().isPresent()) { + localByUuid.put(contact.getAddress().getUuid().get(), contact); + } + if (contact.getAddress().getNumber().isPresent()) { + localByE164.put(contact.getAddress().getNumber().get(), contact); + } + } + + this.self = self.resolve(); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalContactRecord record) { + SignalContactRecord localUuid = record.getAddress().getUuid().isPresent() ? localByUuid.get(record.getAddress().getUuid().get()) : null; + SignalContactRecord localE164 = record.getAddress().getNumber().isPresent() ? localByE164.get(record.getAddress().getNumber().get()) : null; + + return Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164)); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + Map> localIdToRemoteRecords = new HashMap<>(); + + for (SignalContactRecord remote : remoteRecords) { + Optional local = getMatching(remote); + + if (local.isPresent()) { + String serializedLocalId = Base64.encodeBytes(local.get().getId().getRaw()); + Set matches = localIdToRemoteRecords.get(serializedLocalId); + + if (matches == null) { + matches = new HashSet<>(); + } + + matches.add(remote); + localIdToRemoteRecords.put(serializedLocalId, matches); + } + } + + Set duplicates = new HashSet<>(); + for (Set matches : localIdToRemoteRecords.values()) { + if (matches.size() > 1) { + duplicates.addAll(matches); + } + } + + List selfRecords = Stream.of(remoteRecords) + .filter(r -> r.getAddress().getUuid().equals(self.getUuid()) || r.getAddress().getNumber().equals(self.getE164())) + .toList(); + + Set invalid = new HashSet<>(); + invalid.addAll(selfRecords); + invalid.addAll(duplicates); + + if (invalid.size() > 0) { + Log.w(TAG, "Found invalid contact entries! Self Records: " + selfRecords.size() + ", Duplicates: " + duplicates.size()); + } + + return invalid; + } + + @Override + public @NonNull SignalContactRecord merge(@NonNull SignalContactRecord remote, @NonNull SignalContactRecord local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + byte[] unknownFields = remote.serializeUnknownFields(); + UUID uuid = remote.getAddress().getUuid().or(local.getAddress().getUuid()).orNull(); + String e164 = remote.getAddress().getNumber().or(local.getAddress().getNumber()).orNull(); + SignalServiceAddress address = new SignalServiceAddress(uuid, e164); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + String username = remote.getUsername().or(local.getUsername()).or(""); + IdentityState identityState = remote.getIdentityState(); + byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + boolean matchesRemote = doParamsMatch(remote, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread); + boolean matchesLocal = doParamsMatch(local, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalContactRecord.Builder(keyGenerator.generate(), address) + .setUnknownFields(unknownFields) + .setGivenName(givenName) + .setFamilyName(familyName) + .setProfileKey(profileKey) + .setUsername(username) + .setIdentityState(identityState) + .setIdentityKey(identityKey) + .setBlocked(blocked) + .setProfileSharingEnabled(profileSharing) + .setForcedUnread(forcedUnread) + .build(); + } + } + + private static boolean doParamsMatch(@NonNull SignalContactRecord contact, + @Nullable byte[] unknownFields, + @NonNull SignalServiceAddress address, + @NonNull String givenName, + @NonNull String familyName, + @Nullable byte[] profileKey, + @NonNull String username, + @Nullable IdentityState identityState, + @Nullable byte[] identityKey, + boolean blocked, + boolean profileSharing, + boolean archived, + boolean forcedUnread) + { + return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && + Objects.equals(contact.getAddress(), address) && + Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + Objects.equals(contact.getUsername().or(""), username) && + Objects.equals(contact.getIdentityState(), identityState) && + Arrays.equals(contact.getIdentityKey().orNull(), identityKey) && + contact.isBlocked() == blocked && + contact.isProfileSharingEnabled() == profileSharing && + contact.isArchived() == archived && + contact.isForcedUnread() == forcedUnread; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java new file mode 100644 index 00000000..dbfd234e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +final class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByGroupId; + private final GroupV2ExistenceChecker groupExistenceChecker; + + GroupV1ConflictMerger(@NonNull Collection localOnly, @NonNull GroupV2ExistenceChecker groupExistenceChecker) { + localByGroupId = Stream.of(localOnly).collect(Collectors.toMap(g -> GroupId.v1orThrow(g.getGroupId()), g -> g)); + + this.groupExistenceChecker = groupExistenceChecker; + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalGroupV1Record record) { + return Optional.fromNullable(localByGroupId.get(GroupId.v1orThrow(record.getGroupId()))); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + return Stream.of(remoteRecords) + .filter(record -> { + try { + GroupId.V1 id = GroupId.v1(record.getGroupId()); + return groupExistenceChecker.exists(id.deriveV2MigrationGroupId()); + } catch (BadGroupIdException e) { + return true; + } + }).toList(); + } + + @Override + public @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + byte[] unknownFields = remote.serializeUnknownFields(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + + boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread(); + boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV1Record.Builder(keyGenerator.generate(), remote.getGroupId()) + .setUnknownFields(unknownFields) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .setForcedUnread(forcedUnread) + .build(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java new file mode 100644 index 00000000..216025fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + +import org.signal.zkgroup.groups.GroupMasterKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +final class GroupV2ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByMasterKeyBytes; + + GroupV2ConflictMerger(@NonNull Collection localOnly) { + localByMasterKeyBytes = Stream.of(localOnly).collect(Collectors.toMap((SignalGroupV2Record signalGroupV2Record) -> ByteString.copyFrom(signalGroupV2Record.getMasterKeyBytes()), g -> g)); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalGroupV2Record record) { + return Optional.fromNullable(localByMasterKeyBytes.get(ByteString.copyFrom(record.getMasterKeyBytes()))); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + return Stream.of(remoteRecords) + .filterNot(GroupV2ConflictMerger::isValidMasterKey) + .toList(); + } + + @Override + public @NonNull SignalGroupV2Record merge(@NonNull SignalGroupV2Record remote, @NonNull SignalGroupV2Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + byte[] unknownFields = remote.serializeUnknownFields(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + + boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread(); + boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV2Record.Builder(keyGenerator.generate(), remote.getMasterKeyBytes()) + .setUnknownFields(unknownFields) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .setArchived(archived) + .setForcedUnread(forcedUnread) + .build(); + } + } + + private static boolean isValidMasterKey(@NonNull SignalGroupV2Record record) { + return record.getMasterKeyBytes().length == GroupMasterKey.SIZE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ExistenceChecker.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ExistenceChecker.java new file mode 100644 index 00000000..4271d1a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ExistenceChecker.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.groups.GroupId; + +/** + * Allows a caller to determine if a group exists in the local data store already. Needed primarily + * to check if a local GV2 group already exists for a remote GV1 group. + */ +public interface GroupV2ExistenceChecker { + boolean exists(@NonNull GroupId.V2 groupId); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StaticGroupV2ExistenceChecker.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StaticGroupV2ExistenceChecker.java new file mode 100644 index 00000000..fe94b076 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StaticGroupV2ExistenceChecker.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.groups.GroupId; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * Implementation that is backed by a static set of GV2 IDs. + */ +public final class StaticGroupV2ExistenceChecker implements GroupV2ExistenceChecker { + + private final Set ids; + + public StaticGroupV2ExistenceChecker(@NonNull Collection ids) { + this.ids = new HashSet<>(ids); + } + + @Override + public boolean exists(@NonNull GroupId.V2 groupId) { + return ids.contains(groupId); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java new file mode 100644 index 00000000..9df8cb14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -0,0 +1,772 @@ +package org.thoughtcrime.securesms.storage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; +import org.whispersystems.signalservice.api.storage.SignalRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.util.OptionalUtil; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public final class StorageSyncHelper { + + private static final String TAG = Log.tag(StorageSyncHelper.class); + + private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); + + private static KeyGenerator keyGenerator = KEY_GENERATOR; + + private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); + + /** + * Given the local state of pending storage mutations, this will generate a result that will + * include that data that needs to be written to the storage service, as well as any changes you + * need to write back to local storage (like storage keys that might have changed for updated + * contacts). + * + * @param currentManifestVersion What you think the version is locally. + * @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys + * already, and that deletes still have keys. + * @param updates Contacts that have been altered. + * @param inserts Contacts that have been inserted (or newly marked as registered). + * @param deletes Contacts that are no longer registered. + * + * @return If changes need to be written, then it will return those changes. If no changes need + * to be written, this will return {@link Optional#absent()}. + */ + public static @NonNull Optional buildStorageUpdatesForLocal(long currentManifestVersion, + @NonNull List currentLocalKeys, + @NonNull List updates, + @NonNull List inserts, + @NonNull List deletes, + @NonNull Optional accountUpdate, + @NonNull Optional accountInsert) + { + int accountCount = Stream.of(currentLocalKeys) + .filter(id -> id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE) + .toList() + .size(); + + if (accountCount > 1) { + throw new MultipleExistingAccountsException(); + } + + Optional accountId = Optional.fromNullable(Stream.of(currentLocalKeys) + .filter(id -> id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE) + .findFirst() + .orElse(null)); + + + if (accountId.isPresent() && accountInsert.isPresent() && !accountInsert.get().getId().equals(accountId.get())) { + throw new InvalidAccountInsertException(); + } + + if (accountId.isPresent() && accountUpdate.isPresent() && !accountUpdate.get().getId().equals(accountId.get())) { + throw new InvalidAccountUpdateException(); + } + + if (accountUpdate.isPresent() && accountInsert.isPresent()) { + throw new InvalidAccountDualInsertUpdateException(); + } + + Set completeIds = new LinkedHashSet<>(currentLocalKeys); + Set storageInserts = new LinkedHashSet<>(); + Set storageDeletes = new LinkedHashSet<>(); + Map storageKeyUpdates = new HashMap<>(); + + for (RecipientSettings insert : inserts) { + if (insert.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && insert.getSyncExtras().getGroupMasterKey() == null) { + Log.w(TAG, "Missing master key on gv2 recipient"); + continue; + } + + storageInserts.add(StorageSyncModels.localToRemoteRecord(insert)); + + switch (insert.getGroupType()) { + case NONE: + completeIds.add(StorageId.forContact(insert.getStorageId())); + break; + case SIGNAL_V1: + completeIds.add(StorageId.forGroupV1(insert.getStorageId())); + break; + case SIGNAL_V2: + completeIds.add(StorageId.forGroupV2(insert.getStorageId())); + break; + default: + throw new AssertionError("Unsupported type!"); + } + } + + if (accountInsert.isPresent()) { + storageInserts.add(SignalStorageRecord.forAccount(accountInsert.get())); + completeIds.add(accountInsert.get().getId()); + } + + for (RecipientSettings delete : deletes) { + byte[] key = Objects.requireNonNull(delete.getStorageId()); + storageDeletes.add(ByteBuffer.wrap(key)); + completeIds.remove(StorageId.forContact(key)); + } + + for (RecipientSettings update : updates) { + StorageId oldId; + StorageId newId; + + switch (update.getGroupType()) { + case NONE: + oldId = StorageId.forContact(update.getStorageId()); + newId = StorageId.forContact(generateKey()); + break; + case SIGNAL_V1: + oldId = StorageId.forGroupV1(update.getStorageId()); + newId = StorageId.forGroupV1(generateKey()); + break; + case SIGNAL_V2: + oldId = StorageId.forGroupV2(update.getStorageId()); + newId = StorageId.forGroupV2(generateKey()); + break; + default: + throw new AssertionError("Unsupported type!"); + } + + storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newId.getRaw())); + storageDeletes.add(ByteBuffer.wrap(oldId.getRaw())); + completeIds.remove(oldId); + completeIds.add(newId); + storageKeyUpdates.put(update.getId(), newId.getRaw()); + } + + if (accountUpdate.isPresent()) { + StorageId oldId = accountUpdate.get().getId(); + StorageId newId = StorageId.forAccount(generateKey()); + + storageInserts.add(SignalStorageRecord.forAccount(newId, accountUpdate.get())); + storageDeletes.add(ByteBuffer.wrap(oldId.getRaw())); + completeIds.remove(oldId); + completeIds.add(newId); + storageKeyUpdates.put(Recipient.self().getId(), newId.getRaw()); + } + + if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { + return Optional.absent(); + } else { + List storageDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); + List completeIdsBytes = new ArrayList<>(completeIds); + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeIdsBytes); + WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), storageDeleteBytes); + + return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates)); + } + } + + /** + * Given a list of all the local and remote keys you know about, this will return a result telling + * you which keys are exclusively remote and which are exclusively local. + * + * @param remoteKeys All remote keys available. + * @param localKeys All local keys available. + * + * @return An object describing which keys are exclusive to the remote data set and which keys are + * exclusive to the local data set. + */ + public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull Collection remoteKeys, + @NonNull Collection localKeys) + { + Map remoteByRawId = Stream.of(remoteKeys).collect(Collectors.toMap(id -> Base64.encodeBytes(id.getRaw()), id -> id)); + Map localByRawId = Stream.of(localKeys).collect(Collectors.toMap(id -> Base64.encodeBytes(id.getRaw()), id -> id)); + + boolean hasTypeMismatch = remoteByRawId.size() != remoteKeys.size() || localByRawId.size() != localKeys.size(); + + Set remoteOnlyRawIds = SetUtil.difference(remoteByRawId.keySet(), localByRawId.keySet()); + Set localOnlyRawIds = SetUtil.difference(localByRawId.keySet(), remoteByRawId.keySet()); + Set sharedRawIds = SetUtil.intersection(localByRawId.keySet(), remoteByRawId.keySet()); + + for (String rawId : sharedRawIds) { + StorageId remote = Objects.requireNonNull(remoteByRawId.get(rawId)); + StorageId local = Objects.requireNonNull(localByRawId.get(rawId)); + + if (remote.getType() != local.getType()) { + remoteOnlyRawIds.remove(rawId); + localOnlyRawIds.remove(rawId); + hasTypeMismatch = true; + } + } + + List remoteOnlyKeys = Stream.of(remoteOnlyRawIds).map(remoteByRawId::get).toList(); + List localOnlyKeys = Stream.of(localOnlyRawIds).map(localByRawId::get).toList(); + + return new KeyDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch); + } + + /** + * Given two sets of storage records, this will resolve the data into a set of actions that need + * to be applied to resolve the differences. This will handle discovering which records between + * the two collections refer to the same contacts and are actually updates, which are brand new, + * etc. + * + * @param remoteOnlyRecords Records that are only present remotely. + * @param localOnlyRecords Records that are only present locally. + * + * @return A set of actions that should be applied to resolve the conflict. + */ + public static @NonNull MergeResult resolveConflict(@NonNull Collection remoteOnlyRecords, + @NonNull Collection localOnlyRecords, + @NonNull GroupV2ExistenceChecker groupExistenceChecker) + { + List remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); + List localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); + + List remoteOnlyGroupV1 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); + List localOnlyGroupV1 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); + + List remoteOnlyGroupV2 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV2().isPresent()).map(r -> r.getGroupV2().get()).toList(); + List localOnlyGroupV2 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV2().isPresent()).map(r -> r.getGroupV2().get()).toList(); + + List remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); + List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); + + List remoteOnlyAccount = Stream.of(remoteOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); + List localOnlyAccount = Stream.of(localOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); + if (remoteOnlyAccount.size() > 0 && localOnlyAccount.isEmpty()) { + throw new AssertionError("Found a remote-only account, but no local-only account!"); + } + if (localOnlyAccount.size() > 1) { + throw new AssertionError("Multiple local accounts?"); + } + + RecordMergeResult contactMergeResult = resolveRecordConflict(remoteOnlyContacts, localOnlyContacts, new ContactConflictMerger(localOnlyContacts, Recipient.self())); + RecordMergeResult groupV1MergeResult = resolveRecordConflict(remoteOnlyGroupV1, localOnlyGroupV1, new GroupV1ConflictMerger(localOnlyGroupV1, groupExistenceChecker)); + RecordMergeResult groupV2MergeResult = resolveRecordConflict(remoteOnlyGroupV2, localOnlyGroupV2, new GroupV2ConflictMerger(localOnlyGroupV2)); + RecordMergeResult accountMergeResult = resolveRecordConflict(remoteOnlyAccount, localOnlyAccount, new AccountConflictMerger(localOnlyAccount.isEmpty() ? Optional.absent() : Optional.of(localOnlyAccount.get(0)))); + + Set remoteInserts = new HashSet<>(); + remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); + remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).toList()); + remoteInserts.addAll(Stream.of(groupV2MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV2).toList()); + remoteInserts.addAll(Stream.of(accountMergeResult.remoteInserts).map(SignalStorageRecord::forAccount).toList()); + + Set> remoteUpdates = new HashSet<>(); + remoteUpdates.addAll(Stream.of(contactMergeResult.remoteUpdates) + .map(c -> new RecordUpdate<>(SignalStorageRecord.forContact(c.getOld()), SignalStorageRecord.forContact(c.getNew()))) + .toList()); + remoteUpdates.addAll(Stream.of(groupV1MergeResult.remoteUpdates) + .map(c -> new RecordUpdate<>(SignalStorageRecord.forGroupV1(c.getOld()), SignalStorageRecord.forGroupV1(c.getNew()))) + .toList()); + remoteUpdates.addAll(Stream.of(groupV2MergeResult.remoteUpdates) + .map(c -> new RecordUpdate<>(SignalStorageRecord.forGroupV2(c.getOld()), SignalStorageRecord.forGroupV2(c.getNew()))) + .toList()); + remoteUpdates.addAll(Stream.of(accountMergeResult.remoteUpdates) + .map(c -> new RecordUpdate<>(SignalStorageRecord.forAccount(c.getOld()), SignalStorageRecord.forAccount(c.getNew()))) + .toList()); + + Set remoteDeletes = new HashSet<>(); + remoteDeletes.addAll(contactMergeResult.remoteDeletes); + remoteDeletes.addAll(groupV1MergeResult.remoteDeletes); + remoteDeletes.addAll(groupV2MergeResult.remoteDeletes); + remoteDeletes.addAll(accountMergeResult.remoteDeletes); + + return new MergeResult(contactMergeResult.localInserts, + contactMergeResult.localUpdates, + groupV1MergeResult.localInserts, + groupV1MergeResult.localUpdates, + groupV2MergeResult.localInserts, + groupV2MergeResult.localUpdates, + new LinkedHashSet<>(remoteOnlyUnknowns), + new LinkedHashSet<>(localOnlyUnknowns), + accountMergeResult.localUpdates.isEmpty() ? Optional.absent() : Optional.of(accountMergeResult.localUpdates.iterator().next()), + remoteInserts, + remoteUpdates, + remoteDeletes); + } + + /** + * Assumes that the merge result has *not* yet been applied to the local data. That means that + * this method will handle generating the correct final key set based on the merge result. + */ + public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion, + @NonNull List currentLocalStorageKeys, + @NonNull MergeResult mergeResult) + { + List inserts = new ArrayList<>(); + inserts.addAll(mergeResult.getRemoteInserts()); + inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); + + List deletes = new ArrayList<>(); + deletes.addAll(Stream.of(mergeResult.getRemoteDeletes()).map(SignalRecord::getId).toList()); + deletes.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).toList()); + + Set completeKeys = new HashSet<>(currentLocalStorageKeys); + completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList()); + completeKeys.removeAll(Stream.of(mergeResult.getAllRemovedRecords()).map(SignalRecord::getId).toList()); + completeKeys.addAll(Stream.of(inserts).map(SignalStorageRecord::getId).toList()); + completeKeys.removeAll(deletes); + + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys)); + + return new WriteOperationResult(manifest, inserts, Stream.of(deletes).map(StorageId::getRaw).toList()); + } + + public static @NonNull byte[] generateKey() { + return keyGenerator.generate(); + } + + @VisibleForTesting + static void setTestKeyGenerator(@Nullable KeyGenerator testKeyGenerator) { + keyGenerator = testKeyGenerator; + } + + private static @NonNull RecordMergeResult resolveRecordConflict(@NonNull Collection remoteOnlyRecords, + @NonNull Collection localOnlyRecords, + @NonNull ConflictMerger merger) + { + Set localInserts = new HashSet<>(remoteOnlyRecords); + Set remoteInserts = new HashSet<>(localOnlyRecords); + Set> localUpdates = new HashSet<>(); + Set> remoteUpdates = new HashSet<>(); + Set remoteDeletes = new HashSet<>(merger.getInvalidEntries(remoteOnlyRecords)); + + remoteOnlyRecords.removeAll(remoteDeletes); + localInserts.removeAll(remoteDeletes); + + for (E remote : remoteOnlyRecords) { + Optional local = merger.getMatching(remote); + + if (local.isPresent()) { + E merged = merger.merge(remote, local.get(), keyGenerator); + + if (!merged.equals(remote)) { + remoteUpdates.add(new RecordUpdate<>(remote, merged)); + } + + if (!merged.equals(local.get())) { + localUpdates.add(new RecordUpdate<>(local.get(), merged)); + } + + localInserts.remove(remote); + remoteInserts.remove(local.get()); + } + } + + return new RecordMergeResult<>(localInserts, localUpdates, remoteInserts, remoteUpdates, remoteDeletes); + } + + public static boolean profileKeyChanged(RecordUpdate update) { + return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); + } + + public static Optional getPendingAccountSyncUpdate(@NonNull Context context, @NonNull Recipient self) { + if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(self.getId()) != RecipientDatabase.DirtyState.UPDATE) { + return Optional.absent(); + } + return Optional.of(buildAccountRecord(context, self).getAccount().get()); + } + + public static Optional getPendingAccountSyncInsert(@NonNull Context context, @NonNull Recipient self) { + if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(self.getId()) != RecipientDatabase.DirtyState.INSERT) { + return Optional.absent(); + } + return Optional.of(buildAccountRecord(context, self).getAccount().get()); + } + + public static SignalStorageRecord buildAccountRecord(@NonNull Context context, @NonNull Recipient self) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientSettings settings = recipientDatabase.getRecipientSettingsForSync(self.getId()); + List pinned = Stream.of(DatabaseFactory.getThreadDatabase(context).getPinnedRecipientIds()) + .map(recipientDatabase::getRecipientSettingsForSync) + .toList(); + + SignalAccountRecord account = new SignalAccountRecord.Builder(self.getStorageServiceId()) + .setUnknownFields(settings != null ? settings.getSyncExtras().getStorageProto() : null) + .setProfileKey(self.getProfileKey()) + .setGivenName(self.getProfileName().getGivenName()) + .setFamilyName(self.getProfileName().getFamilyName()) + .setAvatarUrlPath(self.getProfileAvatar()) + .setNoteToSelfArchived(settings != null && settings.getSyncExtras().isArchived()) + .setNoteToSelfForcedUnread(settings != null && settings.getSyncExtras().isForcedUnread()) + .setTypingIndicatorsEnabled(TextSecurePreferences.isTypingIndicatorsEnabled(context)) + .setReadReceiptsEnabled(TextSecurePreferences.isReadReceiptsEnabled(context)) + .setSealedSenderIndicatorsEnabled(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context)) + .setLinkPreviewsEnabled(SignalStore.settings().isLinkPreviewsEnabled()) + .setUnlistedPhoneNumber(SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode().isUnlisted()) + .setPhoneNumberSharingMode(StorageSyncModels.localToRemotePhoneNumberSharingMode(SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode())) + .setPinnedConversations(StorageSyncModels.localToRemotePinnedConversations(pinned)) + .setPreferContactAvatars(SignalStore.settings().isPreferSystemContactPhotos()) + .build(); + + return SignalStorageRecord.forAccount(account); + } + + public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional> update) { + if (!update.isPresent()) { + return; + } + applyAccountStorageSyncUpdates(context, StorageId.forAccount(Recipient.self().getStorageServiceId()), update.get().getNew(), true); + } + + public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull StorageId storageId, @NonNull SignalAccountRecord update, boolean fetchProfile) { + DatabaseFactory.getRecipientDatabase(context).applyStorageSyncUpdates(storageId, update); + + TextSecurePreferences.setReadReceiptsEnabled(context, update.isReadReceiptsEnabled()); + TextSecurePreferences.setTypingIndicatorsEnabled(context, update.isTypingIndicatorsEnabled()); + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.isSealedSenderIndicatorsEnabled()); + SignalStore.settings().setLinkPreviewsEnabled(update.isLinkPreviewsEnabled()); + SignalStore.phoneNumberPrivacy().setPhoneNumberListingMode(update.isPhoneNumberUnlisted() ? PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED : PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED); + SignalStore.phoneNumberPrivacy().setPhoneNumberSharingMode(StorageSyncModels.remoteToLocalPhoneNumberSharingMode(update.getPhoneNumberSharingMode())); + SignalStore.settings().setPreferSystemContactPhotos(update.isPreferContactAvatars()); + + if (fetchProfile && update.getAvatarUrlPath().isPresent()) { + ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get())); + } + } + + public static void scheduleSyncForDataChange() { + if (!SignalStore.registrationValues().isRegistrationComplete()) { + Log.d(TAG, "Registration still ongoing. Ignore sync request."); + return; + } + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + } + + public static void scheduleRoutineSync() { + long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageServiceValues().getLastSyncTime(); + + if (timeSinceLastSync > REFRESH_INTERVAL) { + Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago."); + scheduleSyncForDataChange(); + } else { + Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago."); + } + } + + public static final class KeyDifferenceResult { + private final List remoteOnlyKeys; + private final List localOnlyKeys; + private final boolean hasTypeMismatches; + + private KeyDifferenceResult(@NonNull List remoteOnlyKeys, + @NonNull List localOnlyKeys, + boolean hasTypeMismatches) + { + this.remoteOnlyKeys = remoteOnlyKeys; + this.localOnlyKeys = localOnlyKeys; + this.hasTypeMismatches = hasTypeMismatches; + } + + public @NonNull List getRemoteOnlyKeys() { + return remoteOnlyKeys; + } + + public @NonNull List getLocalOnlyKeys() { + return localOnlyKeys; + } + + /** + * @return True if there exist some keys that have matching raw ID's but different types, + * otherwise false. + */ + public boolean hasTypeMismatches() { + return hasTypeMismatches; + } + + public boolean isEmpty() { + return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty(); + } + } + + public static final class MergeResult { + private final Set localContactInserts; + private final Set> localContactUpdates; + private final Set localGroupV1Inserts; + private final Set> localGroupV1Updates; + private final Set localGroupV2Inserts; + private final Set> localGroupV2Updates; + private final Set localUnknownInserts; + private final Set localUnknownDeletes; + private final Optional> localAccountUpdate; + private final Set remoteInserts; + private final Set> remoteUpdates; + private final Set remoteDeletes; + + @VisibleForTesting + MergeResult(@NonNull Set localContactInserts, + @NonNull Set> localContactUpdates, + @NonNull Set localGroupV1Inserts, + @NonNull Set> localGroupV1Updates, + @NonNull Set localGroupV2Inserts, + @NonNull Set> localGroupV2Updates, + @NonNull Set localUnknownInserts, + @NonNull Set localUnknownDeletes, + @NonNull Optional> localAccountUpdate, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) + { + this.localContactInserts = localContactInserts; + this.localContactUpdates = localContactUpdates; + this.localGroupV1Inserts = localGroupV1Inserts; + this.localGroupV1Updates = localGroupV1Updates; + this.localGroupV2Inserts = localGroupV2Inserts; + this.localGroupV2Updates = localGroupV2Updates; + this.localUnknownInserts = localUnknownInserts; + this.localUnknownDeletes = localUnknownDeletes; + this.localAccountUpdate = localAccountUpdate; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + this.remoteDeletes = remoteDeletes; + } + + public @NonNull Set getLocalContactInserts() { + return localContactInserts; + } + + public @NonNull Set> getLocalContactUpdates() { + return localContactUpdates; + } + + public @NonNull Set getLocalGroupV1Inserts() { + return localGroupV1Inserts; + } + + public @NonNull Set> getLocalGroupV1Updates() { + return localGroupV1Updates; + } + + public @NonNull Set getLocalGroupV2Inserts() { + return localGroupV2Inserts; + } + + public @NonNull Set> getLocalGroupV2Updates() { + return localGroupV2Updates; + } + + public @NonNull Set getLocalUnknownInserts() { + return localUnknownInserts; + } + + public @NonNull Set getLocalUnknownDeletes() { + return localUnknownDeletes; + } + + public @NonNull Optional> getLocalAccountUpdate() { + return localAccountUpdate; + } + + public @NonNull Set getRemoteInserts() { + return remoteInserts; + } + + public @NonNull Set> getRemoteUpdates() { + return remoteUpdates; + } + + public @NonNull Set getRemoteDeletes() { + return remoteDeletes; + } + + @NonNull Set getAllNewRecords() { + Set records = new HashSet<>(); + + records.addAll(localContactInserts); + records.addAll(localGroupV1Inserts); + records.addAll(localGroupV2Inserts); + records.addAll(remoteInserts); + records.addAll(localUnknownInserts); + records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(localGroupV2Updates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getNew).toList()); + if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getNew()); + + return records; + } + + @NonNull Set getAllRemovedRecords() { + Set records = new HashSet<>(); + + records.addAll(localUnknownDeletes); + records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(localGroupV2Updates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getOld).toList()); + records.addAll(remoteDeletes); + if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getOld()); + + return records; + } + + @Override + public @NonNull String toString() { + return String.format(Locale.ENGLISH, + "localContactInserts: %d, localContactUpdates: %d, localGroupV1Inserts: %d, localGroupV1Updates: %d, localGroupV2Inserts: %d, localGroupV2Updates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, localAccountUpdate: %b, remoteInserts: %d, remoteUpdates: %d, remoteDeletes: %d", + localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localGroupV2Inserts.size(), localGroupV2Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), localAccountUpdate.isPresent(), remoteInserts.size(), remoteUpdates.size(), remoteDeletes.size()); + } + } + + public static final class WriteOperationResult { + private final SignalStorageManifest manifest; + private final List inserts; + private final List deletes; + + private WriteOperationResult(@NonNull SignalStorageManifest manifest, + @NonNull List inserts, + @NonNull List deletes) + { + this.manifest = manifest; + this.inserts = inserts; + this.deletes = deletes; + } + + public @NonNull SignalStorageManifest getManifest() { + return manifest; + } + + public @NonNull List getInserts() { + return inserts; + } + + public @NonNull List getDeletes() { + return deletes; + } + + public boolean isEmpty() { + return inserts.isEmpty() && deletes.isEmpty(); + } + + @Override + public @NonNull String toString() { + return String.format(Locale.ENGLISH, + "ManifestVersion: %d, Total Keys: %d, Inserts: %d, Deletes: %d", + manifest.getVersion(), + manifest.getStorageIds().size(), + inserts.size(), + deletes.size()); + } + } + + public static class LocalWriteResult { + private final WriteOperationResult writeResult; + private final Map storageKeyUpdates; + + private LocalWriteResult(WriteOperationResult writeResult, Map storageKeyUpdates) { + this.writeResult = writeResult; + this.storageKeyUpdates = storageKeyUpdates; + } + + public @NonNull WriteOperationResult getWriteResult() { + return writeResult; + } + + public @NonNull Map getStorageKeyUpdates() { + return storageKeyUpdates; + } + } + + public static class RecordUpdate { + private final E oldRecord; + private final E newRecord; + + RecordUpdate(@NonNull E oldRecord, @NonNull E newRecord) { + this.oldRecord = oldRecord; + this.newRecord = newRecord; + } + + public @NonNull E getOld() { + return oldRecord; + } + + public @NonNull E getNew() { + return newRecord; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RecordUpdate that = (RecordUpdate) o; + return oldRecord.equals(that.oldRecord) && + newRecord.equals(that.newRecord); + } + + @Override + public int hashCode() { + return Objects.hash(oldRecord, newRecord); + } + } + + private static class RecordMergeResult { + final Set localInserts; + final Set> localUpdates; + final Set remoteInserts; + final Set> remoteUpdates; + final Set remoteDeletes; + + RecordMergeResult(@NonNull Set localInserts, + @NonNull Set> localUpdates, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) + { + this.localInserts = localInserts; + this.localUpdates = localUpdates; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + this.remoteDeletes = remoteDeletes; + } + } + + interface ConflictMerger { + @NonNull Optional getMatching(@NonNull E record); + @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords); + @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull KeyGenerator keyGenerator); + } + + interface KeyGenerator { + @NonNull byte[] generate(); + } + + private static final class MultipleExistingAccountsException extends IllegalArgumentException {} + private static final class InvalidAccountInsertException extends IllegalArgumentException {} + private static final class InvalidAccountUpdateException extends IllegalArgumentException {} + private static final class InvalidAccountDualInsertUpdateException extends IllegalArgumentException {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java new file mode 100644 index 00000000..c59a36ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.List; + +public final class StorageSyncModels { + + private StorageSyncModels() {} + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) { + if (settings.getStorageId() == null) { + throw new AssertionError("Must have a storage key!"); + } + + return localToRemoteRecord(settings, settings.getStorageId()); + } + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] rawStorageId) { + switch (settings.getGroupType()) { + case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId)); + case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId)); + case SIGNAL_V2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId)); + default: throw new AssertionError("Unsupported type!"); + } + } + + public static AccountRecord.PhoneNumberSharingMode localToRemotePhoneNumberSharingMode(PhoneNumberPrivacyValues.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { + switch (phoneNumberPhoneNumberSharingMode) { + case EVERYONE: return AccountRecord.PhoneNumberSharingMode.EVERYBODY; + case CONTACTS: return AccountRecord.PhoneNumberSharingMode.CONTACTS_ONLY; + case NOBODY : return AccountRecord.PhoneNumberSharingMode.NOBODY; + default : throw new AssertionError(); + } + } + + public static PhoneNumberPrivacyValues.PhoneNumberSharingMode remoteToLocalPhoneNumberSharingMode(AccountRecord.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { + switch (phoneNumberPhoneNumberSharingMode) { + case EVERYBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYONE; + case CONTACTS_ONLY: return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS; + case NOBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY; + default : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS; + } + } + + public static List localToRemotePinnedConversations(@NonNull List settings) { + return Stream.of(settings) + .filter(s -> s.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V1 || + s.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 || + s.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) + .map(StorageSyncModels::localToRemotePinnedConversation) + .toList(); + } + + private static @NonNull SignalAccountRecord.PinnedConversation localToRemotePinnedConversation(@NonNull RecipientSettings settings) { + switch (settings.getGroupType()) { + case NONE : return SignalAccountRecord.PinnedConversation.forContact(new SignalServiceAddress(settings.getUuid(), settings.getE164())); + case SIGNAL_V1: return SignalAccountRecord.PinnedConversation.forGroupV1(settings.getGroupId().requireV1().getDecodedId()); + case SIGNAL_V2: return SignalAccountRecord.PinnedConversation.forGroupV2(settings.getSyncExtras().getGroupMasterKey().serialize()); + default : throw new AssertionError("Unexpected group type!"); + } + } + + private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] rawStorageId) { + if (recipient.getUuid() == null && recipient.getE164() == null) { + throw new AssertionError("Must have either a UUID or a phone number!"); + } + + return new SignalContactRecord.Builder(rawStorageId, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) + .setUnknownFields(recipient.getSyncExtras().getStorageProto()) + .setProfileKey(recipient.getProfileKey()) + .setGivenName(recipient.getProfileName().getGivenName()) + .setFamilyName(recipient.getProfileName().getFamilyName()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing() || recipient.getSystemContactUri() != null) + .setIdentityKey(recipient.getSyncExtras().getIdentityKey()) + .setIdentityState(localToRemoteIdentityState(recipient.getSyncExtras().getIdentityStatus())) + .setArchived(recipient.getSyncExtras().isArchived()) + .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) + .build(); + } + + private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] rawStorageId) { + GroupId groupId = recipient.getGroupId(); + + if (groupId == null) { + throw new AssertionError("Must have a groupId!"); + } + + if (!groupId.isV1()) { + throw new AssertionError("Group is not V1"); + } + + return new SignalGroupV1Record.Builder(rawStorageId, groupId.getDecodedId()) + .setUnknownFields(recipient.getSyncExtras().getStorageProto()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .setArchived(recipient.getSyncExtras().isArchived()) + .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) + .build(); + } + + private static @NonNull SignalGroupV2Record localToRemoteGroupV2(@NonNull RecipientSettings recipient, byte[] rawStorageId) { + GroupId groupId = recipient.getGroupId(); + + if (groupId == null) { + throw new AssertionError("Must have a groupId!"); + } + + if (!groupId.isV2()) { + throw new AssertionError("Group is not V2"); + } + + GroupMasterKey groupMasterKey = recipient.getSyncExtras().getGroupMasterKey(); + + if (groupMasterKey == null) { + throw new AssertionError("Group master key not on recipient record"); + } + + return new SignalGroupV2Record.Builder(rawStorageId, groupMasterKey) + .setUnknownFields(recipient.getSyncExtras().getStorageProto()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .setArchived(recipient.getSyncExtras().isArchived()) + .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) + .build(); + } + + public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { + switch (identityState) { + case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED; + case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED; + default: return IdentityDatabase.VerifiedStatus.DEFAULT; + } + } + + private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) { + switch (local) { + case VERIFIED: return IdentityState.VERIFIED; + case UNVERIFIED: return IdentityState.UNVERIFIED; + default: return IdentityState.DEFAULT; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java new file mode 100644 index 00000000..8ce10335 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.nio.ByteBuffer; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class StorageSyncValidations { + + private StorageSyncValidations() {} + + public static void validate(@NonNull StorageSyncHelper.WriteOperationResult result) { + validateManifestAndInserts(result.getManifest(), result.getInserts()); + + if (result.getDeletes().size() > 0) { + Set allSetEncoded = Stream.of(result.getManifest().getStorageIds()).map(StorageId::getRaw).map(Base64::encodeBytes).collect(Collectors.toSet()); + + for (byte[] delete : result.getDeletes()) { + String encoded = Base64.encodeBytes(delete); + if (allSetEncoded.contains(encoded)) { + throw new DeletePresentInFullIdSetError(); + } + } + } + } + + + public static void validateForcePush(@NonNull SignalStorageManifest manifest, @NonNull List inserts) { + validateManifestAndInserts(manifest, inserts); + } + + private static void validateManifestAndInserts(@NonNull SignalStorageManifest manifest, @NonNull List inserts) { + Set allSet = new HashSet<>(manifest.getStorageIds()); + Set insertSet = new HashSet<>(Stream.of(inserts).map(SignalStorageRecord::getId).toList()); + Set rawIdSet = Stream.of(allSet).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); + + if (allSet.size() != manifest.getStorageIds().size()) { + throw new DuplicateStorageIdError(); + } + + if (rawIdSet.size() != allSet.size()) { + throw new DuplicateRawIdError(); + } + + int accountCount = 0; + for (StorageId id : manifest.getStorageIds()) { + accountCount += id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE ? 1 : 0; + } + + if (inserts.size() > insertSet.size()) { + throw new DuplicateInsertInWriteError(); + } + + if (accountCount > 1) { + throw new MultipleAccountError(); + } + + if (accountCount == 0) { + throw new MissingAccountError(); + } + + for (SignalStorageRecord insert : inserts) { + if (!allSet.contains(insert.getId())) { + throw new InsertNotPresentInFullIdSetError(); + } + + if (insert.isUnknown()) { + throw new UnknownInsertError(); + } + + if (insert.getContact().isPresent()) { + Recipient self = Recipient.self().fresh(); + SignalServiceAddress address = insert.getContact().get().getAddress(); + if (self.getE164().get().equals(address.getNumber().or("")) || self.getUuid().get().equals(address.getUuid().orNull())) { + throw new SelfAddedAsContactError(); + } + } + } + } + + private static final class DuplicateStorageIdError extends Error { + } + + private static final class DuplicateRawIdError extends Error { + } + + private static final class DuplicateInsertInWriteError extends Error { + } + + private static final class InsertNotPresentInFullIdSetError extends Error { + } + + private static final class DeletePresentInFullIdSetError extends Error { + } + + private static final class UnknownInsertError extends Error { + } + + private static final class MultipleAccountError extends Error { + } + + private static final class MissingAccountError extends Error { + } + + private static final class SelfAddedAsContactError extends Error { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/transport/InsecureFallbackApprovalException.java b/app/src/main/java/org/thoughtcrime/securesms/transport/InsecureFallbackApprovalException.java new file mode 100644 index 00000000..15651696 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/transport/InsecureFallbackApprovalException.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.transport; + +public class InsecureFallbackApprovalException extends Exception { + public InsecureFallbackApprovalException(String detailMessage) { + super(detailMessage); + } + + public InsecureFallbackApprovalException(Throwable e) { + super(e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java new file mode 100644 index 00000000..626bbafc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.transport; + +public class RetryLaterException extends Exception { + public RetryLaterException() {} + + public RetryLaterException(Exception e) { + super(e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/transport/UndeliverableMessageException.java b/app/src/main/java/org/thoughtcrime/securesms/transport/UndeliverableMessageException.java new file mode 100644 index 00000000..02987b1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/transport/UndeliverableMessageException.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.transport; + +public class UndeliverableMessageException extends Exception { + public UndeliverableMessageException() { + } + + public UndeliverableMessageException(String detailMessage) { + super(detailMessage); + } + + public UndeliverableMessageException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public UndeliverableMessageException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java new file mode 100644 index 00000000..d1fa1110 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.database.Cursor; + +import androidx.loader.content.AsyncTaskLoader; + +import org.signal.core.util.logging.Log; + +/** + * A Loader similar to CursorLoader that doesn't require queries to go through the ContentResolver + * to get the benefits of reloading when content has changed. + */ +public abstract class AbstractCursorLoader extends AsyncTaskLoader { + + @SuppressWarnings("unused") + private static final String TAG = AbstractCursorLoader.class.getSimpleName(); + + @SuppressLint("StaticFieldLeak") + protected final Context context; + private final ForceLoadContentObserver observer; + protected Cursor cursor; + + public AbstractCursorLoader(Context context) { + super(context); + this.context = context.getApplicationContext(); + this.observer = new ForceLoadContentObserver(); + } + + public abstract Cursor getCursor(); + + @Override + public void deliverResult(Cursor newCursor) { + if (isReset()) { + if (newCursor != null) { + newCursor.close(); + } + return; + } + Cursor oldCursor = this.cursor; + + this.cursor = newCursor; + + if (isStarted()) { + super.deliverResult(newCursor); + } + if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) { + oldCursor.close(); + } + } + + @Override + protected void onStartLoading() { + if (cursor != null) { + deliverResult(cursor); + } + if (takeContentChanged() || cursor == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public void onCanceled(Cursor cursor) { + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + } + + @Override + public Cursor loadInBackground() { + long startTime = System.currentTimeMillis(); + + Cursor newCursor = getCursor(); + if (newCursor != null) { + newCursor.getCount(); + newCursor.registerContentObserver(observer); + } + + Log.d(TAG, "[" + getClass().getSimpleName() + "] Cursor load time: " + (System.currentTimeMillis() - startTime) + " ms"); + return newCursor; + } + + @Override + protected void onReset() { + super.onReset(); + + onStopLoading(); + + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + cursor = null; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java new file mode 100644 index 00000000..ebebade3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AccessibilityUtil.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.provider.Settings; + +public final class AccessibilityUtil { + + private AccessibilityUtil() { + } + + public static boolean areAnimationsDisabled(Context context) { + return Settings.Global.getFloat(context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1) == 0f; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityTransitionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityTransitionUtil.java new file mode 100644 index 00000000..60027f7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityTransitionUtil.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.util; + +import androidx.activity.ComponentActivity; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public final class ActivityTransitionUtil { + + private ActivityTransitionUtil() {} + + /** + * To be used with finish + */ + public static void setSlideOutTransition(@NonNull ComponentActivity activity) { + activity.overridePendingTransition(R.anim.slide_from_start, R.anim.slide_to_end); + } + + /** + * To be used with startActivity + */ + public static void setSlideInTransition(@NonNull ComponentActivity activity) { + activity.overridePendingTransition(R.anim.slide_from_end, R.anim.slide_to_start); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AlarmSleepTimer.java b/app/src/main/java/org/thoughtcrime/securesms/util/AlarmSleepTimer.java new file mode 100644 index 00000000..4341868d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AlarmSleepTimer.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.util; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.os.SystemClock; + +import org.signal.core.util.logging.Log; +import org.whispersystems.signalservice.api.util.SleepTimer; + +import java.util.concurrent.ConcurrentSkipListSet; + +/** + * A sleep timer that is based on elapsed realtime, so + * that it works properly, even in low-power sleep modes. + * + */ +public class AlarmSleepTimer implements SleepTimer { + private static final String TAG = AlarmSleepTimer.class.getSimpleName(); + private static ConcurrentSkipListSet actionIdList = new ConcurrentSkipListSet<>(); + + private final Context context; + + public AlarmSleepTimer(Context context) { + this.context = context; + } + + @Override + public void sleep(long millis) { + final AlarmReceiver alarmReceiver = new AlarmSleepTimer.AlarmReceiver(); + int actionId = 0; + while (!actionIdList.add(actionId)){ + actionId++; + } + try { + context.registerReceiver(alarmReceiver, + new IntentFilter(AlarmReceiver.WAKE_UP_THREAD_ACTION + "." + actionId)); + + final long startTime = System.currentTimeMillis(); + alarmReceiver.setAlarm(millis, AlarmReceiver.WAKE_UP_THREAD_ACTION + "." + actionId); + + while (System.currentTimeMillis() - startTime < millis) { + try { + synchronized (this) { + wait(millis - System.currentTimeMillis() + startTime); + } + } catch (InterruptedException e) { + Log.w(TAG, e); + } + } + context.unregisterReceiver(alarmReceiver); + } catch(Exception e) { + Log.w(TAG, "Exception during sleep ...",e); + }finally { + actionIdList.remove(actionId); + } + } + + private class AlarmReceiver extends BroadcastReceiver { + private static final String WAKE_UP_THREAD_ACTION = "org.thoughtcrime.securesms.util.AlarmSleepTimer.AlarmReceiver.WAKE_UP_THREAD"; + + private void setAlarm(long millis, String action) { + final Intent intent = new Intent(action); + final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + final AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); + + Log.w(TAG, "Setting alarm to wake up in " + millis + "ms."); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + millis, + pendingIntent); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + millis, + pendingIntent); + } else { + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + millis, + pendingIntent); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + Log.w(TAG, "Waking up."); + + synchronized (AlarmSleepTimer.this) { + AlarmSleepTimer.this.notifyAll(); + } + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AppForegroundObserver.java b/app/src/main/java/org/thoughtcrime/securesms/util/AppForegroundObserver.java new file mode 100644 index 00000000..9a9587c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AppForegroundObserver.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * A wrapper around {@link ProcessLifecycleOwner} that allows for safely adding/removing observers + * on multiple threads. + */ +public final class AppForegroundObserver { + + private final Set listeners = new CopyOnWriteArraySet<>(); + + private volatile Boolean isForegrounded = null; + + @MainThread + public void begin() { + Util.assertMainThread(); + + ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() { + @Override + public void onStart(@NonNull LifecycleOwner owner) { + onForeground(); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + onBackground(); + } + }); + } + + /** + * Adds a listener to be notified of when the app moves between the background and the foreground. + * To mimic the behavior of subscribing to {@link ProcessLifecycleOwner}, this listener will be + * immediately notified of the foreground state if we've experienced a foreground/background event + * already. + */ + @AnyThread + public void addListener(@NonNull Listener listener) { + listeners.add(listener); + + if (isForegrounded != null) { + if (isForegrounded) { + listener.onForeground(); + } else { + listener.onBackground(); + } + } + } + + @AnyThread + public void removeListener(@NonNull Listener listener) { + listeners.remove(listener); + } + + public boolean isForegrounded() { + return isForegrounded != null && isForegrounded; + } + + @MainThread + private void onForeground() { + isForegrounded = true; + + for (Listener listener : listeners) { + listener.onForeground(); + } + } + + @MainThread + private void onBackground() { + isForegrounded = false; + + for (Listener listener : listeners) { + listener.onBackground(); + } + } + + public interface Listener { + void onForeground(); + void onBackground(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AppSignatureUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AppSignatureUtil.java new file mode 100644 index 00000000..8e8578b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AppSignatureUtil.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public final class AppSignatureUtil { + + private static final String TAG = Log.tag(AppSignatureUtil.class); + + private static final String HASH_TYPE = "SHA-256"; + private static final int HASH_LENGTH_BYTES = 9; + private static final int HASH_LENGTH_CHARS = 11; + + private AppSignatureUtil() {} + + /** + * Only intended to be used for logging. + */ + @SuppressLint("PackageManagerGetSignatures") + public static Optional getAppSignature(@NonNull Context context) { + try { + String packageName = context.getPackageName(); + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); + Signature[] signatures = packageInfo.signatures; + + if (signatures.length > 0) { + String hash = hash(packageName, signatures[0].toCharsString()); + return Optional.fromNullable(hash); + } + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, e); + } + + return Optional.absent(); + } + + private static String hash(String packageName, String signature) { + String appInfo = packageName + " " + signature; + try { + MessageDigest messageDigest = MessageDigest.getInstance(HASH_TYPE); + messageDigest.update(appInfo.getBytes(StandardCharsets.UTF_8)); + + byte[] hashSignature = messageDigest.digest(); + hashSignature = Arrays.copyOfRange(hashSignature, 0, HASH_LENGTH_BYTES); + + String base64Hash = Base64.encodeBytes(hashSignature); + base64Hash = base64Hash.substring(0, HASH_LENGTH_CHARS); + + return base64Hash; + } catch (NoSuchAlgorithmException e) { + Log.w(TAG, e); + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AppStartup.java b/app/src/main/java/org/thoughtcrime/securesms/util/AppStartup.java new file mode 100644 index 00000000..55c12d12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AppStartup.java @@ -0,0 +1,185 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Application; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; + +import java.util.LinkedList; +import java.util.List; + +/** + * Manages our app startup flow. + */ +public final class AppStartup { + + /** The time to wait after Application#onCreate() to see if any UI rendering starts */ + private final long UI_WAIT_TIME = 500; + + /** The maximum amount of time we'll wait for critical rendering events to finish. */ + private final long FAILSAFE_RENDER_TIME = 2500; + + private static final String TAG = Log.tag(AppStartup.class); + + private static final AppStartup INSTANCE = new AppStartup(); + + private final List blocking; + private final List nonBlocking; + private final List postRender; + private final Handler postRenderHandler; + + private int outstandingCriticalRenderEvents; + + private long applicationStartTime; + private long renderStartTime; + private long renderEndTime; + + public static @NonNull AppStartup getInstance() { + return INSTANCE; + } + + private AppStartup() { + this.blocking = new LinkedList<>(); + this.nonBlocking = new LinkedList<>(); + this.postRender = new LinkedList<>(); + this.postRenderHandler = new Handler(Looper.getMainLooper()); + } + + public void onApplicationCreate() { + this.applicationStartTime = System.currentTimeMillis(); + } + + /** + * Schedules a task that must happen during app startup in a blocking fashion. + */ + @MainThread + public @NonNull AppStartup addBlocking(@NonNull String name, @NonNull Runnable task) { + blocking.add(new Task(name, task)); + return this; + } + + /** + * Schedules a task that should not block app startup, but should still happen as quickly as + * possible. + */ + @MainThread + public @NonNull AppStartup addNonBlocking(@NonNull Runnable task) { + nonBlocking.add(new Task("", task)); + return this; + } + + /** + * Schedules a task that should only be executed after all critical UI has been rendered. If no + * UI will be shown (i.e. the Application was created in the background), this will simply happen + * a short delay after {@link Application#onCreate()}. + * @param task + * @return + */ + @MainThread + public @NonNull AppStartup addPostRender(@NonNull Runnable task) { + postRender.add(new Task("", task)); + return this; + } + + /** + * Indicates a UI event critical to initial rendering has started. This will delay tasks that were + * scheduled via {@link #addPostRender(Runnable)}. You MUST call + * {@link #onCriticalRenderEventEnd()} for each invocation of this method. + */ + @MainThread + public void onCriticalRenderEventStart() { + if (outstandingCriticalRenderEvents == 0 && postRender.size() > 0) { + Log.i(TAG, "Received first critical render event."); + renderStartTime = System.currentTimeMillis(); + + postRenderHandler.removeCallbacksAndMessages(null); + postRenderHandler.postDelayed(() -> { + Log.w(TAG, "Reached the failsafe event for post-render! Either someone forgot to call #onRenderEnd(), the activity was started while the phone was locked, or app start is taking a very long time."); + executePostRender(); + }, FAILSAFE_RENDER_TIME); + } + + outstandingCriticalRenderEvents++; + } + + /** + * Indicates a UI event critical to initial rendering has ended. Should only be paired with + * {@link #onCriticalRenderEventStart()}. + */ + @MainThread + public void onCriticalRenderEventEnd() { + if (outstandingCriticalRenderEvents <= 0) { + Log.w(TAG, "Too many end events! onCriticalRenderEventStart/End was mismanaged."); + } + + outstandingCriticalRenderEvents = Math.max(outstandingCriticalRenderEvents - 1, 0); + + if (outstandingCriticalRenderEvents == 0 && postRender.size() > 0) { + renderEndTime = System.currentTimeMillis(); + + Log.i(TAG, "First render has finished. " + + "Cold Start: " + (renderEndTime - applicationStartTime) + " ms, " + + "Render Time: " + (renderEndTime - renderStartTime) + " ms"); + + postRenderHandler.removeCallbacksAndMessages(null); + executePostRender(); + } + } + + /** + * Begins all pending task execution. + */ + @MainThread + public void execute() { + Stopwatch stopwatch = new Stopwatch("init"); + + for (Task task : blocking) { + task.getRunnable().run(); + stopwatch.split(task.getName()); + } + blocking.clear(); + + for (Task task : nonBlocking) { + SignalExecutors.BOUNDED.execute(task.getRunnable()); + } + nonBlocking.clear(); + + stopwatch.split("schedule-non-blocking"); + stopwatch.stop(TAG); + + postRenderHandler.postDelayed(() -> { + Log.i(TAG, "Assuming the application has started in the background. Running post-render tasks."); + executePostRender(); + }, UI_WAIT_TIME); + } + + private void executePostRender() { + for (Task task : postRender) { + SignalExecutors.BOUNDED.execute(task.getRunnable()); + } + postRender.clear(); + } + + private class Task { + private final String name; + private final Runnable runnable; + + protected Task(@NonNull String name, @NonNull Runnable runnable) { + this.name = name; + this.runnable = runnable; + } + + @NonNull String getName() { + return name; + } + + public @NonNull Runnable getRunnable() { + return runnable; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java b/app/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java new file mode 100644 index 00000000..1a0f0d2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AsyncLoader.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.util; + +/* + * Copyright (C) 2011 Alexander Blom + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.content.Context; + +import androidx.loader.content.AsyncTaskLoader; + +/** + * Loader which extends AsyncTaskLoaders and handles caveats + * as pointed out in http://code.google.com/p/android/issues/detail?id=14944. + * + * Based on CursorLoader.java in the Fragment compatibility package + * + * @author Alexander Blom (me@alexanderblom.se) + * + * @param data type + */ +public abstract class AsyncLoader extends AsyncTaskLoader { + private D data; + + public AsyncLoader(Context context) { + super(context); + } + + @Override + public void deliverResult(D data) { + if (isReset()) { + // An async query came in while the loader is stopped + return; + } + + this.data = data; + + super.deliverResult(data); + } + + + @Override + protected void onStartLoading() { + if (data != null) { + deliverResult(data); + } + + if (takeContentChanged() || data == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + data = null; + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java b/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java new file mode 100644 index 00000000..f0fdcbe4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class AsynchronousCallback { + + /** + * Use to call back from a asynchronous repository call, e.g. a load operation. + *

+ * Using the original thread used for operation to invoke the callback methods. + *

+ * The contract is that exactly one method on the callback will be called, exactly once. + * + * @param Result type + * @param Error type + */ + public interface WorkerThread { + + @androidx.annotation.WorkerThread + void onComplete(@Nullable R result); + + @androidx.annotation.WorkerThread + void onError(@Nullable E error); + } + + /** + * Use to call back from a asynchronous repository call, e.g. a load operation. + *

+ * Using the main thread used for operation to invoke the callback methods. + *

+ * The contract is that exactly one method on the callback will be called, exactly once. + * + * @param Result type + * @param Error type + */ + public interface MainThread { + + @androidx.annotation.MainThread + void onComplete(@Nullable R result); + + @androidx.annotation.MainThread + void onError(@Nullable E error); + + + /** + * If you have a callback that is only suitable for running on the main thread, this will + * decorate it to make it suitable to pass as a worker thread callback. + */ + default @NonNull WorkerThread toWorkerCallback() { + return new WorkerThread() { + @Override + public void onComplete(@Nullable R result) { + Util.runOnMain(() -> MainThread.this.onComplete(result)); + } + + @Override + public void onError(@Nullable E error) { + Util.runOnMain(() -> MainThread.this.onError(error)); + } + }; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java new file mode 100644 index 00000000..b00b3565 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collections; +import java.util.Set; + +public class AttachmentUtil { + + private static final String TAG = AttachmentUtil.class.getSimpleName(); + + @WorkerThread + public static boolean isAutoDownloadPermitted(@NonNull Context context, @Nullable DatabaseAttachment attachment) { + if (attachment == null) { + Log.w(TAG, "attachment was null, returning vacuous true"); + return true; + } + + if (!isFromTrustedConversation(context, attachment)) { + return false; + } + + Set allowedTypes = getAllowedAutoDownloadTypes(context); + String contentType = attachment.getContentType(); + + if (attachment.isVoiceNote() || + (MediaUtil.isAudio(attachment) && TextUtils.isEmpty(attachment.getFileName())) || + MediaUtil.isLongTextType(attachment.getContentType()) || + attachment.isSticker()) + { + return true; + } else if (isNonDocumentType(contentType)) { + return NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType)); + } else { + return NotInCallConstraint.isNotInConnectedCall() && allowedTypes.contains("documents"); + } + } + + /** + * Deletes the specified attachment. If its the only attachment for its linked message, the entire + * message is deleted. + */ + @WorkerThread + public static void deleteAttachment(@NonNull Context context, + @NonNull DatabaseAttachment attachment) + { + AttachmentId attachmentId = attachment.getAttachmentId(); + long mmsId = attachment.getMmsId(); + int attachmentCount = DatabaseFactory.getAttachmentDatabase(context) + .getAttachmentsForMessage(mmsId) + .size(); + + if (attachmentCount <= 1) { + DatabaseFactory.getMmsDatabase(context).deleteMessage(mmsId); + } else { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId); + } + } + + private static boolean isNonDocumentType(String contentType) { + return + MediaUtil.isImageType(contentType) || + MediaUtil.isVideoType(contentType) || + MediaUtil.isAudioType(contentType); + } + + private static @NonNull Set getAllowedAutoDownloadTypes(@NonNull Context context) { + if (NetworkUtil.isConnectedWifi(context)) return TextSecurePreferences.getWifiMediaDownloadAllowed(context); + else if (NetworkUtil.isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context); + else if (NetworkUtil.isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context); + else return Collections.emptySet(); + } + + @WorkerThread + private static boolean isFromTrustedConversation(@NonNull Context context, @NonNull DatabaseAttachment attachment) { + try { + MessageRecord message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(attachment.getMmsId()); + + Recipient individualRecipient = message.getRecipient(); + Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (threadRecipient != null && threadRecipient.isGroup()) { + return threadRecipient.isProfileSharing() || isTrustedIndividual(individualRecipient, message); + } else { + return isTrustedIndividual(individualRecipient, message); + } + } catch (NoSuchMessageException e) { + Log.w(TAG, "Message could not be found! Assuming not a trusted contact."); + return false; + } + } + + private static boolean isTrustedIndividual(@NonNull Recipient recipient, @NonNull MessageRecord message) { + return recipient.isSystemContact() || + recipient.isProfileSharing() || + message.isOutgoing() || + recipient.isSelf(); + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java new file mode 100644 index 00000000..e5881e80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -0,0 +1,170 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.IconCompat; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomViewTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.concurrent.ExecutionException; + +public final class AvatarUtil { + + private AvatarUtil() { + } + + public static void loadBlurredIconIntoImageView(@NonNull Recipient recipient, @NonNull AppCompatImageView target) { + Context context = target.getContext(); + + ContactPhoto photo; + + if (recipient.isSelf()) { + photo = new ProfileContactPhoto(Recipient.self(), Recipient.self().getProfileAvatar()); + } else if (recipient.getContactPhoto() == null) { + target.setImageDrawable(null); + target.setBackgroundColor(ContextCompat.getColor(target.getContext(), R.color.black)); + return; + } else { + photo = recipient.getContactPhoto(); + } + + GlideApp.with(target) + .load(photo) + .transform(new BlurTransformation(context, 0.25f, BlurTransformation.MAX_RADIUS)) + .into(new CustomViewTarget(target) { + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + target.setImageDrawable(null); + target.setBackgroundColor(ContextCompat.getColor(target.getContext(), R.color.black)); + } + + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + target.setImageDrawable(resource); + } + + @Override + protected void onResourceCleared(@Nullable Drawable placeholder) { + target.setImageDrawable(placeholder); + } + }); + } + + public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) { + Context context = target.getContext(); + + requestCircle(GlideApp.with(context).asDrawable(), context, recipient).into(target); + } + + public static Bitmap loadIconBitmapSquareNoCache(@NonNull Context context, + @NonNull Recipient recipient, + int width, + int height) + throws ExecutionException, InterruptedException + { + return requestSquare(GlideApp.with(context).asBitmap(), context, recipient) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit(width, height) + .get(); + } + + @WorkerThread + public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) { + try { + return IconCompat.createWithBitmap(requestCircle(GlideApp.with(context).asBitmap(), context, recipient).submit().get()); + } catch (ExecutionException | InterruptedException e) { + return null; + } + } + + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + @WorkerThread + public static @NonNull Icon getIconForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + try { + return Icon.createWithAdaptiveBitmap(GlideApp.with(context).asBitmap().load(new ConversationShortcutPhoto(recipient)).submit().get()); + } catch (ExecutionException | InterruptedException e) { + throw new AssertionError("This call should not fail."); + } + } + + @WorkerThread + public static @NonNull IconCompat getIconCompatForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + try { + return IconCompat.createWithAdaptiveBitmap(GlideApp.with(context).asBitmap().load(new ConversationShortcutPhoto(recipient)).submit().get()); + } catch (ExecutionException | InterruptedException e) { + throw new AssertionError("This call should not fail."); + } + } + + @WorkerThread + public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient) { + try { + return requestCircle(GlideApp.with(context).asBitmap(), context, recipient).submit().get(); + } catch (ExecutionException | InterruptedException e) { + return null; + } + } + + private static GlideRequest requestCircle(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + return request(glideRequest, context, recipient).circleCrop(); + } + + private static GlideRequest requestSquare(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + return request(glideRequest, context, recipient).centerCrop(); + } + + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + return request(glideRequest, context, recipient, true); + } + + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient, boolean loadSelf) { + final ContactPhoto photo; + if (Recipient.self().equals(recipient) && loadSelf) { + photo = new ProfileContactPhoto(recipient, recipient.getProfileAvatar()); + } else { + photo = recipient.getContactPhoto(); + } + + return glideRequest.load(photo) + .error(getFallback(context, recipient)) + .diskCacheStrategy(DiskCacheStrategy.ALL); + } + + private static Drawable getFallback(@NonNull Context context, @NonNull Recipient recipient) { + String name = Optional.fromNullable(recipient.getDisplayName(context)).or(""); + MaterialColor fallbackColor = recipient.getColor(); + + if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { + fallbackColor = ContactColors.generateFor(name); + } + + return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor.toAvatarColor(context)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java new file mode 100644 index 00000000..d4607d15 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -0,0 +1,325 @@ +package org.thoughtcrime.securesms.util; + + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.io.File; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public class BackupUtil { + + private static final String TAG = BackupUtil.class.getSimpleName(); + + public static final int PASSPHRASE_LENGTH = 30; + + public static @NonNull String getLastBackupTime(@NonNull Context context, @NonNull Locale locale) { + try { + BackupInfo backup = getLatestBackup(); + + if (backup == null) return context.getString(R.string.BackupUtil_never); + else return DateUtils.getExtendedRelativeTimeSpanString(context, locale, backup.getTimestamp()); + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return context.getString(R.string.BackupUtil_unknown); + } + } + + public static boolean isUserSelectionRequired(@NonNull Context context) { + return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + public static boolean canUserAccessBackupDirectory(@NonNull Context context) { + if (isUserSelectionRequired(context)) { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null) { + return false; + } + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); + return backupDirectory != null && backupDirectory.exists() && backupDirectory.canRead() && backupDirectory.canWrite(); + } else { + return Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + } + + public static @Nullable BackupInfo getLatestBackup() throws NoExternalStorageException { + List backups = getAllBackupsNewestFirst(); + + return backups.isEmpty() ? null : backups.get(0); + } + + public static void deleteAllBackups() { + Log.i(TAG, "Deleting all backups"); + + try { + List backups = getAllBackupsNewestFirst(); + + for (BackupInfo backup : backups) { + backup.delete(); + } + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + } + } + + public static void deleteOldBackups() { + Log.i(TAG, "Deleting older backups"); + + try { + List backups = getAllBackupsNewestFirst(); + + for (int i = 2; i < backups.size(); i++) { + backups.get(i).delete(); + } + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + } + } + + public static void disableBackups(@NonNull Context context) { + BackupPassphrase.set(context, null); + TextSecurePreferences.setBackupEnabled(context, false); + BackupUtil.deleteAllBackups(); + + if (BackupUtil.isUserSelectionRequired(context)) { + Uri backupLocationUri = SignalStore.settings().getSignalBackupDirectory(); + + if (backupLocationUri == null) { + return; + } + + SignalStore.settings().clearSignalBackupDirectory(); + + try { + context.getContentResolver() + .releasePersistableUriPermission(Objects.requireNonNull(backupLocationUri), + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } catch (SecurityException e) { + Log.w(TAG, "Could not release permissions", e); + } + } + } + + private static List getAllBackupsNewestFirst() throws NoExternalStorageException { + if (isUserSelectionRequired(ApplicationDependencies.getApplication())) { + return getAllBackupsNewestFirstApi29(); + } else { + return getAllBackupsNewestFirstLegacy(); + } + } + + @RequiresApi(29) + private static List getAllBackupsNewestFirstApi29() { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null) { + Log.i(TAG, "Backup directory is not set. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(ApplicationDependencies.getApplication(), backupDirectoryUri); + if (backupDirectory == null || !backupDirectory.exists() || !backupDirectory.canRead()) { + Log.w(TAG, "Backup directory is inaccessible. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile[] files = backupDirectory.listFiles(); + List backups = new ArrayList<>(files.length); + + for (DocumentFile file : files) { + if (file.isFile() && file.getName() != null && file.getName().endsWith(".backup")) { + long backupTimestamp = getBackupTimestamp(file.getName()); + + if (backupTimestamp != -1) { + backups.add(new BackupInfo(backupTimestamp, file.length(), file.getUri())); + } + } + } + + Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return backups; + } + + @RequiresApi(29) + public static @Nullable BackupInfo getBackupInfoFromSingleUri(@NonNull Context context, @NonNull Uri singleUri) { + DocumentFile documentFile = DocumentFile.fromSingleUri(context, singleUri); + + if (isBackupFileReadable(documentFile)) { + long backupTimestamp = getBackupTimestamp(Objects.requireNonNull(documentFile.getName())); + return new BackupInfo(backupTimestamp, documentFile.length(), documentFile.getUri()); + } else { + Log.w(TAG, "Could not load backup info."); + return null; + } + } + + private static List getAllBackupsNewestFirstLegacy() throws NoExternalStorageException { + File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); + File[] files = backupDirectory.listFiles(); + List backups = new ArrayList<>(files.length); + + for (File file : files) { + if (file.isFile() && file.getAbsolutePath().endsWith(".backup")) { + long backupTimestamp = getBackupTimestamp(file.getName()); + + if (backupTimestamp != -1) { + backups.add(new BackupInfo(backupTimestamp, file.length(), Uri.fromFile(file))); + } + } + } + + Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return backups; + } + + public static @NonNull String[] generateBackupPassphrase() { + String[] result = new String[6]; + byte[] random = new byte[30]; + + new SecureRandom().nextBytes(random); + + for (int i=0;i<30;i+=5) { + result[i/5] = String.format("%05d", ByteUtil.byteArray5ToLong(random, i) % 100000); + } + + return result; + } + + public static boolean hasBackupFiles(@NonNull Context context) { + if (Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { + try { + File directory = StorageUtil.getBackupDirectory(); + + if (directory.exists() && directory.isDirectory()) { + File[] files = directory.listFiles(); + return files != null && files.length > 0; + } else { + return false; + } + } catch (NoExternalStorageException e) { + Log.w(TAG, "Failed to read storage!", e); + return false; + } + } else { + return false; + } + } + + private static long getBackupTimestamp(@NonNull String backupName) { + String[] prefixSuffix = backupName.split("[.]"); + + if (prefixSuffix.length == 2) { + String[] parts = prefixSuffix[0].split("\\-"); + + if (parts.length == 7) { + try { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, Integer.parseInt(parts[1])); + calendar.set(Calendar.MONTH, Integer.parseInt(parts[2]) - 1); + calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(parts[3])); + calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[4])); + calendar.set(Calendar.MINUTE, Integer.parseInt(parts[5])); + calendar.set(Calendar.SECOND, Integer.parseInt(parts[6])); + calendar.set(Calendar.MILLISECOND, 0); + + return calendar.getTimeInMillis(); + } catch (NumberFormatException e) { + Log.w(TAG, e); + } + } + } + + return -1; + } + + private static boolean isBackupFileReadable(@Nullable DocumentFile documentFile) { + if (documentFile == null) { + throw new AssertionError("We do not support platforms prior to KitKat."); + } else if (!documentFile.exists()) { + Log.w(TAG, "isBackupFileReadable: The document at the specified Uri cannot be found."); + return false; + } else if (!documentFile.canRead()) { + Log.w(TAG, "isBackupFileReadable: The document at the specified Uri cannot be read."); + return false; + } else if (TextUtils.isEmpty(documentFile.getName()) || !documentFile.getName().endsWith(".backup")) { + Log.w(TAG, "isBackupFileReadable: The document at the specified Uri has an unsupported file extension."); + return false; + } else { + Log.i(TAG, "isBackupFileReadable: The document at the specified Uri looks like a readable backup"); + return true; + } + } + + public static class BackupInfo { + + private final long timestamp; + private final long size; + private final Uri uri; + + BackupInfo(long timestamp, long size, Uri uri) { + this.timestamp = timestamp; + this.size = size; + this.uri = uri; + } + + public long getTimestamp() { + return timestamp; + } + + public long getSize() { + return size; + } + + public Uri getUri() { + return uri; + } + + private void delete() { + File file = new File(Objects.requireNonNull(uri.getPath())); + + if (file.exists()) { + Log.i(TAG, "Deleting File: " + file.getAbsolutePath()); + + if (!file.delete()) { + Log.w(TAG, "Delete failed: " + file.getAbsolutePath()); + } + } else { + DocumentFile document = DocumentFile.fromSingleUri(ApplicationDependencies.getApplication(), uri); + if (document != null && document.exists()) { + Log.i(TAG, "Deleting DocumentFile: " + uri); + + if (!document.delete()) { + Log.w(TAG, "Delete failed: " + uri); + } + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java b/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java new file mode 100644 index 00000000..2ad8aed7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.io.IOException; + +public final class Base64 { + + private Base64() { + } + + public static @NonNull byte[] decode(@NonNull String s) throws IOException { + return org.whispersystems.util.Base64.decode(s); + } + + public static @NonNull String encodeBytes(@NonNull byte[] source) { + return org.whispersystems.util.Base64.encodeBytes(source); + } + + public static @NonNull byte[] decodeOrThrow(@NonNull String s) { + try { + return org.whispersystems.util.Base64.decode(s); + } catch (IOException e) { + throw new AssertionError(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java new file mode 100644 index 00000000..c1061594 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapDecodingException.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +public class BitmapDecodingException extends Exception { + + public BitmapDecodingException(String s) { + super(s); + } + + public BitmapDecodingException(Exception nested) { + super(nested); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java new file mode 100644 index 00000000..196b9f55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -0,0 +1,475 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.exifinterface.media.ExifInterface; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.MediaConstraints; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; + +public class BitmapUtil { + + private static final String TAG = BitmapUtil.class.getSimpleName(); + + private static final int MAX_COMPRESSION_QUALITY = 90; + private static final int MIN_COMPRESSION_QUALITY = 45; + private static final int MAX_COMPRESSION_ATTEMPTS = 5; + private static final int MIN_COMPRESSION_QUALITY_DECREASE = 5; + private static final int MAX_IMAGE_HALF_SCALES = 3; + + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ + @Deprecated + @WorkerThread + public static ScaleResult createScaledBytes(@NonNull Context context, @NonNull T model, @NonNull MediaConstraints constraints) + throws BitmapDecodingException + { + return createScaledBytes(context, model, + constraints.getImageMaxWidth(context), + constraints.getImageMaxHeight(context), + constraints.getImageMaxSize(context)); + } + + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ + @WorkerThread + public static ScaleResult createScaledBytes(@NonNull Context context, + @NonNull T model, + final int maxImageWidth, + final int maxImageHeight, + final int maxImageSize) + throws BitmapDecodingException + { + return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, CompressFormat.JPEG); + } + + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ + @WorkerThread + public static ScaleResult createScaledBytes(Context context, + T model, + int maxImageWidth, + int maxImageHeight, + int maxImageSize, + @NonNull CompressFormat format) + throws BitmapDecodingException + { + return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, format, 1, 0); + } + + @WorkerThread + private static ScaleResult createScaledBytes(@NonNull Context context, + @NonNull T model, + final int maxImageWidth, + final int maxImageHeight, + final int maxImageSize, + @NonNull CompressFormat format, + final int sizeAttempt, + int totalAttempts) + throws BitmapDecodingException + { + try { + int quality = MAX_COMPRESSION_QUALITY; + int attempts = 0; + byte[] bytes; + + Bitmap scaledBitmap = GlideApp.with(context.getApplicationContext()) + .asBitmap() + .load(model) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerInside() + .submit(maxImageWidth, maxImageHeight) + .get(); + + if (scaledBitmap == null) { + throw new BitmapDecodingException("Unable to decode image"); + } + + Log.i(TAG, String.format(Locale.US,"Initial scaled bitmap has size of %d bytes.", scaledBitmap.getByteCount())); + Log.i(TAG, String.format(Locale.US, "Max dimensions %d x %d, %d bytes", maxImageWidth, maxImageHeight, maxImageSize)); + + try { + do { + totalAttempts++; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + scaledBitmap.compress(format, quality, baos); + bytes = baos.toByteArray(); + + Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes."); + if (quality == MIN_COMPRESSION_QUALITY) break; + + int nextQuality = (int)Math.floor(quality * Math.sqrt((double)maxImageSize / bytes.length)); + if (quality - nextQuality < MIN_COMPRESSION_QUALITY_DECREASE) { + nextQuality = quality - MIN_COMPRESSION_QUALITY_DECREASE; + } + quality = Math.max(nextQuality, MIN_COMPRESSION_QUALITY); + } + while (bytes.length > maxImageSize && attempts++ < MAX_COMPRESSION_ATTEMPTS); + + if (bytes.length > maxImageSize) { + if (sizeAttempt <= MAX_IMAGE_HALF_SCALES) { + scaledBitmap.recycle(); + scaledBitmap = null; + + Log.i(TAG, "Halving dimensions and retrying."); + return createScaledBytes(context, model, maxImageWidth / 2, maxImageHeight / 2, maxImageSize, format, sizeAttempt + 1, totalAttempts); + } else { + throw new BitmapDecodingException("Unable to scale image below " + bytes.length + " bytes."); + } + } + + if (bytes.length <= 0) { + throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes."); + } + + Log.i(TAG, String.format(Locale.US, "createScaledBytes(%s) -> quality %d, %d attempt(s) over %d sizes.", model.getClass().getName(), quality, totalAttempts, sizeAttempt)); + + return new ScaleResult(bytes, scaledBitmap.getWidth(), scaledBitmap.getHeight()); + } finally { + if (scaledBitmap != null) scaledBitmap.recycle(); + } + } catch (InterruptedException | ExecutionException e) { + throw new BitmapDecodingException(e); + } + } + + @WorkerThread + public static Bitmap createScaledBitmap(Context context, T model, int maxWidth, int maxHeight) + throws BitmapDecodingException + { + try { + return GlideApp.with(context.getApplicationContext()) + .asBitmap() + .load(model) + .centerInside() + .submit(maxWidth, maxHeight) + .get(); + } catch (InterruptedException | ExecutionException e) { + throw new BitmapDecodingException(e); + } + } + + @WorkerThread + public static Bitmap createScaledBitmap(Bitmap bitmap, int maxWidth, int maxHeight) { + if (bitmap.getWidth() <= maxWidth && bitmap.getHeight() <= maxHeight) { + return bitmap; + } + + if (maxWidth <= 0 || maxHeight <= 0) { + return bitmap; + } + + int newWidth = maxWidth; + int newHeight = maxHeight; + + float widthRatio = bitmap.getWidth() / (float) maxWidth; + float heightRatio = bitmap.getHeight() / (float) maxHeight; + + if (widthRatio > heightRatio) { + newHeight = (int) (bitmap.getHeight() / widthRatio); + } else { + newWidth = (int) (bitmap.getWidth() / heightRatio); + } + + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); + } + + public static @NonNull CompressFormat getCompressFormatForContentType(@Nullable String contentType) { + if (contentType == null) return CompressFormat.JPEG; + + switch (contentType) { + case MediaUtil.IMAGE_JPEG: return CompressFormat.JPEG; + case MediaUtil.IMAGE_PNG: return CompressFormat.PNG; + case MediaUtil.IMAGE_WEBP: return CompressFormat.WEBP; + default: return CompressFormat.JPEG; + } + } + + private static BitmapFactory.Options getImageDimensions(InputStream inputStream) + throws BitmapDecodingException + { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BufferedInputStream fis = new BufferedInputStream(inputStream); + BitmapFactory.decodeStream(fis, null, options); + try { + fis.close(); + } catch (IOException ioe) { + Log.w(TAG, "failed to close the InputStream after reading image dimensions"); + } + + if (options.outWidth == -1 || options.outHeight == -1) { + throw new BitmapDecodingException("Failed to decode image dimensions: " + options.outWidth + ", " + options.outHeight); + } + + return options; + } + + @Nullable + public static Pair getExifDimensions(InputStream inputStream) throws IOException { + ExifInterface exif = new ExifInterface(inputStream); + int width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0); + int height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0); + if (width == 0 || height == 0) { + return null; + } + + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 || + orientation == ExifInterface.ORIENTATION_TRANSVERSE || + orientation == ExifInterface.ORIENTATION_TRANSPOSE) + { + return new Pair<>(height, width); + } + return new Pair<>(width, height); + } + + public static Pair getDimensions(InputStream inputStream) throws BitmapDecodingException { + BitmapFactory.Options options = getImageDimensions(inputStream); + return new Pair<>(options.outWidth, options.outHeight); + } + + public static InputStream toCompressedJpeg(Bitmap bitmap) { + ByteArrayOutputStream thumbnailBytes = new ByteArrayOutputStream(); + bitmap.compress(CompressFormat.JPEG, 85, thumbnailBytes); + return new ByteArrayInputStream(thumbnailBytes.toByteArray()); + } + + public static @Nullable byte[] toByteArray(@Nullable Bitmap bitmap) { + if (bitmap == null) return null; + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + return stream.toByteArray(); + } + + public static @Nullable byte[] toWebPByteArray(@Nullable Bitmap bitmap) { + if (bitmap == null) return null; + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (Build.VERSION.SDK_INT >= 30) { + bitmap.compress(CompressFormat.WEBP_LOSSLESS, 100, stream); + } else { + bitmap.compress(CompressFormat.WEBP, 100, stream); + } + return stream.toByteArray(); + } + + public static @Nullable Bitmap fromByteArray(@Nullable byte[] bytes) { + if (bytes == null) return null; + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + } + + public static byte[] createFromNV21(@NonNull final byte[] data, + final int width, + final int height, + int rotation, + final Rect croppingRect, + final boolean flipHorizontal) + throws IOException + { + byte[] rotated = rotateNV21(data, width, height, rotation, flipHorizontal); + final int rotatedWidth = rotation % 180 > 0 ? height : width; + final int rotatedHeight = rotation % 180 > 0 ? width : height; + YuvImage previewImage = new YuvImage(rotated, ImageFormat.NV21, + rotatedWidth, rotatedHeight, null); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + previewImage.compressToJpeg(croppingRect, 80, outputStream); + byte[] bytes = outputStream.toByteArray(); + outputStream.close(); + return bytes; + } + + /* + * NV21 a.k.a. YUV420sp + * YUV 4:2:0 planar image, with 8 bit Y samples, followed by interleaved V/U plane with 8bit 2x2 + * subsampled chroma samples. + * + * http://www.fourcc.org/yuv.php#NV21 + */ + public static byte[] rotateNV21(@NonNull final byte[] yuv, + final int width, + final int height, + final int rotation, + final boolean flipHorizontal) + throws IOException + { + if (rotation == 0) return yuv; + if (rotation % 90 != 0 || rotation < 0 || rotation > 270) { + throw new IllegalArgumentException("0 <= rotation < 360, rotation % 90 == 0"); + } else if ((width * height * 3) / 2 != yuv.length) { + throw new IOException("provided width and height don't jive with the data length (" + + yuv.length + "). Width: " + width + " height: " + height + + " = data length: " + (width * height * 3) / 2); + } + + final byte[] output = new byte[yuv.length]; + final int frameSize = width * height; + final boolean swap = rotation % 180 != 0; + final boolean xflip = flipHorizontal ? rotation % 270 == 0 : rotation % 270 != 0; + final boolean yflip = rotation >= 180; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + final int yIn = j * width + i; + final int uIn = frameSize + (j >> 1) * width + (i & ~1); + final int vIn = uIn + 1; + + final int wOut = swap ? height : width; + final int hOut = swap ? width : height; + final int iSwapped = swap ? j : i; + final int jSwapped = swap ? i : j; + final int iOut = xflip ? wOut - iSwapped - 1 : iSwapped; + final int jOut = yflip ? hOut - jSwapped - 1 : jSwapped; + + final int yOut = jOut * wOut + iOut; + final int uOut = frameSize + (jOut >> 1) * wOut + (iOut & ~1); + final int vOut = uOut + 1; + + output[yOut] = (byte)(0xff & yuv[yIn]); + output[uOut] = (byte)(0xff & yuv[uIn]); + output[vOut] = (byte)(0xff & yuv[vIn]); + } + } + return output; + } + + public static Bitmap createFromDrawable(final Drawable drawable, final int width, final int height) { + final AtomicBoolean created = new AtomicBoolean(false); + final Bitmap[] result = new Bitmap[1]; + + Runnable runnable = new Runnable() { + @Override + public void run() { + if (drawable instanceof BitmapDrawable) { + result[0] = ((BitmapDrawable) drawable).getBitmap(); + } else { + int canvasWidth = drawable.getIntrinsicWidth(); + if (canvasWidth <= 0) canvasWidth = width; + + int canvasHeight = drawable.getIntrinsicHeight(); + if (canvasHeight <= 0) canvasHeight = height; + + Bitmap bitmap; + + try { + bitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + } catch (Exception e) { + Log.w(TAG, e); + bitmap = null; + } + + result[0] = bitmap; + } + + synchronized (result) { + created.set(true); + result.notifyAll(); + } + } + }; + + Util.runOnMain(runnable); + + synchronized (result) { + while (!created.get()) Util.wait(result, 0); + return result[0]; + } + } + + public static int getMaxTextureSize() { + final int MAX_ALLOWED_TEXTURE_SIZE = 2048; + + EGL10 egl = (EGL10) EGLContext.getEGL(); + EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + + int[] version = new int[2]; + egl.eglInitialize(display, version); + + int[] totalConfigurations = new int[1]; + egl.eglGetConfigs(display, null, 0, totalConfigurations); + + EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]]; + egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations); + + int[] textureSize = new int[1]; + int maximumTextureSize = 0; + + for (int i = 0; i < totalConfigurations[0]; i++) { + egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize); + + if (maximumTextureSize < textureSize[0]) + maximumTextureSize = textureSize[0]; + } + + egl.eglTerminate(display); + + return Math.min(maximumTextureSize, MAX_ALLOWED_TEXTURE_SIZE); + } + + public static class ScaleResult { + private final byte[] bitmap; + private final int width; + private final int height; + + public ScaleResult(byte[] bitmap, int width, int height) { + this.bitmap = bitmap; + this.width = width; + this.height = height; + } + + + public byte[] getBitmap() { + return bitmap; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Bitmask.java b/app/src/main/java/org/thoughtcrime/securesms/util/Bitmask.java new file mode 100644 index 00000000..17693134 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Bitmask.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.util; + +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.Locale; + +/** + * A set of utilities to make working with Bitmasks easier. + */ +public final class Bitmask { + + /** + * Reads a bitmasked boolean from a long at the requested position. + */ + public static boolean read(long value, int position) { + return read(value, position, 1) > 0; + } + + /** + * Reads a bitmasked value from a long at the requested position. + * + * @param value The value your are reading state from + * @param position The position you'd like to read from + * @param flagBitSize How many bits are in each flag + * @return The value at the requested position + */ + public static long read(long value, int position, int flagBitSize) { + Preconditions.checkArgument(flagBitSize >= 0, "Must have a positive bit size! size: " + flagBitSize); + + int bitsToShift = position * flagBitSize; + Preconditions.checkArgument(bitsToShift + flagBitSize <= 64 && position >= 0, String.format(Locale.US, "Your position is out of bounds! position: %d, flagBitSize: %d", position, flagBitSize)); + + long shifted = value >>> bitsToShift; + long mask = twoToThe(flagBitSize) - 1; + + return shifted & mask; + } + + /** + * Sets the value at the specified position in a single-bit bitmasked long. + */ + public static long update(long existing, int position, boolean value) { + return update(existing, position, 1, value ? 1 : 0); + } + + /** + * Updates the value in a bitmasked long. + * + * @param existing The existing state of the bitmask + * @param position The position you'd like to update + * @param flagBitSize How many bits are in each flag + * @param value The value you'd like to set at the specified position + * @return The updated bitmask + */ + public static long update(long existing, int position, int flagBitSize, long value) { + Preconditions.checkArgument(flagBitSize >= 0, "Must have a positive bit size! size: " + flagBitSize); + Preconditions.checkArgument(value >= 0, "Value must be positive! value: " + value); + Preconditions.checkArgument(value < twoToThe(flagBitSize), String.format(Locale.US, "Value is larger than you can hold for the given bitsize! value: %d, flagBitSize: %d", value, flagBitSize)); + + int bitsToShift = position * flagBitSize; + Preconditions.checkArgument(bitsToShift + flagBitSize <= 64 && position >= 0, String.format(Locale.US, "Your position is out of bounds! position: %d, flagBitSize: %d", position, flagBitSize)); + + long clearMask = ~((twoToThe(flagBitSize) - 1) << bitsToShift); + long cleared = existing & clearMask; + long shiftedValue = value << bitsToShift; + + return cleared | shiftedValue; + } + + /** Simple method to do 2^n. Giving it a name just so it's clear what's happening. */ + private static long twoToThe(long n) { + return 1 << n; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java b/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java new file mode 100644 index 00000000..c5f21c3c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; + +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.security.MessageDigest; +import java.util.Locale; + +public final class BlurTransformation extends BitmapTransformation { + + public static final float MAX_RADIUS = 25f; + + private final RenderScript rs; + private final float bitmapScaleFactor; + private final float blurRadius; + + public BlurTransformation(@NonNull Context context, float bitmapScaleFactor, float blurRadius) { + rs = RenderScript.create(context); + + Preconditions.checkArgument(blurRadius >= 0 && blurRadius <= 25, "Blur radius must be a non-negative value less than or equal to 25."); + Preconditions.checkArgument(bitmapScaleFactor > 0, "Bitmap scale factor must be a non-negative value"); + + this.bitmapScaleFactor = bitmapScaleFactor; + this.blurRadius = blurRadius; + } + + @Override + protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) { + Matrix scaleMatrix = new Matrix(); + scaleMatrix.setScale(bitmapScaleFactor, bitmapScaleFactor); + + Bitmap blurredBitmap = Bitmap.createBitmap(toTransform, 0, 0, outWidth, outHeight, scaleMatrix, true); + Allocation input = Allocation.createFromBitmap(rs, blurredBitmap, Allocation.MipmapControl.MIPMAP_FULL, Allocation.USAGE_SHARED); + Allocation output = Allocation.createTyped(rs, input.getType()); + ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + + script.setInput(input); + script.setRadius(blurRadius); + script.forEach(output); + output.copyTo(blurredBitmap); + + return blurredBitmap; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(String.format(Locale.US, "blur-%f-%f", bitmapScaleFactor, blurRadius).getBytes()); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BottomSheetUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BottomSheetUtil.java new file mode 100644 index 00000000..ee37b334 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BottomSheetUtil.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +public final class BottomSheetUtil { + + public static final String STANDARD_BOTTOM_SHEET_FRAGMENT_TAG = "BOTTOM"; + + private BottomSheetUtil() {} + + /** + * Show preventing a possible IllegalStateException. + */ + public static void show(@NonNull FragmentManager manager, + @Nullable String tag, + @NonNull BottomSheetDialogFragment dialog) + { + FragmentTransaction transaction = manager.beginTransaction(); + transaction.add(dialog, tag); + transaction.commitAllowingStateLoss(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java new file mode 100644 index 00000000..deab2d1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.service.notification.StatusBarNotification; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.notifications.SingleRecipientNotificationBuilder; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import static org.thoughtcrime.securesms.util.ConversationUtil.CONVERSATION_SUPPORT_VERSION; + +/** + * Bubble-related utility methods. + */ +public final class BubbleUtil { + + private static final String TAG = Log.tag(BubbleUtil.class); + + private BubbleUtil() { + } + + /** + * Checks whether we are allowed to create a bubble for the given recipient. + * + * In order to Bubble, a recipient must have a thread, be unblocked, and the user must not have + * notification privacy settings enabled. Furthermore, we check the Notifications system to verify + * that bubbles are allowed in the first place. + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + public static boolean canBubble(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable Long threadId) { + if (threadId == null) { + Log.i(TAG, "Cannot bubble recipient without thread"); + return false; + } + + NotificationPrivacyPreference privacyPreference = TextSecurePreferences.getNotificationPrivacy(context); + if (!privacyPreference.isDisplayMessage()) { + Log.i(TAG, "Bubbles are not available when notification privacy settings are enabled."); + return false; + } + + Recipient recipient = Recipient.resolved(recipientId); + if (recipient.isBlocked()) { + Log.i(TAG, "Cannot bubble blocked recipient"); + return false; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + NotificationChannel conversationChannel = notificationManager.getNotificationChannel(ConversationUtil.getChannelId(context, recipient), + ConversationUtil.getShortcutId(recipientId)); + + return notificationManager.areBubblesAllowed() || (conversationChannel != null && conversationChannel.canBubble()); + } + + /** + * Display a bubble for a given recipient's thread. + */ + public static void displayAsBubble(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + SignalExecutors.BOUNDED.execute(() -> { + if (canBubble(context, recipientId, threadId)) { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] notifications = notificationManager.getActiveNotifications(); + int threadNotificationId = NotificationIds.getNotificationIdForThread(threadId); + Notification activeThreadNotification = Stream.of(notifications) + .filter(n -> n.getId() == threadNotificationId) + .findFirst() + .map(StatusBarNotification::getNotification) + .orElse(null); + + if (activeThreadNotification != null && activeThreadNotification.deleteIntent != null) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId, BubbleState.SHOWN); + } else { + Recipient recipient = Recipient.resolved(recipientId); + SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); + + builder.addMessageBody(recipient, recipient, "", System.currentTimeMillis(), null); + builder.setThread(recipient); + builder.setDefaultBubbleState(BubbleState.SHOWN); + builder.setGroup(DefaultMessageNotifier.NOTIFICATION_GROUP); + + Log.d(TAG, "Posting Notification for requested bubble"); + notificationManager.notify(NotificationIds.getNotificationIdForThread(threadId), builder.build()); + } + } + }); + } + } + + public enum BubbleState { + SHOWN, + HIDDEN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BucketInfo.java b/app/src/main/java/org/thoughtcrime/securesms/util/BucketInfo.java new file mode 100644 index 00000000..8d5178be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BucketInfo.java @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.util; + +import android.app.usage.UsageEvents; +import android.app.usage.UsageStatsManager; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.Date; + +@RequiresApi(28) +public final class BucketInfo { + + /** + * UsageStatsManager.STANDBY_BUCKET_EXEMPTED: is a Hidden API + */ + public static final int STANDBY_BUCKET_EXEMPTED = 5; + + private final int currentBucket; + private final int worstBucket; + private final int bestBucket; + private final CharSequence history; + + private BucketInfo(int currentBucket, int worstBucket, int bestBucket, CharSequence history) { + this.currentBucket = currentBucket; + this.worstBucket = worstBucket; + this.bestBucket = bestBucket; + this.history = history; + } + + public static @NonNull BucketInfo getInfo(@NonNull UsageStatsManager usageStatsManager, long overLastDurationMs) { + StringBuilder stringBuilder = new StringBuilder(); + + int currentBucket = usageStatsManager.getAppStandbyBucket(); + int worseBucket = currentBucket; + int bestBucket = currentBucket; + + long now = System.currentTimeMillis(); + UsageEvents.Event event = new UsageEvents.Event(); + UsageEvents usageEvents = usageStatsManager.queryEventsForSelf(now - overLastDurationMs, now); + + while (usageEvents.hasNextEvent()) { + usageEvents.getNextEvent(event); + + if (event.getEventType() == UsageEvents.Event.STANDBY_BUCKET_CHANGED) { + int appStandbyBucket = event.getAppStandbyBucket(); + + stringBuilder.append(new Date(event.getTimeStamp())) + .append(": ") + .append("Bucket Change: ") + .append(bucketToString(appStandbyBucket)) + .append("\n"); + + if (appStandbyBucket > worseBucket) { + worseBucket = appStandbyBucket; + } + if (appStandbyBucket < bestBucket) { + bestBucket = appStandbyBucket; + } + } + } + + return new BucketInfo(currentBucket, worseBucket, bestBucket, stringBuilder); + } + + /** + * Not localized, for logs and debug only. + */ + public static String bucketToString(int bucket) { + switch (bucket) { + case UsageStatsManager.STANDBY_BUCKET_ACTIVE: return "Active"; + case UsageStatsManager.STANDBY_BUCKET_FREQUENT: return "Frequent"; + case UsageStatsManager.STANDBY_BUCKET_WORKING_SET: return "Working Set"; + case UsageStatsManager.STANDBY_BUCKET_RARE: return "Rare"; + case STANDBY_BUCKET_EXEMPTED: return "Exempted"; + default: return "Unknown " + bucket; + } + } + + public int getBestBucket() { + return bestBucket; + } + + public int getWorstBucket() { + return worstBucket; + } + + public int getCurrentBucket() { + return currentBucket; + } + + public CharSequence getHistory() { + return history; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java new file mode 100644 index 00000000..661d502a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.UUID; + +/** + * Logic to bucket a user for a given feature flag based on their UUID. + */ +public final class BucketingUtil { + + private BucketingUtil() {} + + /** + * Calculate a user bucket for a given feature flag, uuid, and part per modulus. + * + * @param key Feature flag key (e.g., "research.megaphone.1") + * @param uuid Current user's UUID (see {@link Recipient#getUuid()}) + * @param modulus Drives the bucketing parts per N (e.g., passing 1,000,000 indicates bucketing into parts per million) + */ + public static long bucket(@NonNull String key, @NonNull UUID uuid, long modulus) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + + digest.update(key.getBytes()); + digest.update(".".getBytes()); + + ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]); + byteBuffer.order(ByteOrder.BIG_ENDIAN); + byteBuffer.putLong(uuid.getMostSignificantBits()); + byteBuffer.putLong(uuid.getLeastSignificantBits()); + + digest.update(byteBuffer.array()); + + return new BigInteger(Arrays.copyOfRange(digest.digest(), 0, 8)).mod(BigInteger.valueOf(modulus)).longValue(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ByteUnit.java b/app/src/main/java/org/thoughtcrime/securesms/util/ByteUnit.java new file mode 100644 index 00000000..739419d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ByteUnit.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.util; + +/** + * Just like {@link java.util.concurrent.TimeUnit}, but for bytes. + */ +public enum ByteUnit { + + BYTES { + public long toBytes(long d) { return d; } + public long toKilobytes(long d) { return d/1024; } + public long toMegabytes(long d) { return toKilobytes(d)/1024; } + public long toGigabytes(long d) { return toMegabytes(d)/1024; } + }, + + KILOBYTES { + public long toBytes(long d) { return d * 1024; } + public long toKilobytes(long d) { return d; } + public long toMegabytes(long d) { return d/1024; } + public long toGigabytes(long d) { return toMegabytes(d)/1024; } + }, + + MEGABYTES { + public long toBytes(long d) { return toKilobytes(d) * 1024; } + public long toKilobytes(long d) { return d * 1024; } + public long toMegabytes(long d) { return d; } + public long toGigabytes(long d) { return d/1024; } + }, + + GIGABYTES { + public long toBytes(long d) { return toKilobytes(d) * 1024; } + public long toKilobytes(long d) { return toMegabytes(d) * 1024; } + public long toMegabytes(long d) { return d * 1024; } + public long toGigabytes(long d) { return d; } + }; + + public long toBytes(long d) { throw new AbstractMethodError(); } + public long toKilobytes(long d) { throw new AbstractMethodError(); } + public long toMegabytes(long d) { throw new AbstractMethodError(); } + public long toGigabytes(long d) { throw new AbstractMethodError(); } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CachedInflater.java b/app/src/main/java/org/thoughtcrime/securesms/util/CachedInflater.java new file mode 100644 index 00000000..73326755 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CachedInflater.java @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.LayoutRes; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.asynclayoutinflater.view.AsyncLayoutInflater; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +/** + * A class that can be used to pre-cache layouts. Usage flow: + * + * - At some point before you want to use the views, call {@link #cacheUntilLimit(int, ViewGroup, int)}. + * - Later, use {@link #inflate(int, ViewGroup, boolean)}, which will prefer using cached views + * before inflating new ones. + */ +public class CachedInflater { + + private static final String TAG = Log.tag(CachedInflater.class); + + private final Context context; + + /** + * Does *not* work with the application context. + */ + public static CachedInflater from(@NonNull Context context) { + return new CachedInflater(context); + } + + private CachedInflater(@NonNull Context context) { + this.context = context; + } + + /** + * Identical to {@link LayoutInflater#inflate(int, ViewGroup, boolean)}, but will prioritize + * pulling a cached view first. + */ + @MainThread + @SuppressWarnings("unchecked") + public V inflate(@LayoutRes int layoutRes, @Nullable ViewGroup parent, boolean attachToRoot) { + View cached = ViewCache.getInstance().pull(layoutRes, context.getResources().getConfiguration()); + if (cached != null) { + if (parent != null && attachToRoot) { + parent.addView(cached); + } + return (V) cached; + } else { + return (V) LayoutInflater.from(context).inflate(layoutRes, parent, attachToRoot); + } + } + + /** + * Will inflate as many views as necessary until the cache holds the amount you specify. + */ + @MainThread + public void cacheUntilLimit(@LayoutRes int layoutRes, @Nullable ViewGroup parent, int limit) { + ViewCache.getInstance().cacheUntilLimit(context, layoutRes, parent, limit); + } + + /** + * Clears all cached views. This should be done if, for instance, the theme changes. + */ + @MainThread + public void clear() { + Log.d(TAG, "Clearing view cache."); + ViewCache.getInstance().clear(); + } + + private static class ViewCache { + + private static final ViewCache INSTANCE = new ViewCache(); + private static final Executor ENQUEUING_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED); + + private final Map> cache = new HashMap<>(); + + private long lastClearTime; + private int nightModeConfiguration; + private float fontScale; + + static ViewCache getInstance() { + return INSTANCE; + } + + @MainThread + void cacheUntilLimit(@NonNull Context context, @LayoutRes int layoutRes, @Nullable ViewGroup parent, int limit) { + Configuration configuration = context.getResources().getConfiguration(); + int currentNightModeConfiguration = ConfigurationUtil.getNightModeConfiguration(configuration); + float currentFontScale = ConfigurationUtil.getFontScale(configuration); + + if (nightModeConfiguration != currentNightModeConfiguration || fontScale != currentFontScale) { + clear(); + nightModeConfiguration = currentNightModeConfiguration; + fontScale = currentFontScale; + } + + AsyncLayoutInflater inflater = new AsyncLayoutInflater(context); + + int existingCount = Util.getOrDefault(cache, layoutRes, Collections.emptyList()).size(); + int inflateCount = Math.max(limit - existingCount, 0); + long enqueueTime = System.currentTimeMillis(); + + // Calling AsyncLayoutInflator#inflate can block the calling thread when there's a large number of requests. + // The method is annotated @UiThread, but unnecessarily so. + ENQUEUING_EXECUTOR.execute(() -> { + if (enqueueTime < lastClearTime) { + Log.d(TAG, "Prefetch is no longer valid. Ignoring " + inflateCount + " inflates."); + return; + } + + AsyncLayoutInflater.OnInflateFinishedListener onInflateFinishedListener = (view, resId, p) -> { + Util.assertMainThread(); + if (enqueueTime < lastClearTime) { + Log.d(TAG, "Prefetch is no longer valid. Ignoring."); + return; + } + + List views = cache.get(resId); + + views = views == null ? new LinkedList<>() : views; + views.add(view); + + cache.put(resId, views); + }; + + for (int i = 0; i < inflateCount; i++) { + inflater.inflate(layoutRes, parent, onInflateFinishedListener); + } + }); + } + + @MainThread + @Nullable View pull(@LayoutRes int layoutRes, @NonNull Configuration configuration) { + if (this.nightModeConfiguration != ConfigurationUtil.getNightModeConfiguration(configuration) || this.fontScale != ConfigurationUtil.getFontScale(configuration)) { + clear(); + return null; + } + + List views = cache.get(layoutRes); + return views != null && !views.isEmpty() ? views.remove(0) + : null; + } + + @MainThread + void clear() { + lastClearTime = System.currentTimeMillis(); + cache.clear(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CalendarDateOnly.java b/app/src/main/java/org/thoughtcrime/securesms/util/CalendarDateOnly.java new file mode 100644 index 00000000..5d9fa555 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CalendarDateOnly.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.util.Calendar; + +public final class CalendarDateOnly { + + public static Calendar getInstance() { + Calendar calendar = Calendar.getInstance(); + + removeTime(calendar); + + return calendar; + } + + public static void removeTime(@NonNull Calendar calendar) { + calendar.set(Calendar.HOUR_OF_DAY, calendar.getActualMinimum(Calendar.HOUR_OF_DAY)); + calendar.set(Calendar.MINUTE, calendar.getActualMinimum(Calendar.MINUTE)); + calendar.set(Calendar.SECOND, calendar.getActualMinimum(Calendar.SECOND)); + calendar.set(Calendar.MILLISECOND, calendar.getActualMinimum(Calendar.MILLISECOND)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CensorshipUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/CensorshipUtil.java new file mode 100644 index 00000000..418ddd8d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CensorshipUtil.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; + +public final class CensorshipUtil { + + private CensorshipUtil() {} + + public static boolean isCensored(@NonNull Context context) { + return new SignalServiceNetworkAccess(context).isCensored(context); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java new file mode 100644 index 00000000..1d9fe124 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java @@ -0,0 +1,62 @@ +/** + * Copyright (C) 2015 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +public abstract class CharacterCalculator { + + public abstract CharacterState calculateCharacters(String messageBody); + + public static CharacterCalculator readFromParcel(@NonNull Parcel in) { + switch (in.readInt()) { + case 1: return new SmsCharacterCalculator(); + case 2: return new MmsCharacterCalculator(); + case 3: return new PushCharacterCalculator(); + default: throw new IllegalArgumentException("Read an unsupported value for a calculator."); + } + } + + public static void writeToParcel(@NonNull Parcel dest, @NonNull CharacterCalculator calculator) { + if (calculator instanceof SmsCharacterCalculator) { + dest.writeInt(1); + } else if (calculator instanceof MmsCharacterCalculator) { + dest.writeInt(2); + } else if (calculator instanceof PushCharacterCalculator) { + dest.writeInt(3); + } else { + throw new IllegalArgumentException("Tried to write an unsupported calculator to a parcel."); + } + } + + public static class CharacterState { + public final int charactersRemaining; + public final int messagesSpent; + public final int maxTotalMessageSize; + public final int maxPrimaryMessageSize; + + public CharacterState(int messagesSpent, int charactersRemaining, int maxTotalMessageSize, int maxPrimaryMessageSize) { + this.messagesSpent = messagesSpent; + this.charactersRemaining = charactersRemaining; + this.maxTotalMessageSize = maxTotalMessageSize; + this.maxPrimaryMessageSize = maxPrimaryMessageSize; + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CloseableLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/util/CloseableLiveData.java new file mode 100644 index 00000000..d84038cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CloseableLiveData.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util; + +import androidx.lifecycle.MutableLiveData; + +import org.signal.core.util.StreamUtil; + +import java.io.Closeable; + +/** + * Implementation of {@link androidx.lifecycle.LiveData} that will handle closing the contained + * {@link Closeable} when the value changes. + */ +public class CloseableLiveData extends MutableLiveData { + + @Override + public void setValue(E value) { + setValue(value, true); + } + + public void setValue(E value, boolean closePrevious) { + E previous = getValue(); + + if (previous != null && closePrevious) { + StreamUtil.close(previous); + } + + super.setValue(value); + } + + public void close() { + E value = getValue(); + + if (value != null) { + StreamUtil.close(value); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java new file mode 100644 index 00000000..e8767856 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -0,0 +1,292 @@ +package org.thoughtcrime.securesms.util; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.ResultReceiver; +import android.text.TextUtils; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.TaskStackBuilder; +import androidx.fragment.app.FragmentActivity; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinUpdateRequiredBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.proxy.ProxyBottomSheetFragment; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +public class CommunicationActions { + + private static final String TAG = Log.tag(CommunicationActions.class); + + public static void startVoiceCall(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { + if (TelephonyUtil.isAnyPstnLineBusy(activity)) { + Toast.makeText(activity, + R.string.CommunicationActions_a_cellular_call_is_already_in_progress, + Toast.LENGTH_SHORT) + .show(); + return; + } + + WebRtcCallService.isCallActive(activity, new ResultReceiver(new Handler(Looper.getMainLooper())) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (resultCode == 1) { + startCallInternal(activity, recipient, false); + } else { + new AlertDialog.Builder(activity) + .setMessage(R.string.CommunicationActions_start_voice_call) + .setPositiveButton(R.string.CommunicationActions_call, (d, w) -> startCallInternal(activity, recipient, false)) + .setNegativeButton(R.string.CommunicationActions_cancel, (d, w) -> d.dismiss()) + .setCancelable(true) + .show(); + } + } + }); + } + + public static void startVideoCall(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { + if (TelephonyUtil.isAnyPstnLineBusy(activity)) { + Toast.makeText(activity, + R.string.CommunicationActions_a_cellular_call_is_already_in_progress, + Toast.LENGTH_SHORT) + .show(); + return; + } + + WebRtcCallService.isCallActive(activity, new ResultReceiver(new Handler(Looper.getMainLooper())) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + startCallInternal(activity, recipient, resultCode != 1); + } + }); + } + + public static void startConversation(@NonNull Context context, @NonNull Recipient recipient, @Nullable String text) { + startConversation(context, recipient, text, null); + } + + public static void startConversation(@NonNull Context context, + @NonNull Recipient recipient, + @Nullable String text, + @Nullable TaskStackBuilder backStack) + { + new AsyncTask() { + @Override + protected Long doInBackground(Void... voids) { + return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + } + + @Override + protected void onPostExecute(Long threadId) { + ConversationIntents.Builder builder = ConversationIntents.createBuilder(context, recipient.getId(), threadId); + if (!TextUtils.isEmpty(text)) { + builder.withDraftText(text); + } + + Intent intent = builder.build(); + if (backStack != null) { + backStack.addNextIntent(intent); + backStack.startActivities(); + } else { + context.startActivity(intent); + } + } + }.execute(); + } + + public static void startInsecureCall(@NonNull Activity activity, @NonNull Recipient recipient) { + new AlertDialog.Builder(activity) + .setTitle(R.string.CommunicationActions_insecure_call) + .setMessage(R.string.CommunicationActions_carrier_charges_may_apply) + .setPositiveButton(R.string.CommunicationActions_call, (d, w) -> { + d.dismiss(); + startInsecureCallInternal(activity, recipient); + }) + .setNegativeButton(R.string.CommunicationActions_cancel, (d, w) -> d.dismiss()) + .show(); + } + + public static void composeSmsThroughDefaultApp(@NonNull Context context, @NonNull Recipient recipient, @Nullable String text) { + Intent intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:" + recipient.requireSmsAddress())); + if (text != null) { + intent.putExtra("sms_body", text); + } + context.startActivity(intent); + } + + public static void openBrowserLink(@NonNull Context context, @NonNull String link) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link)); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show(); + } + } + + public static void openEmail(@NonNull Context context, @NonNull String address, @Nullable String subject, @Nullable String body) { + Intent intent = new Intent(Intent.ACTION_SENDTO); + intent.setData(Uri.parse("mailto:")); + intent.putExtra(Intent.EXTRA_EMAIL, new String[]{ address }); + intent.putExtra(Intent.EXTRA_SUBJECT, Util.emptyIfNull(subject)); + intent.putExtra(Intent.EXTRA_TEXT, Util.emptyIfNull(body)); + + context.startActivity(Intent.createChooser(intent, context.getString(R.string.CommunicationActions_send_email))); + } + + /** + * If the url is a group link it will handle it. + * If the url is a malformed group link, it will assume Signal needs to update. + * Otherwise returns false, indicating was not a group link. + */ + public static boolean handlePotentialGroupLinkUrl(@NonNull FragmentActivity activity, @NonNull String potentialGroupLinkUrl) { + try { + GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUri(potentialGroupLinkUrl); + + if (groupInviteLinkUrl == null) { + return false; + } + + handleGroupLinkUrl(activity, groupInviteLinkUrl); + return true; + } catch (GroupInviteLinkUrl.InvalidGroupLinkException e) { + Log.w(TAG, "Could not parse group URL", e); + Toast.makeText(activity, R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_group_link_is_not_valid, Toast.LENGTH_SHORT).show(); + return true; + } catch (GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { + Log.w(TAG, "Group link is for an advanced version", e); + GroupJoinUpdateRequiredBottomSheetDialogFragment.show(activity.getSupportFragmentManager()); + return true; + } + } + + public static void handleGroupLinkUrl(@NonNull FragmentActivity activity, + @NonNull GroupInviteLinkUrl groupInviteLinkUrl) + { + GroupId.V2 groupId = GroupId.v2(groupInviteLinkUrl.getGroupMasterKey()); + + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + GroupDatabase.GroupRecord group = DatabaseFactory.getGroupDatabase(activity) + .getGroup(groupId) + .orNull(); + + return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId()) + : null; + }, + recipient -> { + if (recipient != null) { + CommunicationActions.startConversation(activity, recipient, null); + Toast.makeText(activity, R.string.GroupJoinBottomSheetDialogFragment_you_are_already_a_member, Toast.LENGTH_SHORT).show(); + } else { + GroupJoinBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), groupInviteLinkUrl); + } + }); + } + + /** + * If the url is a proxy link it will handle it. + * Otherwise returns false, indicating was not a proxy link. + */ + public static boolean handlePotentialProxyLinkUrl(@NonNull FragmentActivity activity, @NonNull String potentialProxyLinkUrl) { + String proxy = SignalProxyUtil.parseHostFromProxyDeepLink(potentialProxyLinkUrl); + + if (proxy != null) { + ProxyBottomSheetFragment.showForProxy(activity.getSupportFragmentManager(), proxy); + return true; + } else { + return false; + } + } + + private static void startInsecureCallInternal(@NonNull Activity activity, @NonNull Recipient recipient) { + try { + Intent dialIntent = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + recipient.requireSmsAddress())); + activity.startActivity(dialIntent); + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, anfe); + Dialogs.showAlertDialog(activity, + activity.getString(R.string.ConversationActivity_calls_not_supported), + activity.getString(R.string.ConversationActivity_this_device_does_not_appear_to_support_dial_actions)); + } + } + + private static void startCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient, boolean isVideo) { + if (isVideo) startVideoCallInternal(activity, recipient); + else startAudioCallInternal(activity, recipient); + } + + private static void startAudioCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { + Permissions.with(activity) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.ConversationActivity__to_call_s_signal_needs_access_to_your_microphone, recipient.getDisplayName(activity)), + R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(activity.getString(R.string.ConversationActivity__to_call_s_signal_needs_access_to_your_microphone, recipient.getDisplayName(activity))) + .onAllGranted(() -> { + Intent intent = new Intent(activity, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.AUDIO_CALL.getCode()); + activity.startService(intent); + + MessageSender.onMessageSent(); + + Intent activityIntent = new Intent(activity, WebRtcCallActivity.class); + + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + activity.startActivity(activityIntent); + }) + .execute(); + } + + private static void startVideoCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { + Permissions.with(activity) + .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.getDisplayName(activity)), + R.drawable.ic_mic_solid_24, + R.drawable.ic_video_solid_24_tinted) + .withPermanentDenialDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.getDisplayName(activity))) + .onAllGranted(() -> { + Intent intent = new Intent(activity, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_PRE_JOIN_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.VIDEO_CALL.getCode()); + activity.startService(intent); + + Intent activityIntent = new Intent(activity, WebRtcCallActivity.class); + + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true); + + activity.startActivity(activityIntent); + }) + .execute(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java new file mode 100644 index 00000000..bfd8128a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Configuration; + +import androidx.annotation.NonNull; + +public final class ConfigurationUtil { + + private ConfigurationUtil() {} + + public static int getNightModeConfiguration(@NonNull Context context) { + return getNightModeConfiguration(context.getResources().getConfiguration()); + } + + public static int getNightModeConfiguration(@NonNull Configuration configuration) { + return configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK; + } + + public static float getFontScale(@NonNull Configuration configuration) { + return configuration.fontScale; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java new file mode 100644 index 00000000..f89cf738 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import java.util.Objects; + +public final class ContextUtil { + private ContextUtil() {} + + public static @NonNull Drawable requireDrawable(@NonNull Context context, @DrawableRes int drawable) { + return Objects.requireNonNull(ContextCompat.getDrawable(context, drawable)); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java new file mode 100644 index 00000000..9ccfaaba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.ByteUtil; + +import java.security.MessageDigest; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +public final class ConversationShortcutPhoto implements Key { + + private final Recipient recipient; + private final String avatarObject; + + @WorkerThread + public ConversationShortcutPhoto(@NonNull Recipient recipient) { + this.recipient = recipient.resolve(); + this.avatarObject = Util.firstNonNull(recipient.getProfileAvatar(), ""); + + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(recipient.getDisplayName(ApplicationDependencies.getApplication()).getBytes()); + messageDigest.update(avatarObject.getBytes()); + messageDigest.update(isSystemContactPhoto() ? (byte) 1 : (byte) 0); + messageDigest.update(ByteUtil.longToByteArray(getFileLastModified())); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConversationShortcutPhoto that = (ConversationShortcutPhoto) o; + return Objects.equals(recipient, that.recipient) && + Objects.equals(avatarObject, that.avatarObject) && + isSystemContactPhoto() == that.isSystemContactPhoto() && + getFileLastModified() == that.getFileLastModified(); + } + + @Override + public int hashCode() { + return Objects.hash(recipient, avatarObject, isSystemContactPhoto(), getFileLastModified()); + } + + private boolean isSystemContactPhoto() { + return recipient.getContactPhoto() instanceof SystemContactPhoto; + } + + private long getFileLastModified() { + if (!recipient.isSelf()) { + return 0; + } + + return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId()); + } + + public static final class Loader implements ModelLoader { + + private final Context context; + + private Loader(@NonNull Context context) { + this.context = context; + } + + @Override + public @NonNull LoadData buildLoadData(@NonNull ConversationShortcutPhoto conversationShortcutPhoto, int width, int height, @NonNull Options options) { + return new LoadData<>(conversationShortcutPhoto, new Fetcher(context, conversationShortcutPhoto)); + } + + @Override + public boolean handles(@NonNull ConversationShortcutPhoto conversationShortcutPhoto) { + return true; + } + + public static class Factory implements ModelLoaderFactory { + + private final Context context; + + public Factory(@NonNull Context context) { + this.context = context; + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new Loader(context); + } + + @Override + public void teardown() { + } + } + } + + static final class Fetcher implements DataFetcher { + + private final Context context; + private final ConversationShortcutPhoto photo; + + private Fetcher(@NonNull Context context, @NonNull ConversationShortcutPhoto photo) { + this.context = context; + this.photo = photo; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + Bitmap bitmap; + + try { + bitmap = getShortcutInfoBitmap(context); + } catch (ExecutionException | InterruptedException e) { + bitmap = getFallbackForShortcut(context); + } + + callback.onDataReady(bitmap); + } + + @Override + public void cleanup() { + } + + @Override + public void cancel() { + } + + @Override + public @NonNull Class getDataClass() { + return Bitmap.class; + } + + @Override + public @NonNull DataSource getDataSource() { + return DataSource.LOCAL; + } + + private @NonNull Bitmap getShortcutInfoBitmap(@NonNull Context context) throws ExecutionException, InterruptedException { + return DrawableUtil.wrapBitmapForShortcutInfo(request(GlideApp.with(context).asBitmap(), context, false).circleCrop().submit().get()); + } + + private @NonNull Bitmap getFallbackForShortcut(@NonNull Context context) { + Recipient recipient = photo.recipient; + + @DrawableRes final int photoSource; + if (recipient.isSelf()) { + photoSource = R.drawable.ic_note_80; + } else if (recipient.isGroup()) { + photoSource = R.drawable.ic_group_80; + } else { + photoSource = R.drawable.ic_profile_80; + } + + FallbackContactPhoto photo = recipient.isSelf() || recipient.isGroup() ? new FallbackPhoto80dp(photoSource, recipient.getColor().toAvatarColor(context)) + : new ShortcutGeneratedContactPhoto(recipient.getDisplayName(context), photoSource, ViewUtil.dpToPx(80), ViewUtil.dpToPx(28)); + Bitmap toWrap = DrawableUtil.toBitmap(photo.asDrawable(context, recipient.getColor().toAvatarColor(context)), ViewUtil.dpToPx(80), ViewUtil.dpToPx(80)); + Bitmap wrapped = DrawableUtil.wrapBitmapForShortcutInfo(toWrap); + + toWrap.recycle(); + + return wrapped; + } + + private GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, boolean loadSelf) { + return glideRequest.load(photo.recipient.getContactPhoto()).diskCacheStrategy(DiskCacheStrategy.ALL); + } + } + + private static final class ShortcutGeneratedContactPhoto extends GeneratedContactPhoto { + public ShortcutGeneratedContactPhoto(@NonNull String name, int fallbackResId, int targetSize, int fontSize) { + super(name, fallbackResId, targetSize, fontSize); + } + + @Override + protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + return new FallbackPhoto80dp(getFallbackResId(), color).asDrawable(context, -1); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java new file mode 100644 index 00000000..3419cd0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java @@ -0,0 +1,269 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Person; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * ConversationUtil encapsulates support for Android 11+'s new Conversations system + */ +public final class ConversationUtil { + + public static final int CONVERSATION_SUPPORT_VERSION = 30; + + private static final String TAG = Log.tag(ConversationUtil.class); + + private ConversationUtil() {} + + + /** + * @return The stringified channel id for a given Recipient + */ + @WorkerThread + public static @NonNull String getChannelId(@NonNull Context context, @NonNull Recipient recipient) { + Recipient resolved = recipient.resolve(); + + return resolved.getNotificationChannel() != null ? resolved.getNotificationChannel() : NotificationChannels.getMessagesChannel(context); + } + + /** + * Enqueues a job to update the list of shortcuts. + */ + public static void refreshRecipientShortcuts() { + ConversationShortcutUpdateJob.enqueue(); + } + + /** + * Synchronously pushes a new dynamic shortcut for the given recipient if one does not already exist. + * + * If added, this recipient is given a high ranking with the intention of not appearing immediately in results. + */ + @WorkerThread + public static void pushShortcutForRecipientIfNeededSync(@NonNull Context context, @NonNull Recipient recipient) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + String shortcutId = getShortcutId(recipient); + List shortcuts = shortcutManager.getDynamicShortcuts(); + + boolean hasPushedRecipientShortcut = Stream.of(shortcuts) + .filter(info -> Objects.equals(shortcutId, info.getId())) + .findFirst() + .isPresent(); + + if (!hasPushedRecipientShortcut) { + pushShortcutForRecipientInternal(context, recipient, shortcuts.size()); + } + } + } + + /** + * Clears all currently set dynamic shortcuts + */ + public static void clearAllShortcuts(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + List shortcutInfos = shortcutManager.getDynamicShortcuts(); + + shortcutManager.removeLongLivedShortcuts(Stream.of(shortcutInfos).map(ShortcutInfo::getId).toList()); + } + } + + /** + * Clears the shortcuts tied to a given thread. + */ + public static void clearShortcuts(@NonNull Context context, @NonNull Set threadIds) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + SignalExecutors.BOUNDED.execute(() -> { + List recipientIds = DatabaseFactory.getThreadDatabase(context).getRecipientIdsForThreadIds(threadIds); + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + + shortcutManager.removeLongLivedShortcuts(Stream.of(recipientIds).map(ConversationUtil::getShortcutId).toList()); + }); + } + } + + /** + * Returns an ID that is unique between all recipients. + * + * @param recipientId The recipient ID to get a shortcut ID for + * + * @return A unique identifier that is stable for a given recipient id + */ + public static @NonNull String getShortcutId(@NonNull RecipientId recipientId) { + return recipientId.serialize(); + } + + /** + * Returns an ID that is unique between all recipients. + * + * @param recipient The recipient to get a shortcut for. + * + * @return A unique identifier that is stable for a given recipient id + */ + public static @NonNull String getShortcutId(@NonNull Recipient recipient) { + return getShortcutId(recipient.getId()); + } + + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + public static int getMaxShortcuts(@NonNull Context context) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + return shortcutManager.getMaxShortcutCountPerActivity(); + } + + /** + * Sets the shortcuts to match the provided recipient list. This call may fail due to getting + * rate-limited. + * + * @param rankedRecipients The recipients in descending priority order. Meaning the most important + * recipient should be first in the list. + * @return True if the update was successful, false if we were rate-limited. + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + public static boolean setActiveShortcuts(@NonNull Context context, @NonNull List rankedRecipients) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + + if (shortcutManager.isRateLimitingActive()) { + return false; + } + + int maxShortcuts = shortcutManager.getMaxShortcutCountPerActivity(); + + if (rankedRecipients.size() > maxShortcuts) { + Log.w(TAG, "Too many recipients provided! Provided: " + rankedRecipients.size() + ", Max: " + maxShortcuts); + rankedRecipients = rankedRecipients.subList(0, maxShortcuts); + } + + List shortcuts = new ArrayList<>(rankedRecipients.size()); + + for (int i = 0; i < rankedRecipients.size(); i++) { + ShortcutInfo info = buildShortcutInfo(context, rankedRecipients.get(i), i); + shortcuts.add(info); + } + + return shortcutManager.setDynamicShortcuts(shortcuts); + } + + /** + * Pushes a dynamic shortcut for a given recipient to the shortcut manager + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static void pushShortcutForRecipientInternal(@NonNull Context context, @NonNull Recipient recipient, int rank) { + ShortcutInfo shortcutInfo = buildShortcutInfo(context, recipient, rank); + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + + shortcutManager.pushDynamicShortcut(shortcutInfo); + } + + /** + * Builds the shortcut info object for a given Recipient. + * + * @param context The Context under which we are operating + * @param recipient The Recipient to generate a ShortcutInfo for + * @param rank The rank that should be assigned to this recipient + * @return The new ShortcutInfo + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull ShortcutInfo buildShortcutInfo(@NonNull Context context, + @NonNull Recipient recipient, + int rank) + { + Recipient resolved = recipient.resolve(); + Person[] persons = buildPersons(context, resolved); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(resolved); + String shortName = resolved.isSelf() ? context.getString(R.string.note_to_self) : resolved.getShortDisplayName(context); + String longName = resolved.isSelf() ? context.getString(R.string.note_to_self) : resolved.getDisplayName(context); + + return new ShortcutInfo.Builder(context, getShortcutId(resolved)) + .setLongLived(true) + .setIntent(ConversationIntents.createBuilder(context, resolved.getId(), threadId).build()) + .setShortLabel(shortName) + .setLongLabel(longName) + .setIcon(AvatarUtil.getIconForShortcut(context, resolved)) + .setPersons(persons) + .setCategories(Collections.singleton(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .setActivity(new ComponentName(context, "org.thoughtcrime.securesms.RoutingActivity")) + .setRank(rank) + .build(); + } + + /** + * @return an array of Person objects correlating to members of a conversation (other than self) + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person[] buildPersons(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.isGroup()) { + return buildPersonsForGroup(context, recipient.getGroupId().get()); + } else { + return new Person[]{buildPerson(context, recipient)}; + } + } + + /** + * @return an array of Person objects correlating to members of a group (other than self) + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person[] buildPersonsForGroup(@NonNull Context context, @NonNull GroupId groupId) { + List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + return Stream.of(members).map(member -> buildPerson(context, member.resolve())).toArray(Person[]::new); + } + + /** + * @return A Person object representing the given Recipient + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person buildPerson(@NonNull Context context, @NonNull Recipient recipient) { + return new Person.Builder() + .setKey(getShortcutId(recipient.getId())) + .setName(recipient.getDisplayName(context)) + .setUri(recipient.isSystemContact() ? recipient.getContactUri().toString() : null) + .build(); + } + + /** + * @return A Compat Library Person object representing the given Recipient + */ + @WorkerThread + public static @NonNull androidx.core.app.Person buildPersonCompat(@NonNull Context context, @NonNull Recipient recipient) { + return new androidx.core.app.Person.Builder() + .setKey(getShortcutId(recipient.getId())) + .setName(recipient.getDisplayName(context)) + .setIcon(AvatarUtil.getIconForNotification(context, recipient)) + .setUri(recipient.isSystemContact() ? recipient.getContactUri().toString() : null) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java new file mode 100644 index 00000000..83665faa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.util; + +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import org.whispersystems.libsignal.util.guava.Optional; + +public final class CursorUtil { + + private CursorUtil() {} + + public static String requireString(@NonNull Cursor cursor, @NonNull String column) { + return cursor.getString(cursor.getColumnIndexOrThrow(column)); + } + + public static int requireInt(@NonNull Cursor cursor, @NonNull String column) { + return cursor.getInt(cursor.getColumnIndexOrThrow(column)); + } + + public static float requireFloat(@NonNull Cursor cursor, @NonNull String column) { + return cursor.getFloat(cursor.getColumnIndexOrThrow(column)); + } + + public static long requireLong(@NonNull Cursor cursor, @NonNull String column) { + return cursor.getLong(cursor.getColumnIndexOrThrow(column)); + } + + public static boolean requireBoolean(@NonNull Cursor cursor, @NonNull String column) { + return requireInt(cursor, column) != 0; + } + + public static byte[] requireBlob(@NonNull Cursor cursor, @NonNull String column) { + return cursor.getBlob(cursor.getColumnIndexOrThrow(column)); + } + + public static boolean requireMaskedBoolean(@NonNull Cursor cursor, @NonNull String column, int position) { + return Bitmask.read(requireLong(cursor, column), position); + } + + public static int requireMaskedInt(@NonNull Cursor cursor, @NonNull String column, int position, int flagBitSize) { + return Util.toIntExact(Bitmask.read(requireLong(cursor, column), position, flagBitSize)); + } + + public static Optional getString(@NonNull Cursor cursor, @NonNull String column) { + if (cursor.getColumnIndex(column) < 0) { + return Optional.absent(); + } else { + return Optional.fromNullable(requireString(cursor, column)); + } + } + + public static Optional getInt(@NonNull Cursor cursor, @NonNull String column) { + if (cursor.getColumnIndex(column) < 0) { + return Optional.absent(); + } else { + return Optional.of(requireInt(cursor, column)); + } + } + + public static Optional getBoolean(@NonNull Cursor cursor, @NonNull String column) { + if (cursor.getColumnIndex(column) < 0) { + return Optional.absent(); + } else { + return Optional.of(requireBoolean(cursor, column)); + } + } + + public static Optional getBlob(@NonNull Cursor cursor, @NonNull String column) { + if (cursor.getColumnIndex(column) < 0) { + return Optional.absent(); + } else { + return Optional.fromNullable(requireBlob(cursor, column)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java new file mode 100644 index 00000000..5a892bc0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.text.format.DateFormat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; + +import java.text.DateFormatSymbols; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Utility methods to help display dates in a nice, easily readable way. + */ +public class DateUtils extends android.text.format.DateUtils { + + @SuppressWarnings("unused") + private static final String TAG = DateUtils.class.getSimpleName(); + private static final ThreadLocal DATE_FORMAT = new ThreadLocal<>(); + private static final ThreadLocal BRIEF_EXACT_FORMAT = new ThreadLocal<>(); + + private static boolean isWithin(final long millis, final long span, final TimeUnit unit) { + return System.currentTimeMillis() - millis <= unit.toMillis(span); + } + + private static boolean isWithinAbs(final long millis, final long span, final TimeUnit unit) { + return Math.abs(System.currentTimeMillis() - millis) <= unit.toMillis(span); + } + + private static boolean isYesterday(final long when) { + return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); + } + + private static int convertDelta(final long millis, TimeUnit to) { + return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); + } + + private static String getFormattedDateTime(long time, String template, Locale locale) { + final String localizedPattern = getLocalizedPattern(template, locale); + return setLowercaseAmPmStrings(new SimpleDateFormat(localizedPattern, locale), locale).format(new Date(time)); + } + + public static String getBriefRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { + if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + return c.getString(R.string.DateUtils_just_now); + } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { + int mins = convertDelta(timestamp, TimeUnit.MINUTES); + return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); + } else if (isWithin(timestamp, 1, TimeUnit.DAYS)) { + int hours = convertDelta(timestamp, TimeUnit.HOURS); + return c.getResources().getQuantityString(R.plurals.hours_ago, hours, hours); + } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { + return getFormattedDateTime(timestamp, "EEE", locale); + } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { + return getFormattedDateTime(timestamp, "MMM d", locale); + } else { + return getFormattedDateTime(timestamp, "MMM d, yyyy", locale); + } + } + + public static String getExtendedRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { + if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + return c.getString(R.string.DateUtils_just_now); + } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { + int mins = (int)TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); + return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); + } else { + StringBuilder format = new StringBuilder(); + if (isWithin(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); + else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format.append("MMM d, "); + else format.append("MMM d, yyyy, "); + + if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); + else format.append("hh:mm a"); + + return getFormattedDateTime(timestamp, format.toString(), locale); + } + } + + public static String getTimeString(final Context c, final Locale locale, final long timestamp) { + StringBuilder format = new StringBuilder(); + + if (isSameDay(System.currentTimeMillis(), timestamp)) format.append(""); + else if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); + else if (isWithinAbs(timestamp, 364, TimeUnit.DAYS)) format.append("MMM d, "); + else format.append("MMM d, yyyy, "); + + if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); + else format.append("hh:mm a"); + + return getFormattedDateTime(timestamp, format.toString(), locale); + } + + public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) { + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); + + if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) { + return context.getString(R.string.DeviceListItem_today); + } else { + String format; + + if (isWithin(timestamp, 6, TimeUnit.DAYS)) format = "EEE "; + else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format = "MMM d"; + else format = "MMM d, yyy"; + + return getFormattedDateTime(timestamp, format, locale); + } + } + + public static SimpleDateFormat getDetailedDateFormatter(Context context, Locale locale) { + String dateFormatPattern; + + if (DateFormat.is24HourFormat(context)) { + dateFormatPattern = getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale); + } else { + dateFormatPattern = getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale); + } + + return new SimpleDateFormat(dateFormatPattern, locale); + } + + public static String getRelativeDate(@NonNull Context context, + @NonNull Locale locale, + long timestamp) + { + if (isToday(timestamp)) { + return context.getString(R.string.DateUtils_today); + } else if (isYesterday(timestamp)) { + return context.getString(R.string.DateUtils_yesterday); + } else { + return formatDate(locale, timestamp); + } + } + + public static String formatDate(@NonNull Locale locale, long timestamp) { + return getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale); + } + + public static String formatDateWithoutDayOfWeek(@NonNull Locale locale, long timestamp) { + return getFormattedDateTime(timestamp, "MMM d yyyy", locale); + } + + public static boolean isSameDay(long t1, long t2) { + String d1 = getDateFormat().format(new Date(t1)); + String d2 = getDateFormat().format(new Date(t2)); + + return d1.equals(d2); + } + + public static boolean isSameExtendedRelativeTimestamp(@NonNull Context context, @NonNull Locale locale, long t1, long t2) { + return getExtendedRelativeTimeSpanString(context, locale, t1).equals(getExtendedRelativeTimeSpanString(context, locale, t2)); + } + + private static String getLocalizedPattern(String template, Locale locale) { + return DateFormat.getBestDateTimePattern(locale, template); + } + + private static @NonNull SimpleDateFormat setLowercaseAmPmStrings(@NonNull SimpleDateFormat format, @NonNull Locale locale) { + DateFormatSymbols symbols = new DateFormatSymbols(locale); + + symbols.setAmPmStrings(new String[] { "am", "pm"}); + format.setDateFormatSymbols(symbols); + + return format; + } + + /** + * e.g. 2020-09-04T19:17:51Z + * https://www.iso.org/iso-8601-date-and-time-format.html + * + * Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences. + * + * @return The timestamp if able to be parsed, otherwise -1. + */ + @SuppressLint("ObsoleteSdkInt") + public static long parseIso8601(@Nullable String date) { + SimpleDateFormat format; + if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); + } else { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); + } + + if (Util.isEmpty(date)) { + return -1; + } + + try { + return format.parse(date).getTime(); + } catch (ParseException e) { + Log.w(TAG, "Failed to parse date.", e); + return -1; + } + } + + @SuppressLint("SimpleDateFormat") + private static SimpleDateFormat getDateFormat() { + SimpleDateFormat format = DATE_FORMAT.get(); + + if (format == null) { + format = new SimpleDateFormat("yyyyMMdd"); + DATE_FORMAT.set(format); + } + + return format; + } + + @SuppressLint("SimpleDateFormat") + private static SimpleDateFormat getBriefExactFormat() { + SimpleDateFormat format = BRIEF_EXACT_FORMAT.get(); + + if (format == null) { + format = new SimpleDateFormat(); + BRIEF_EXACT_FORMAT.set(format); + } + + return format; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java new file mode 100644 index 00000000..eabd1ff9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Handler; +import android.os.Looper; + +/** + * A class that will throttle the number of runnables executed to be at most once every specified + * interval. However, it could be longer if events are published consistently. + * + * Useful for performing actions in response to rapid user input, such as inputting text, where you + * don't necessarily want to perform an action after every input. + * + * See http://rxmarbles.com/#debounce + */ +public class Debouncer { + + private final Handler handler; + private final long threshold; + + /** + * @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every + * {@code threshold} milliseconds. + */ + public Debouncer(long threshold) { + this.handler = new Handler(Looper.getMainLooper()); + this.threshold = threshold; + } + + public void publish(Runnable runnable) { + handler.removeCallbacksAndMessages(null); + handler.postDelayed(runnable, threshold); + } + + public void clear() { + handler.removeCallbacksAndMessages(null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java new file mode 100644 index 00000000..7c91e192 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.lifecycle.MutableLiveData; + +import org.whispersystems.libsignal.util.guava.Preconditions; + +/** + * Helps prevent all the @Nullable warnings when working with LiveData. + */ +public class DefaultValueLiveData extends MutableLiveData { + + private final T defaultValue; + + public DefaultValueLiveData(@NonNull T defaultValue) { + super(defaultValue); + this.defaultValue = defaultValue; + } + + @Override + public void postValue(@NonNull T value) { + Preconditions.checkNotNull(value); + super.postValue(value); + } + + @Override + public void setValue(@NonNull T value) { + Preconditions.checkNotNull(value); + super.setValue(value); + } + + @Override + public @NonNull T getValue() { + T value = super.getValue(); + return value != null ? value : defaultValue; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java b/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java new file mode 100644 index 00000000..4843ca24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; + +public class Deferred { + + private Runnable deferred; + private boolean isDeferred = true; + + public void defer(@Nullable Runnable deferred) { + this.deferred = deferred; + executeIfNecessary(); + } + + public void setDeferred(boolean isDeferred) { + this.isDeferred = isDeferred; + executeIfNecessary(); + } + + public boolean isDeferred() { + return isDeferred; + } + + private void executeIfNecessary() { + if (deferred != null && !isDeferred) { + Runnable local = deferred; + + deferred = null; + + local.run(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DelimiterUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DelimiterUtil.java new file mode 100644 index 00000000..bf27f659 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DelimiterUtil.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.util; + + +import java.util.regex.Pattern; + +public class DelimiterUtil { + + public static String escape(String value, char delimiter) { + return value.replace("" + delimiter, "\\" + delimiter); + } + + public static String unescape(String value, char delimiter) { + return value.replace("\\" + delimiter, "" + delimiter); + } + + public static String[] split(String value, char delimiter) { + if (value == null || value.length() == 0) { + return new String[0]; + } else { + String regex = "(?= FeatureFlags.animatedStickerMinimumTotalMemoryMb() || + getMemoryClass(context) >= FeatureFlags.animatedStickerMinimumMemoryClass()); + } + + public static boolean isLowMemoryDevice(@NonNull Context context) { + ActivityManager activityManager = ServiceUtil.getActivityManager(context); + return activityManager.isLowRamDevice(); + } + + public static int getMemoryClass(@NonNull Context context) { + ActivityManager activityManager = ServiceUtil.getActivityManager(context); + return activityManager.getMemoryClass(); + } + + public static @NonNull MemoryInfo getMemoryInfo(@NonNull Context context) { + MemoryInfo info = new MemoryInfo(); + ActivityManager activityManager = ServiceUtil.getActivityManager(context); + + activityManager.getMemoryInfo(info); + + return info; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Dialogs.java b/app/src/main/java/org/thoughtcrime/securesms/util/Dialogs.java new file mode 100644 index 00000000..87e7f89c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Dialogs.java @@ -0,0 +1,43 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; + +public class Dialogs { + public static void showAlertDialog(Context context, String title, String message) { + AlertDialog.Builder dialog = new AlertDialog.Builder(context); + dialog.setTitle(title); + dialog.setMessage(message); + dialog.setIcon(R.drawable.ic_warning); + dialog.setPositiveButton(android.R.string.ok, null); + dialog.show(); + } + + public static void showInfoDialog(Context context, String title, String message) { + AlertDialog.Builder dialog = new AlertDialog.Builder(context); + dialog.setTitle(title); + dialog.setMessage(message); + dialog.setIcon(R.drawable.ic_info_outline); + dialog.setPositiveButton(android.R.string.ok, null); + dialog.show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java b/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java new file mode 100644 index 00000000..ddde3b67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Set; + +/** + * Helps determine the difference between two collections based on their {@link #equals(Object)} + * implementations. + */ +public class DiffHelper { + + /** + * @return Result indicating the differences between the two collections. Important: The iteration + * order of the result will not necessarily match the iteration order of the original + * collection. + */ + public static Result calculate(@NonNull Collection oldList, @NonNull Collection newList) { + Set inserted = SetUtil.difference(newList, oldList); + Set removed = SetUtil.difference(oldList, newList); + + return new Result<>(inserted, removed); + } + + public static class Result { + private final Collection inserted; + private final Collection removed; + + public Result(@NonNull Collection inserted, @NonNull Collection removed) { + this.removed = removed; + this.inserted = inserted; + } + + public @NonNull Collection getInserted() { + return inserted; + } + + public @NonNull Collection getRemoved() { + return removed; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DisplayMetricsUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DisplayMetricsUtil.java new file mode 100644 index 00000000..5113c4d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DisplayMetricsUtil.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.util; + +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +public final class DisplayMetricsUtil { + private DisplayMetricsUtil() { + } + + public static void forceAspectRatioToScreenByAdjustingHeight(@NonNull DisplayMetrics displayMetrics, @NonNull View view) { + int screenHeight = displayMetrics.heightPixels; + int screenWidth = displayMetrics.widthPixels; + + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.height = params.width * screenHeight / screenWidth; + view.setLayoutParams(params); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java new file mode 100644 index 00000000..a34b897a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.DrawableCompat; + +public final class DrawableUtil { + + private static final int SHORTCUT_INFO_BITMAP_SIZE = ViewUtil.dpToPx(108); + private static final int SHORTCUT_INFO_WRAPPED_SIZE = ViewUtil.dpToPx(72); + private static final int SHORTCUT_INFO_PADDING = (SHORTCUT_INFO_BITMAP_SIZE - SHORTCUT_INFO_WRAPPED_SIZE) / 2; + + private DrawableUtil() {} + + public static @NonNull Bitmap toBitmap(@NonNull Drawable drawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + public static @NonNull Bitmap wrapBitmapForShortcutInfo(@NonNull Bitmap toWrap) { + Bitmap bitmap = Bitmap.createBitmap(SHORTCUT_INFO_BITMAP_SIZE, SHORTCUT_INFO_BITMAP_SIZE, Bitmap.Config.ARGB_8888); + Bitmap scaled = Bitmap.createScaledBitmap(toWrap, SHORTCUT_INFO_WRAPPED_SIZE, SHORTCUT_INFO_WRAPPED_SIZE, true); + + Canvas canvas = new Canvas(bitmap); + canvas.drawBitmap(scaled, SHORTCUT_INFO_PADDING, SHORTCUT_INFO_PADDING, null); + + return bitmap; + } + + /** + * Returns a new {@link Drawable} that safely wraps and tints the provided drawable. + */ + public static @NonNull Drawable tint(@NonNull Drawable drawable, @ColorInt int tint) { + Drawable tinted = DrawableCompat.wrap(drawable).mutate(); + DrawableCompat.setTint(tinted, tint); + return tinted; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkActionBarTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkActionBarTheme.java new file mode 100644 index 00000000..fbe77ba7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkActionBarTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicDarkActionBarTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_DarkActionBar; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkToolbarTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkToolbarTheme.java new file mode 100644 index 00000000..b66255b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicDarkToolbarTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicDarkToolbarTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_DarkNoActionBar; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicIntroTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicIntroTheme.java new file mode 100644 index 00000000..889538ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicIntroTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicIntroTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_IntroTheme; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicLanguage.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicLanguage.java new file mode 100644 index 00000000..2909e84c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicLanguage.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.app.Service; +import android.content.Context; +import android.content.res.Configuration; + +import org.thoughtcrime.securesms.util.dynamiclanguage.LanguageString; + +import java.util.Locale; + +/** + * @deprecated Use a base activity that uses the {@link org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper} + */ +@Deprecated +public class DynamicLanguage { + + public void onCreate(Activity activity) { + } + + public void onResume(Activity activity) { + } + + public void updateServiceLocale(Service service) { + setContextLocale(service, getSelectedLocale(service)); + } + + public Locale getCurrentLocale() { + return Locale.getDefault(); + } + + static int getLayoutDirection(Context context) { + Configuration configuration = context.getResources().getConfiguration(); + return configuration.getLayoutDirection(); + } + + private static void setContextLocale(Context context, Locale selectedLocale) { + Configuration configuration = context.getResources().getConfiguration(); + + if (!configuration.locale.equals(selectedLocale)) { + configuration.setLocale(selectedLocale); + context.getResources().updateConfiguration(configuration, + context.getResources().getDisplayMetrics()); + } + } + + private static Locale getSelectedLocale(Context context) { + Locale locale = LanguageString.parseLocale(TextSecurePreferences.getLanguage(context)); + if (locale == null) { + return Locale.getDefault(); + } else { + return locale; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarInviteTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarInviteTheme.java new file mode 100644 index 00000000..c5999d5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarInviteTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicNoActionBarInviteTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_Invite; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java new file mode 100644 index 00000000..53e27e72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicNoActionBarTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicNoActionBarTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_NoActionBar; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicRegistrationTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicRegistrationTheme.java new file mode 100644 index 00000000..f5d5f07b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicRegistrationTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicRegistrationTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_Registration; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java new file mode 100644 index 00000000..4dfa1b16 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicTheme.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AppCompatDelegate; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; + +public class DynamicTheme { + + private static final String TAG = Log.tag(DynamicTheme.class); + + public static final String DARK = "dark"; + public static final String LIGHT = "light"; + public static final String SYSTEM = "system"; + + private static int globalNightModeConfiguration; + + private int onCreateNightModeConfiguration; + + public void onCreate(@NonNull Activity activity) { + int previousGlobalConfiguration = globalNightModeConfiguration; + + onCreateNightModeConfiguration = ConfigurationUtil.getNightModeConfiguration(activity); + globalNightModeConfiguration = onCreateNightModeConfiguration; + + activity.setTheme(getTheme()); + + if (previousGlobalConfiguration != globalNightModeConfiguration) { + Log.d(TAG, "Previous night mode has changed previous: " + previousGlobalConfiguration + " now: " + globalNightModeConfiguration); + CachedInflater.from(activity).clear(); + } + } + + public void onResume(@NonNull Activity activity) { + if (onCreateNightModeConfiguration != ConfigurationUtil.getNightModeConfiguration(activity)) { + Log.d(TAG, "Create configuration different from current previous: " + onCreateNightModeConfiguration + " now: " + ConfigurationUtil.getNightModeConfiguration(activity)); + CachedInflater.from(activity).clear(); + } + } + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight; + } + + public static boolean systemThemeAvailable() { + return Build.VERSION.SDK_INT >= 29; + } + + public static void setDefaultDayNightMode(@NonNull Context context) { + String theme = TextSecurePreferences.getTheme(context); + + if (theme.equals(SYSTEM)) { + Log.d(TAG, "Setting to follow system expecting: " + ConfigurationUtil.getNightModeConfiguration(context.getApplicationContext())); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } else if (DynamicTheme.isDarkTheme(context)) { + Log.d(TAG, "Setting to always night"); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + } else { + Log.d(TAG, "Setting to always day"); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } + + CachedInflater.from(context).clear(); + } + + /** + * Takes the system theme into account. + */ + public static boolean isDarkTheme(@NonNull Context context) { + String theme = TextSecurePreferences.getTheme(context); + + if (theme.equals(SYSTEM) && systemThemeAvailable()) { + return isSystemInDarkTheme(context); + } else { + return theme.equals(DARK); + } + } + + private static boolean isSystemInDarkTheme(@NonNull Context context) { + return (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EarlyMessageCache.java b/app/src/main/java/org/thoughtcrime/securesms/util/EarlyMessageCache.java new file mode 100644 index 00000000..c479479c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EarlyMessageCache.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +/** + * Sometimes a message that is referencing another message can arrive out of order. In these cases, + * we want to temporarily hold on (i.e. keep a memory cache) to these messages and apply them after + * we receive the referenced message. + */ +public final class EarlyMessageCache { + + private final LRUCache> cache = new LRUCache<>(100); + + /** + * @param targetSender The sender of the message this message depends on. + * @param targetSentTimestamp The sent timestamp of the message this message depends on. + */ + public void store(@NonNull RecipientId targetSender, long targetSentTimestamp, @NonNull SignalServiceContent content) { + MessageId messageId = new MessageId(targetSender, targetSentTimestamp); + List contentList = cache.get(messageId); + + if (contentList == null) { + contentList = new LinkedList<>(); + } + + contentList.add(content); + + cache.put(messageId, contentList); + } + + /** + * Returns and removes any content that is dependent on the provided message id. + * @param sender The sender of the message in question. + * @param sentTimestamp The sent timestamp of the message in question. + */ + public Optional> retrieve(@NonNull RecipientId sender, long sentTimestamp) { + return Optional.fromNullable(cache.remove(new MessageId(sender, sentTimestamp))); + } + + private static final class MessageId { + private final RecipientId sender; + private final long sentTimestamp; + + private MessageId(@NonNull RecipientId sender, long sentTimestamp) { + this.sender = sender; + this.sentTimestamp = sentTimestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MessageId messageId = (MessageId) o; + return sentTimestamp == messageId.sentTimestamp && + Objects.equals(sender, messageId.sender); + } + + @Override + public int hashCode() { + return Objects.hash(sentTimestamp, sender); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java new file mode 100644 index 00000000..843f80a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +public class EllapsedTimeFormatter { + private final long hours; + private final long minutes; + private final long seconds; + + private EllapsedTimeFormatter(long durationMillis) { + hours = durationMillis / 3600; + minutes = durationMillis % 3600 / 60; + seconds = durationMillis % 3600 % 60; + } + + @Override + public @NonNull String toString() { + if (hours > 0) { + return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds); + } else { + return String.format(Locale.US, "%02d:%02d", minutes, seconds); + } + } + + public static @Nullable EllapsedTimeFormatter fromDurationMillis(long durationMillis) { + if (durationMillis == -1) { + return null; + } + + return new EllapsedTimeFormatter(durationMillis); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationUtil.java new file mode 100644 index 00000000..e6cfb8b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationUtil.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +import java.util.concurrent.TimeUnit; + +public final class ExpirationUtil { + + private static final int SECONDS_IN_WEEK = (int) TimeUnit.DAYS.toSeconds(7); + private static final int SECONDS_IN_DAY = (int) TimeUnit.DAYS.toSeconds(1); + private static final int SECONDS_IN_HOUR = (int) TimeUnit.HOURS.toSeconds(1); + private static final int SECONDS_IN_MINUTE = (int) TimeUnit.MINUTES.toSeconds(1); + + public static String getExpirationDisplayValue(Context context, int expirationTime) { + if (expirationTime <= 0) { + return context.getString(R.string.expiration_off); + } + + String displayValue = ""; + + int secondsRemaining = expirationTime; + + int weeks = secondsRemaining / SECONDS_IN_WEEK; + displayValue = getDisplayValue(context, displayValue, R.plurals.expiration_weeks, weeks); + secondsRemaining = secondsRemaining - weeks * SECONDS_IN_WEEK; + + int days = secondsRemaining / SECONDS_IN_DAY; + displayValue = getDisplayValue(context, displayValue, R.plurals.expiration_days, days); + secondsRemaining = secondsRemaining - days * SECONDS_IN_DAY; + + int hours = secondsRemaining / SECONDS_IN_HOUR; + displayValue = getDisplayValue(context, displayValue, R.plurals.expiration_hours, hours); + secondsRemaining = secondsRemaining - hours * SECONDS_IN_HOUR; + + int minutes = secondsRemaining / SECONDS_IN_MINUTE; + displayValue = getDisplayValue(context, displayValue, R.plurals.expiration_minutes, minutes); + secondsRemaining = secondsRemaining - minutes * SECONDS_IN_MINUTE; + + displayValue = getDisplayValue(context, displayValue, R.plurals.expiration_seconds, secondsRemaining); + + return displayValue; + } + + private static String getDisplayValue(Context context, String currentValue, @PluralsRes int plurals, int duration) { + if (duration > 0) { + String durationString = context.getResources().getQuantityString(plurals, duration, duration); + if (currentValue.isEmpty()) { + return durationString; + } else { + return context.getString(R.string.expiration_combined, currentValue, durationString); + } + } + return currentValue; + } + + public static String getExpirationAbbreviatedDisplayValue(Context context, int expirationTime) { + if (expirationTime <= 0) { + return context.getString(R.string.expiration_off); + } + + String displayValue = ""; + + int secondsRemaining = expirationTime; + + int weeks = secondsRemaining / SECONDS_IN_WEEK; + displayValue = getAbbreviatedDisplayValue(context, displayValue, R.string.expiration_weeks_abbreviated, weeks); + secondsRemaining = secondsRemaining - weeks * SECONDS_IN_WEEK; + + int days = secondsRemaining / SECONDS_IN_DAY; + displayValue = getAbbreviatedDisplayValue(context, displayValue, R.string.expiration_days_abbreviated, days); + secondsRemaining = secondsRemaining - days * SECONDS_IN_DAY; + + int hours = secondsRemaining / SECONDS_IN_HOUR; + displayValue = getAbbreviatedDisplayValue(context, displayValue, R.string.expiration_hours_abbreviated, hours); + secondsRemaining = secondsRemaining - hours * SECONDS_IN_HOUR; + + int minutes = secondsRemaining / SECONDS_IN_MINUTE; + displayValue = getAbbreviatedDisplayValue(context, displayValue, R.string.expiration_minutes_abbreviated, minutes); + secondsRemaining = secondsRemaining - minutes * SECONDS_IN_MINUTE; + + displayValue = getAbbreviatedDisplayValue(context, displayValue, R.string.expiration_seconds_abbreviated, secondsRemaining); + + return displayValue; + } + + private static String getAbbreviatedDisplayValue(Context context, String currentValue, @StringRes int abbreviation, int duration) { + if (duration > 0) { + String durationString = context.getString(abbreviation, duration); + if (currentValue.isEmpty()) { + return durationString; + } else { + return context.getString(R.string.expiration_combined, currentValue, durationString); + } + } + return currentValue; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java new file mode 100644 index 00000000..8d01218f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -0,0 +1,611 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Build; +import android.text.TextUtils; +import android.util.TimeUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; + +import org.json.JSONException; +import org.json.JSONObject; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + +/** + * A location for flags that can be set locally and remotely. These flags can guard features that + * are not yet ready to be activated. + * + * When creating a new flag: + * - Create a new string constant. This should almost certainly be prefixed with "android." + * - Add a method to retrieve the value using {@link #getBoolean(String, boolean)}. You can also add + * other checks here, like requiring other flags. + * - If you want to be able to change a flag remotely, place it in {@link #REMOTE_CAPABLE}. + * - If you would like to force a value for testing, place an entry in {@link #FORCED_VALUES}. + * Do not commit changes to this map! + * + * Other interesting things you can do: + * - Make a flag {@link #HOT_SWAPPABLE} + * - Make a flag {@link #STICKY} -- booleans only! + * - Register a listener for flag changes in {@link #FLAG_CHANGE_LISTENERS} + */ +public final class FeatureFlags { + + private static final String TAG = Log.tag(FeatureFlags.class); + + private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); + + private static final String USERNAMES = "android.usernames"; + private static final String GROUPS_V2_RECOMMENDED_LIMIT = "global.groupsv2.maxGroupSize"; + private static final String GROUPS_V2_HARD_LIMIT = "global.groupsv2.groupSizeHardLimit"; + private static final String GROUP_NAME_MAX_LENGTH = "global.groupsv2.maxNameLength"; + private static final String INTERNAL_USER = "android.internalUser"; + private static final String VERIFY_V2 = "android.verifyV2"; + private static final String PHONE_NUMBER_PRIVACY_VERSION = "android.phoneNumberPrivacyVersion"; + private static final String CLIENT_EXPIRATION = "android.clientExpiration"; + public static final String DONATE_MEGAPHONE = "android.donate"; + private static final String VIEWED_RECEIPTS = "android.viewed.receipts"; + private static final String GROUP_CALLING = "android.groupsv2.calling.2"; + private static final String GV1_FORCED_MIGRATE = "android.groupsV1Migration.forced"; + private static final String SEND_VIEWED_RECEIPTS = "android.sendViewedReceipts"; + private static final String CUSTOM_VIDEO_MUXER = "android.customVideoMuxer"; + private static final String CDS_REFRESH_INTERVAL = "cds.syncInterval.seconds"; + private static final String AUTOMATIC_SESSION_RESET = "android.automaticSessionReset.2"; + private static final String AUTOMATIC_SESSION_INTERVAL = "android.automaticSessionResetInterval"; + private static final String DEFAULT_MAX_BACKOFF = "android.defaultMaxBackoff"; + private static final String SERVER_ERROR_MAX_BACKOFF = "android.serverErrorMaxBackoff"; + private static final String OKHTTP_AUTOMATIC_RETRY = "android.okhttpAutomaticRetry"; + private static final String SHARE_SELECTION_LIMIT = "android.share.limit"; + private static final String ANIMATED_STICKER_MIN_MEMORY = "android.animatedStickerMinMemory"; + private static final String ANIMATED_STICKER_MIN_TOTAL_MEMORY = "android.animatedStickerMinTotalMemory"; + private static final String MESSAGE_PROCESSOR_ALARM_INTERVAL = "android.messageProcessor.alarmIntervalMins"; + + /** + * We will only store remote values for flags in this set. If you want a flag to be controllable + * remotely, place it in here. + */ + @VisibleForTesting + static final Set REMOTE_CAPABLE = SetUtil.newHashSet( + GROUPS_V2_RECOMMENDED_LIMIT, + GROUPS_V2_HARD_LIMIT, + INTERNAL_USER, + USERNAMES, + VERIFY_V2, + CLIENT_EXPIRATION, + DONATE_MEGAPHONE, + VIEWED_RECEIPTS, + GV1_FORCED_MIGRATE, + GROUP_CALLING, + SEND_VIEWED_RECEIPTS, + CUSTOM_VIDEO_MUXER, + CDS_REFRESH_INTERVAL, + GROUP_NAME_MAX_LENGTH, + AUTOMATIC_SESSION_RESET, + AUTOMATIC_SESSION_INTERVAL, + DEFAULT_MAX_BACKOFF, + SERVER_ERROR_MAX_BACKOFF, + OKHTTP_AUTOMATIC_RETRY, + SHARE_SELECTION_LIMIT, + ANIMATED_STICKER_MIN_MEMORY, + ANIMATED_STICKER_MIN_TOTAL_MEMORY, + MESSAGE_PROCESSOR_ALARM_INTERVAL + ); + + @VisibleForTesting + static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( + PHONE_NUMBER_PRIVACY_VERSION + ); + + /** + * Values in this map will take precedence over any value. This should only be used for local + * development. Given that you specify a default when retrieving a value, and that we only store + * remote values for things in {@link #REMOTE_CAPABLE}, there should be no need to ever *commit* + * an addition to this map. + */ + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + @VisibleForTesting + static final Map FORCED_VALUES = new HashMap() {{ + }}; + + /** + * By default, flags are only updated once at app start. This is to ensure that values don't + * change within an app session, simplifying logic. However, given that this can delay how often + * a flag is updated, you can put a flag in here to mark it as 'hot swappable'. Flags in this set + * will be updated arbitrarily at runtime. This will make values more responsive, but also places + * more burden on the reader to ensure that the app experience remains consistent. + */ + @VisibleForTesting + static final Set HOT_SWAPPABLE = SetUtil.newHashSet( + VERIFY_V2, + CLIENT_EXPIRATION, + GROUP_CALLING, + CUSTOM_VIDEO_MUXER, + CDS_REFRESH_INTERVAL, + GROUP_NAME_MAX_LENGTH, + AUTOMATIC_SESSION_RESET, + AUTOMATIC_SESSION_INTERVAL, + DEFAULT_MAX_BACKOFF, + SERVER_ERROR_MAX_BACKOFF, + OKHTTP_AUTOMATIC_RETRY, + SHARE_SELECTION_LIMIT, + ANIMATED_STICKER_MIN_MEMORY, + ANIMATED_STICKER_MIN_TOTAL_MEMORY, + MESSAGE_PROCESSOR_ALARM_INTERVAL + ); + + /** + * Flags in this set will stay true forever once they receive a true value from a remote config. + */ + @VisibleForTesting + static final Set STICKY = SetUtil.newHashSet( + VERIFY_V2 + ); + + /** + * Listeners that are called when the value in {@link #REMOTE_VALUES} changes. That means that + * hot-swappable flags will have this invoked as soon as we know about that change, but otherwise + * these will only run during initialization. + * + * These can be called on any thread, including the main thread, so be careful! + * + * Also note that this doesn't play well with {@link #FORCED_VALUES} -- changes there will not + * trigger changes in this map, so you'll have to do some manually hacking to get yourself in the + * desired test state. + */ + private static final Map FLAG_CHANGE_LISTENERS = new HashMap() {{ + put(MESSAGE_PROCESSOR_ALARM_INTERVAL, change -> MessageProcessReceiver.startOrUpdateAlarm(ApplicationDependencies.getApplication())); + }}; + + private static final Map REMOTE_VALUES = new TreeMap<>(); + + private FeatureFlags() {} + + public static synchronized void init() { + Map current = parseStoredConfig(SignalStore.remoteConfigValues().getCurrentConfig()); + Map pending = parseStoredConfig(SignalStore.remoteConfigValues().getPendingConfig()); + Map changes = computeChanges(current, pending); + + SignalStore.remoteConfigValues().setCurrentConfig(mapToJson(pending)); + REMOTE_VALUES.putAll(pending); + triggerFlagChangeListeners(changes); + + Log.i(TAG, "init() " + REMOTE_VALUES.toString()); + } + + public static synchronized void refreshIfNecessary() { + long timeSinceLastFetch = System.currentTimeMillis() - SignalStore.remoteConfigValues().getLastFetchTime(); + + if (timeSinceLastFetch < 0 || timeSinceLastFetch > FETCH_INTERVAL) { + Log.i(TAG, "Scheduling remote config refresh."); + ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); + } else { + Log.i(TAG, "Skipping remote config refresh. Refreshed " + timeSinceLastFetch + " ms ago."); + } + } + + public static synchronized void update(@NonNull Map config) { + Map memory = REMOTE_VALUES; + Map disk = parseStoredConfig(SignalStore.remoteConfigValues().getPendingConfig()); + UpdateResult result = updateInternal(config, memory, disk, REMOTE_CAPABLE, HOT_SWAPPABLE, STICKY); + + SignalStore.remoteConfigValues().setPendingConfig(mapToJson(result.getDisk())); + REMOTE_VALUES.clear(); + REMOTE_VALUES.putAll(result.getMemory()); + triggerFlagChangeListeners(result.getMemoryChanges()); + + SignalStore.remoteConfigValues().setLastFetchTime(System.currentTimeMillis()); + + Log.i(TAG, "[Memory] Before: " + memory.toString()); + Log.i(TAG, "[Memory] After : " + result.getMemory().toString()); + Log.i(TAG, "[Disk] Before: " + disk.toString()); + Log.i(TAG, "[Disk] After : " + result.getDisk().toString()); + } + + /** Creating usernames, sending messages by username. */ + public static synchronized boolean usernames() { + return getBoolean(USERNAMES, false); + } + + /** + * Maximum number of members allowed in a group. + */ + public static SelectionLimits groupLimits() { + return new SelectionLimits(getInteger(GROUPS_V2_RECOMMENDED_LIMIT, 151), + getInteger(GROUPS_V2_HARD_LIMIT, 1001)); + } + + /** Internal testing extensions. */ + public static boolean internalUser() { + return getBoolean(INTERNAL_USER, false); + } + + /** Whether or not to use the UUID in verification codes. */ + public static boolean verifyV2() { + return getBoolean(VERIFY_V2, false); + } + + /** The raw client expiration JSON string. */ + public static String clientExpiration() { + return getString(CLIENT_EXPIRATION, null); + } + + /** The raw donate megaphone CSV string */ + public static String donateMegaphone() { + return getString(DONATE_MEGAPHONE, ""); + } + + /** + * Whether the user can choose phone number privacy settings, and; + * Whether to fetch and store the secondary certificate + */ + public static boolean phoneNumberPrivacy() { + return getVersionFlag(PHONE_NUMBER_PRIVACY_VERSION) == VersionFlag.ON; + } + + /** Whether the user should display the content revealed dot in voice notes. */ + public static boolean viewedReceipts() { + return getBoolean(VIEWED_RECEIPTS, false); + } + + /** Whether or not group calling is enabled. */ + public static boolean groupCalling() { + return Build.VERSION.SDK_INT > 19 && getBoolean(GROUP_CALLING, false); + } + + /** Whether or not forced migration from GV1->GV2 is enabled. */ + public static boolean groupsV1ForcedMigration() { + return getBoolean(GV1_FORCED_MIGRATE, false); + } + + /** Whether or not to send viewed receipts. */ + public static boolean sendViewedReceipts() { + return getBoolean(SEND_VIEWED_RECEIPTS, false); + } + + /** Whether to use the custom streaming muxer or built in android muxer. */ + public static boolean useStreamingVideoMuxer() { + return getBoolean(CUSTOM_VIDEO_MUXER, false); + } + + /** The time in between routine CDS refreshes, in seconds. */ + public static int cdsRefreshIntervalSeconds() { + return getInteger(CDS_REFRESH_INTERVAL, (int) TimeUnit.HOURS.toSeconds(48)); + } + + public static @NonNull SelectionLimits shareSelectionLimit() { + int limit = getInteger(SHARE_SELECTION_LIMIT, 5); + return new SelectionLimits(limit, limit); + } + + /** The maximum number of grapheme */ + public static int getMaxGroupNameGraphemeLength() { + return Math.max(32, getInteger(GROUP_NAME_MAX_LENGTH, -1)); + } + + /** Whether or not to allow automatic session resets. */ + public static boolean automaticSessionReset() { + return getBoolean(AUTOMATIC_SESSION_RESET, true); + } + + /** How often we allow an automatic session reset. */ + public static int automaticSessionResetIntervalSeconds() { + return getInteger(AUTOMATIC_SESSION_RESET, (int) TimeUnit.HOURS.toSeconds(1)); + } + + /** The default maximum backoff for jobs. */ + public static long getDefaultMaxBackoff() { + return TimeUnit.SECONDS.toMillis(getInteger(DEFAULT_MAX_BACKOFF, 60)); + } + + /** The maximum backoff for network jobs that hit a 5xx error. */ + public static long getServerErrorMaxBackoff() { + return TimeUnit.SECONDS.toMillis(getInteger(SERVER_ERROR_MAX_BACKOFF, (int) TimeUnit.HOURS.toSeconds(6))); + } + + /** Whether or not to allow automatic retries from OkHttp */ + public static boolean okHttpAutomaticRetry() { + return getBoolean(OKHTTP_AUTOMATIC_RETRY, false); + } + + /** The minimum memory class required for rendering animated stickers in the keyboard and such */ + public static int animatedStickerMinimumMemoryClass() { + return getInteger(ANIMATED_STICKER_MIN_MEMORY, 193); + } + + /** The minimum total memory for rendering animated stickers in the keyboard and such */ + public static int animatedStickerMinimumTotalMemoryMb() { + return getInteger(ANIMATED_STICKER_MIN_TOTAL_MEMORY, (int) ByteUnit.GIGABYTES.toMegabytes(3)); + } + + /** Only for rendering debug info. */ + public static synchronized @NonNull Map getMemoryValues() { + return new TreeMap<>(REMOTE_VALUES); + } + + /** Only for rendering debug info. */ + public static synchronized @NonNull Map getDiskValues() { + return new TreeMap<>(parseStoredConfig(SignalStore.remoteConfigValues().getCurrentConfig())); + } + + /** Only for rendering debug info. */ + public static synchronized @NonNull Map getPendingDiskValues() { + return new TreeMap<>(parseStoredConfig(SignalStore.remoteConfigValues().getPendingConfig())); + } + + /** Only for rendering debug info. */ + public static synchronized @NonNull Map getForcedValues() { + return new TreeMap<>(FORCED_VALUES); + } + + @VisibleForTesting + static @NonNull UpdateResult updateInternal(@NonNull Map remote, + @NonNull Map localMemory, + @NonNull Map localDisk, + @NonNull Set remoteCapable, + @NonNull Set hotSwap, + @NonNull Set sticky) + { + Map newMemory = new TreeMap<>(localMemory); + Map newDisk = new TreeMap<>(localDisk); + + Set allKeys = new HashSet<>(); + allKeys.addAll(remote.keySet()); + allKeys.addAll(localDisk.keySet()); + allKeys.addAll(localMemory.keySet()); + + Stream.of(allKeys) + .filter(remoteCapable::contains) + .forEach(key -> { + Object remoteValue = remote.get(key); + Object diskValue = localDisk.get(key); + Object newValue = remoteValue; + + if (newValue != null && diskValue != null && newValue.getClass() != diskValue.getClass()) { + Log.w(TAG, "Type mismatch! key: " + key); + + newDisk.remove(key); + + if (hotSwap.contains(key)) { + newMemory.remove(key); + } + + return; + } + + if (sticky.contains(key) && (newValue instanceof Boolean || diskValue instanceof Boolean)) { + newValue = diskValue == Boolean.TRUE ? Boolean.TRUE : newValue; + } else if (sticky.contains(key)) { + Log.w(TAG, "Tried to make a non-boolean sticky! Ignoring. (key: " + key + ")"); + } + + if (newValue != null) { + newDisk.put(key, newValue); + } else { + newDisk.remove(key); + } + + if (hotSwap.contains(key)) { + if (newValue != null) { + newMemory.put(key, newValue); + } else { + newMemory.remove(key); + } + } + }); + + Stream.of(allKeys) + .filterNot(remoteCapable::contains) + .filterNot(key -> sticky.contains(key) && localDisk.get(key) == Boolean.TRUE) + .forEach(key -> { + newDisk.remove(key); + + if (hotSwap.contains(key)) { + newMemory.remove(key); + } + }); + + return new UpdateResult(newMemory, newDisk, computeChanges(localMemory, newMemory)); + } + + @VisibleForTesting + static @NonNull Map computeChanges(@NonNull Map oldMap, @NonNull Map newMap) { + Map changes = new HashMap<>(); + Set allKeys = new HashSet<>(); + + allKeys.addAll(oldMap.keySet()); + allKeys.addAll(newMap.keySet()); + + for (String key : allKeys) { + Object oldValue = oldMap.get(key); + Object newValue = newMap.get(key); + + if (oldValue == null && newValue == null) { + throw new AssertionError("Should not be possible."); + } else if (oldValue != null && newValue == null) { + changes.put(key, Change.REMOVED); + } else if (newValue != oldValue && newValue instanceof Boolean) { + changes.put(key, (boolean) newValue ? Change.ENABLED : Change.DISABLED); + } else if (!Objects.equals(oldValue, newValue)) { + changes.put(key, Change.CHANGED); + } + } + + return changes; + } + + private static @NonNull VersionFlag getVersionFlag(@NonNull String key) { + int versionFromKey = getInteger(key, 0); + + if (versionFromKey == 0) { + return VersionFlag.OFF; + } + + if (BuildConfig.CANONICAL_VERSION_CODE >= versionFromKey) { + return VersionFlag.ON; + } else { + return VersionFlag.ON_IN_FUTURE_VERSION; + } + } + + public static long getBackgroundMessageProcessDelay() { + int delayMinutes = getInteger(MESSAGE_PROCESSOR_ALARM_INTERVAL, (int) TimeUnit.HOURS.toMinutes(6)); + return TimeUnit.MINUTES.toMillis(delayMinutes); + } + + private enum VersionFlag { + /** The flag is no set */ + OFF, + + /** The flag is set on for a version higher than the current client version */ + ON_IN_FUTURE_VERSION, + + /** The flag is set on for this version or earlier */ + ON + } + + private static boolean getBoolean(@NonNull String key, boolean defaultValue) { + Boolean forced = (Boolean) FORCED_VALUES.get(key); + if (forced != null) { + return forced; + } + + Object remote = REMOTE_VALUES.get(key); + if (remote instanceof Boolean) { + return (boolean) remote; + } else if (remote != null) { + Log.w(TAG, "Expected a boolean for key '" + key + "', but got something else! Falling back to the default."); + } + + return defaultValue; + } + + private static int getInteger(@NonNull String key, int defaultValue) { + Integer forced = (Integer) FORCED_VALUES.get(key); + if (forced != null) { + return forced; + } + + Object remote = REMOTE_VALUES.get(key); + if (remote instanceof String) { + try { + return Integer.parseInt((String) remote); + } catch (NumberFormatException e) { + Log.w(TAG, "Expected an int for key '" + key + "', but got something else! Falling back to the default."); + } + } + + return defaultValue; + } + + private static String getString(@NonNull String key, String defaultValue) { + String forced = (String) FORCED_VALUES.get(key); + if (forced != null) { + return forced; + } + + Object remote = REMOTE_VALUES.get(key); + if (remote instanceof String) { + return (String) remote; + } + + return defaultValue; + } + + private static Map parseStoredConfig(String stored) { + Map parsed = new HashMap<>(); + + if (TextUtils.isEmpty(stored)) { + Log.i(TAG, "No remote config stored. Skipping."); + return parsed; + } + + try { + JSONObject root = new JSONObject(stored); + Iterator iter = root.keys(); + + while (iter.hasNext()) { + String key = iter.next(); + parsed.put(key, root.get(key)); + } + } catch (JSONException e) { + throw new AssertionError("Failed to parse! Cleared storage."); + } + + return parsed; + } + + private static @NonNull String mapToJson(@NonNull Map map) { + try { + JSONObject json = new JSONObject(); + + for (Map.Entry entry : map.entrySet()) { + json.put(entry.getKey(), entry.getValue()); + } + + return json.toString(); + } catch (JSONException e) { + throw new AssertionError(e); + } + } + + private static void triggerFlagChangeListeners(Map changes) { + for (Map.Entry change : changes.entrySet()) { + OnFlagChange listener = FLAG_CHANGE_LISTENERS.get(change.getKey()); + + if (listener != null) { + Log.i(TAG, "Triggering change listener for: " + change.getKey()); + listener.onFlagChange(change.getValue()); + } + } + } + + @VisibleForTesting + static final class UpdateResult { + private final Map memory; + private final Map disk; + private final Map memoryChanges; + + UpdateResult(@NonNull Map memory, @NonNull Map disk, @NonNull Map memoryChanges) { + this.memory = memory; + this.disk = disk; + this.memoryChanges = memoryChanges; + } + + public @NonNull Map getMemory() { + return memory; + } + + public @NonNull Map getDisk() { + return disk; + } + + public @NonNull Map getMemoryChanges() { + return memoryChanges; + } + } + + @VisibleForTesting + interface OnFlagChange { + void onFlagChange(@NonNull Change change); + } + + enum Change { + ENABLED, DISABLED, CHANGED, REMOVED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java new file mode 100644 index 00000000..54ced557 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +import org.thoughtcrime.securesms.BuildConfig; + +import java.io.File; + +public class FileProviderUtil { + + private static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".fileprovider"; + + public static Uri getUriFor(@NonNull Context context, @NonNull File file) { + if (Build.VERSION.SDK_INT >= 24) return FileProvider.getUriForFile(context, AUTHORITY, file); + else return Uri.fromFile(file); + } + + public static boolean isAuthority(@NonNull Uri uri) { + return AUTHORITY.equals(uri.getAuthority()); + } + + public static boolean delete(@NonNull Context context, @NonNull Uri uri) { + if (AUTHORITY.equals(uri.getAuthority())) { + return context.getContentResolver().delete(uri, null, null) > 0; + } + return new File(uri.getPath()).delete(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java new file mode 100644 index 00000000..8cb7c3ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java @@ -0,0 +1,432 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.ContactsContract; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.util.Log; +import android.util.Pair; +import android.webkit.MimeTypeMap; + +import androidx.annotation.Nullable; + +import org.archiver.ArchiveConstants; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class FileUtils { + + static { + System.loadLibrary("native-utils"); + } + + public static native int getFileDescriptorOwner(FileDescriptor fileDescriptor); + + static native int createMemoryFileDescriptor(String name); + + public static byte[] getFileDigest(FileInputStream fin) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA256"); + + byte[] buffer = new byte[4096]; + int read = 0; + + while ((read = fin.read(buffer, 0, buffer.length)) != -1) { + digest.update(buffer, 0, read); + } + + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + public static void deleteDirectoryContents(@Nullable File directory) { + if (directory == null || !directory.exists() || !directory.isDirectory()) return; + + File[] files = directory.listFiles(); + + if (files != null) { + for (File file : files) { + if (file.isDirectory()) deleteDirectory(file); + else file.delete(); + } + } + } + + public static boolean deleteDirectory(@Nullable File directory) { + if (directory == null || !directory.exists() || !directory.isDirectory()) { + return false; + } + + deleteDirectoryContents(directory); + + return directory.delete(); + } + + + public static File writeFileOnInternalStorage(Context context, String dirName, String sFileName, InputStream source) { + + File dir = new File(context.getFilesDir(), dirName); + if (!dir.exists()) { + dir.mkdir(); + } + + File gpxfile = null; + + try { + //copy: + gpxfile = new File(dir, sFileName); + FileOutputStream fos = new FileOutputStream(gpxfile); + try { + // Transfer bytes from in to out + byte[] buf = new byte[1024]; + int len; + while ((len = source.read(buf)) > 0) { + fos.write(buf, 0, len); + } + } finally { + fos.close(); + } + source.close(); + fos.close(); + } catch (Exception e) { + e.printStackTrace(); + } + + return gpxfile; + + } + + + + + public static String getPath(final Context context, final Uri uri) { + + final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + + // DocumentProvider + if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + if ("primary".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/" + split[1]; + } + + // TODO handle non-primary volumes + } + // DownloadsProvider + else if (isDownloadsDocument(uri)) { + + final String id = DocumentsContract.getDocumentId(uri); + final Uri contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); + + return getDataColumn(context, contentUri, null, null); + } + // MediaProvider + else if (isMediaDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + final String selection = "_id=?"; + final String[] selectionArgs = new String[]{ + split[1] + }; + + return getDataColumn(context, contentUri, selection, selectionArgs); + } + } + // MediaStore (and general) + else if ("content".equalsIgnoreCase(uri.getScheme())) { + return getDataColumn(context, uri, null, null); + } + // File + else if ("file".equalsIgnoreCase(uri.getScheme())) { + return uri.getPath(); + } + + return null; + } + + /** + * Get the value of the data column for this Uri. This is useful for + * MediaStore Uris, and other file-based ContentProviders. + * + * @param context The context. + * @param uri The Uri to query. + * @param selection (Optional) Filter used in the query. + * @param selectionArgs (Optional) Selection arguments used in the query. + * @return The value of the _data column, which is typically a file path. + */ + public static String getDataColumn(Context context, Uri uri, String selection, + String[] selectionArgs) { + + Cursor cursor = null; + final String column = "_data"; + final String[] projection = { + column + }; + + try { + cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, + null); + if (cursor != null && cursor.moveToFirst()) { + final int column_index = cursor.getColumnIndexOrThrow(column); + return cursor.getString(column_index); + } + } finally { + if (cursor != null) + cursor.close(); + } + return null; + } + + + public static boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + public static boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + public static boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + + public static File createPlaceHolderTempFile(Context context, String fileName) { + File dir = new File(context.getFilesDir(), ArchiveConstants.ARCHIVE_FILE_FOLDER_NAME); + if (!dir.exists() && dir != null) { + dir.mkdir(); + } + return new File(dir, fileName); + } + + public static void deleteFile(Context context,String dirName,String fileName){ + + File dir = new File(context.getFilesDir(), dirName); + if(dir.exists()){ + for (File file : dir.listFiles()) { + if(file.getName().equalsIgnoreCase(fileName)){ + file.delete(); + break; + } + } + } + + } + + public static int copy(InputStream input, OutputStream output) throws IOException{ + byte[] buffer = new byte[1024]; + int count = 0; + int n = 0; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } + + private OutputStream copyInputStreamToFile( InputStream in, File file ) { + OutputStream out = null; + try { + out = new FileOutputStream(file); + byte[] buf = new byte[1024]; + int len; + while((len=in.read(buf))>0){ + out.write(buf,0,len); + } + out.close(); + in.close(); + } catch (Exception e) { + e.printStackTrace(); + } + + return out; + } + + + // Code simulating the copy + // You could alternatively use NIO + // And please, unlike me, do something about the Exceptions :D + public static Pair duplicateInputStream(InputStream originalStreamToCopy){ + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + byte[] buffer = new byte[1024]; + int len = 0; + while (true) { + try { + if (!((len = originalStreamToCopy.read(buffer)) > -1)) break; + } catch (IOException e) { + e.printStackTrace(); + } + baos.write(buffer, 0, len); + } + try { + baos.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + + // Open new InputStreams using recorded bytes + // Can be repeated as many times as you wish + InputStream is1 = new ByteArrayInputStream(baos.toByteArray()); + InputStream is2 = new ByteArrayInputStream(baos.toByteArray()); + + return new Pair(is1,is2); + } + + + public static String getExtensionFromMimeType(Context ctx , String mimeType) { + + ContentResolver cR = ctx.getContentResolver(); + MimeTypeMap mime = MimeTypeMap.getSingleton(); + return mime.getExtensionFromMimeType(mimeType); + } + + +/* public static String getRealPathFromURI(Context context, Uri contentUri) { + Cursor cursor = null; + try { + String[] proj = { MediaStore.Images.Media.DATA }; + cursor = context.getContentResolver().query(contentUri, proj, null, null, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + cursor.moveToFirst(); + return cursor.getString(column_index); + } finally { + if (cursor != null) { + cursor.close(); + } + } + }*/ + + + public static String getFilePathForN(Uri uri, Context context) { + Uri returnUri = uri; + Cursor returnCursor = context.getContentResolver().query(/*returnUri*/ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null); + /* + * Get the column indexes of the data in the Cursor, + * * move to the first row in the Cursor, get the data, + * * and display it. + * */ + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE); + returnCursor.moveToFirst(); + String name = (returnCursor.getString(nameIndex)); + String size = (Long.toString(returnCursor.getLong(sizeIndex))); + File file = new File(context.getFilesDir(), name); + try { + InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(file); + int read = 0; + int maxBufferSize = 1 * 1024 * 1024; + int bytesAvailable = inputStream.available(); + + //int bufferSize = 1024; + int bufferSize = Math.min(bytesAvailable, maxBufferSize); + + final byte[] buffers = new byte[bufferSize]; + while ((read = inputStream.read(buffers)) != -1) { + outputStream.write(buffers, 0, read); + } + Log.e("File Size", "Size " + file.length()); + inputStream.close(); + outputStream.close(); + Log.e("File Path", "Path " + file.getPath()); + Log.e("File Size", "Size " + file.length()); + } catch (Exception e) { + Log.e("Exception", e.getMessage()); + } + return file.getPath(); + } + + + + @TargetApi(Build.VERSION_CODES.KITKAT) + public static String getRealPathFromURI_API19(Context context, Uri uri){ + String filePath = ""; + String wholeID = DocumentsContract.getDocumentId(uri); + String id = wholeID.split(":")[1]; + + String[] column = { MediaStore.Images.Media.DATA }; + + // where id is equal to + String sel = MediaStore.Images.Media._ID + "=?"; + + Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + column, sel, new String[]{ id }, null); + int columnIndex = cursor.getColumnIndex(column[0]); + if (cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + cursor.close(); + return filePath; + } + + + public static String getRealPathFromURI(Context context, Uri contentUri) { + Cursor cursor = null; + String path = ""; + try { + String[] proj = { MediaStore.Images.Media.DATA }; + cursor = context.getContentResolver().query(contentUri, proj, null, null, null); + cursor.moveToFirst(); + int column_index = cursor.getColumnIndex(proj[0]); + path = cursor.getString(column_index); + return path; + } finally { + if (cursor != null) { + cursor.close(); + } + } + + } + + + + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FrameRateTracker.java b/app/src/main/java/org/thoughtcrime/securesms/util/FrameRateTracker.java new file mode 100644 index 00000000..df2e29ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FrameRateTracker.java @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Application; +import android.content.Context; +import android.view.Choreographer; +import android.view.Display; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Tracks the frame rate of the app and logs when things are bad. + * + * In general, whenever alterations are made here, the author should be very cautious to do as + * little work as possible, because we don't want the tracker itself to impact the frame rate. + */ +public class FrameRateTracker { + + private static final String TAG = Log.tag(FrameRateTracker.class); + + private static final int MAX_CONSECUTIVE_FRAME_LOGS = 10; + + private final Application context; + + private double refreshRate; + private long idealTimePerFrameNanos; + private long badFrameThresholdNanos; + + private long lastFrameTimeNanos; + + private long consecutiveFrameWarnings; + + public FrameRateTracker(@NonNull Application application) { + this.context = application; + + updateRefreshRate(); + } + + public void begin() { + Log.d(TAG, String.format(Locale.ENGLISH, "Beginning frame rate tracking. Screen refresh rate: %.2f hz, or %.2f ms per frame.", refreshRate, idealTimePerFrameNanos / (float) 1_000_000)); + + lastFrameTimeNanos = System.nanoTime(); + + Choreographer.getInstance().postFrameCallback(calculator); + } + + public void end() { + Choreographer.getInstance().removeFrameCallback(calculator); + } + + /** + * The natural screen refresh rate, in hertz. May not always return the same value if a display + * has a dynamic refresh rate. + */ + public static float getDisplayRefreshRate(@NonNull Context context) { + Display display = ServiceUtil.getWindowManager(context).getDefaultDisplay(); + return display.getRefreshRate(); + } + + /** + * Displays with dynamic refresh rates may change their reported refresh rate over time. + */ + private void updateRefreshRate() { + double newRefreshRate = getDisplayRefreshRate(context); + + if (this.refreshRate != newRefreshRate) { + if (this.refreshRate > 0) { + Log.d(TAG, String.format(Locale.ENGLISH, "Refresh rate changed from %.2f hz to %.2f hz", refreshRate, newRefreshRate)); + } + + this.refreshRate = getDisplayRefreshRate(context); + this.idealTimePerFrameNanos = (long) (TimeUnit.SECONDS.toNanos(1) / refreshRate); + this.badFrameThresholdNanos = idealTimePerFrameNanos * (int) (refreshRate / 4); + } + } + + private final Choreographer.FrameCallback calculator = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + long elapsedNanos = frameTimeNanos - lastFrameTimeNanos; + double fps = TimeUnit.SECONDS.toNanos(1) / (double) elapsedNanos; + + if (elapsedNanos > badFrameThresholdNanos) { + if (consecutiveFrameWarnings < MAX_CONSECUTIVE_FRAME_LOGS) { + long droppedFrames = elapsedNanos / idealTimePerFrameNanos; + Log.w(TAG, String.format(Locale.ENGLISH, "Bad frame! Took %d ms (%d dropped frames, or %.2f FPS)", TimeUnit.NANOSECONDS.toMillis(elapsedNanos), droppedFrames, fps)); + consecutiveFrameWarnings++; + } + } else { + consecutiveFrameWarnings = 0; + } + + lastFrameTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java new file mode 100644 index 00000000..5b50d9f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; + +/** + * Encapsulates logic to properly show/hide system UI/chrome in a full screen setting. Also + * handles adjusting to notched devices as long as you call {@link #configureToolbarSpacer(View)}. + */ +public final class FullscreenHelper { + + @NonNull private final Activity activity; + + public FullscreenHelper(@NonNull Activity activity) { + this.activity = activity; + + if (Build.VERSION.SDK_INT >= 28) { + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + + showSystemUI(); + } + + public void configureToolbarSpacer(@NonNull View spacer) { + if (Build.VERSION.SDK_INT == 19) { + setSpacerHeight(spacer, ViewUtil.getStatusBarHeight(spacer)); + return; + } + + ViewCompat.setOnApplyWindowInsetsListener(spacer, (view, insets) -> { + setSpacerHeight(view, insets.getSystemWindowInsetTop()); + return insets; + }); + } + + private void setSpacerHeight(@NonNull View spacer, int height) { + ViewGroup.LayoutParams params = spacer.getLayoutParams(); + + params.height = height; + + spacer.setLayoutParams(params); + spacer.setVisibility(View.VISIBLE); + } + + public void showAndHideWithSystemUI(@NonNull Window window, @NonNull View... views) { + window.getDecorView().setOnSystemUiVisibilityChangeListener(visibility -> { + boolean hide = (visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0; + + for (View view : views) { + view.animate() + .alpha(hide ? 0 : 1) + .withStartAction(() -> { + if (!hide) { + view.setVisibility(View.VISIBLE); + } + }) + .withEndAction(() -> { + if (hide) { + view.setVisibility(View.INVISIBLE); + } + }) + .start(); + } + }); + } + + public void toggleUiVisibility() { + int systemUiVisibility = activity.getWindow().getDecorView().getSystemUiVisibility(); + if ((systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0) { + showSystemUI(); + } else { + hideSystemUI(); + } + } + + public void hideSystemUI() { + activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN); + } + + public void showSystemUI() { + activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Function3.java b/app/src/main/java/org/thoughtcrime/securesms/util/Function3.java new file mode 100644 index 00000000..afefbc77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Function3.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.util; + +/** + * A function which takes 3 inputs and returns 1 output. + */ +public interface Function3 { + D apply(A a, B b, C c); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java b/app/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java new file mode 100644 index 00000000..e182dc6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FutureTaskListener.java @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import java.util.concurrent.ExecutionException; + +public interface FutureTaskListener { + public void onSuccess(V result); + public void onFailure(ExecutionException exception); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java new file mode 100644 index 00000000..29b6bd73 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -0,0 +1,212 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.protobuf.ByteString; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceContent; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public final class GroupUtil { + + private GroupUtil() { + } + + private static final String TAG = Log.tag(GroupUtil.class); + + /** + * @return The group context present on the content if one exists, otherwise null. + */ + public static @Nullable SignalServiceGroupContext getGroupContextIfPresent(@Nullable SignalServiceContent content) { + if (content == null) { + return null; + } else if (content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupContext().isPresent()) { + return content.getDataMessage().get().getGroupContext().get(); + } else if (content.getSyncMessage().isPresent() && + content.getSyncMessage().get().getSent().isPresent() && + content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().isPresent()) + { + return content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().get(); + } else { + return null; + } + } + + /** + * Result may be a v1 or v2 GroupId. + */ + public static @NonNull GroupId idFromGroupContext(@NonNull SignalServiceGroupContext groupContext) + throws BadGroupIdException + { + if (groupContext.getGroupV1().isPresent()) { + return GroupId.v1(groupContext.getGroupV1().get().getGroupId()); + } else if (groupContext.getGroupV2().isPresent()) { + return GroupId.v2(groupContext.getGroupV2().get().getMasterKey()); + } else { + throw new AssertionError(); + } + } + + public static @NonNull GroupId idFromGroupContextOrThrow(@NonNull SignalServiceGroupContext groupContext) { + try { + return idFromGroupContext(groupContext); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + } + + /** + * Result may be a v1 or v2 GroupId. + */ + public static @NonNull Optional idFromGroupContext(@NonNull Optional groupContext) + throws BadGroupIdException + { + if (groupContext.isPresent()) { + return Optional.of(idFromGroupContext(groupContext.get())); + } + return Optional.absent(); + } + + public static @NonNull GroupMasterKey requireMasterKey(@NonNull byte[] masterKey) { + try { + return new GroupMasterKey(masterKey); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } + + public static @NonNull GroupDescription getNonV2GroupDescription(@NonNull Context context, @Nullable String encodedGroup) { + if (encodedGroup == null) { + return new GroupDescription(context, null); + } + + try { + MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, false); + return new GroupDescription(context, groupContext); + } catch (IOException e) { + Log.w(TAG, e); + return new GroupDescription(context, null); + } + } + + @WorkerThread + public static void setDataMessageGroupContext(@NonNull Context context, + @NonNull SignalServiceDataMessage.Builder dataMessageBuilder, + @NonNull GroupId.Push groupId) + { + if (groupId.isV2()) { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey()) + .withRevision(v2GroupProperties.getGroupRevision()) + .build(); + dataMessageBuilder.asGroupMessage(group); + } else { + dataMessageBuilder.asGroupMessage(new SignalServiceGroup(groupId.getDecodedId())); + } + } + + public static OutgoingGroupUpdateMessage createGroupV1LeaveMessage(@NonNull GroupId.V1 groupId, + @NonNull Recipient groupRecipient) + { + GroupContext groupContext = GroupContext.newBuilder() + .setId(ByteString.copyFrom(groupId.getDecodedId())) + .setType(GroupContext.Type.QUIT) + .build(); + + return new OutgoingGroupUpdateMessage(groupRecipient, + groupContext, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + } + + public static class GroupDescription { + + @NonNull private final Context context; + @Nullable private final MessageGroupContext groupContext; + @Nullable private final List members; + + GroupDescription(@NonNull Context context, @Nullable MessageGroupContext groupContext) { + this.context = context.getApplicationContext(); + this.groupContext = groupContext; + + if (groupContext == null) { + this.members = null; + } else { + List membersList = groupContext.getMembersListExcludingSelf(); + this.members = membersList.isEmpty() ? null : membersList; + } + } + + @WorkerThread + public String toString(@NonNull Recipient sender) { + StringBuilder description = new StringBuilder(); + description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.getDisplayName(context))); + + if (groupContext == null) { + return description.toString(); + } + + String title = StringUtil.isolateBidi(groupContext.getName()); + + if (members != null && members.size() > 0) { + description.append("\n"); + description.append(context.getResources().getQuantityString(R.plurals.GroupUtil_joined_the_group, + members.size(), toString(members))); + } + + if (!title.trim().isEmpty()) { + if (members != null) description.append(" "); + else description.append("\n"); + description.append(context.getString(R.string.GroupUtil_group_name_is_now, title)); + } + + return description.toString(); + } + + private String toString(List recipients) { + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < recipients.size(); i++) { + result.append(Recipient.live(recipients.get(i)).get().getDisplayName(context)); + + if (i != recipients.size() -1 ) + result.append(", "); + } + + return result.toString(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Hex.java b/app/src/main/java/org/thoughtcrime/securesms/util/Hex.java new file mode 100644 index 00000000..a1f3af4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Hex.java @@ -0,0 +1,146 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import java.io.IOException; + +/** + * Utility for generating hex dumps. + */ +public class Hex { + + private final static int HEX_DIGITS_START = 10; + private final static int ASCII_TEXT_START = HEX_DIGITS_START + (16*2 + (16/2)); + + final static String EOL = System.getProperty("line.separator"); + + private final static char[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + public static String toString(byte[] bytes) { + return toString(bytes, 0, bytes.length); + } + + public static String toString(byte[] bytes, int offset, int length) { + StringBuffer buf = new StringBuffer(); + for (int i = 0; i < length; i++) { + appendHexChar(buf, bytes[offset + i]); + buf.append(' '); + } + return buf.toString(); + } + + public static String toStringCondensed(byte[] bytes) { + StringBuffer buf = new StringBuffer(); + for (int i=0;i> 1]; + + // two characters form the hex value. + for (int i = 0, j = 0; j < len; i++) { + int f = Character.digit(data[j], 16) << 4; + j++; + f = f | Character.digit(data[j], 16); + j++; + out[i] = (byte) (f & 0xFF); + } + + return out; + } + + public static byte[] fromStringOrThrow(String encoded) { + try { + return fromStringCondensed(encoded); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static String dump(byte[] bytes) { + return dump(bytes, 0, bytes.length); + } + + public static String dump(byte[] bytes, int offset, int length) { + StringBuffer buf = new StringBuffer(); + int lines = ((length - 1) / 16) + 1; + int lineOffset; + int lineLength; + + for (int i = 0; i < lines; i++) { + lineOffset = (i * 16) + offset; + lineLength = Math.min(16, (length - (i * 16))); + appendDumpLine(buf, i, bytes, lineOffset, lineLength); + buf.append(EOL); + } + + return buf.toString(); + } + + private static void appendDumpLine(StringBuffer buf, int line, byte[] bytes, int lineOffset, int lineLength) { + buf.append(HEX_DIGITS[(line >> 28) & 0xf]); + buf.append(HEX_DIGITS[(line >> 24) & 0xf]); + buf.append(HEX_DIGITS[(line >> 20) & 0xf]); + buf.append(HEX_DIGITS[(line >> 16) & 0xf]); + buf.append(HEX_DIGITS[(line >> 12) & 0xf]); + buf.append(HEX_DIGITS[(line >> 8) & 0xf]); + buf.append(HEX_DIGITS[(line >> 4) & 0xf]); + buf.append(HEX_DIGITS[(line ) & 0xf]); + buf.append(": "); + + for (int i = 0; i < 16; i++) { + int idx = i + lineOffset; + if (i < lineLength) { + int b = bytes[idx]; + appendHexChar(buf, b); + } else { + buf.append(" "); + } + if ((i % 2) == 1) { + buf.append(' '); + } + } + + for (int i = 0; i < 16 && i < lineLength; i++) { + int idx = i + lineOffset; + int b = bytes[idx]; + if (b >= 0x20 && b <= 0x7e) { + buf.append((char)b); + } else { + buf.append('.'); + } + } + } + + private static void appendHexChar(StringBuffer buf, int b) { + buf.append(HEX_DIGITS[(b >> 4) & 0xf]); + buf.append(HEX_DIGITS[b & 0xf]); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java new file mode 100644 index 00000000..186c69bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +public class HtmlUtil { + public static @NonNull String bold(@NonNull String target) { + return "" + target + ""; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IOFunction.java b/app/src/main/java/org/thoughtcrime/securesms/util/IOFunction.java new file mode 100644 index 00000000..9f5d4338 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IOFunction.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.util; + +import java.io.IOException; + +/** + * A function which takes 1 input and returns 1 output, and is capable of throwing an IO Exception. + */ +public interface IOFunction { + O apply(I input) throws IOException; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IasKeyStore.java b/app/src/main/java/org/thoughtcrime/securesms/util/IasKeyStore.java new file mode 100644 index 00000000..a6ce00ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IasKeyStore.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.push.IasTrustStore; +import org.whispersystems.signalservice.api.push.TrustStore; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +public final class IasKeyStore { + + private IasKeyStore() { + } + + public static KeyStore getIasKeyStore(@NonNull Context context) { + try { + TrustStore contactTrustStore = new IasTrustStore(context); + + KeyStore keyStore = KeyStore.getInstance("BKS"); + keyStore.load(contactTrustStore.getKeyStoreInputStream(), contactTrustStore.getKeyStorePassword().toCharArray()); + + return keyStore; + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java new file mode 100644 index 00000000..f7be227b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java @@ -0,0 +1,247 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.DatabaseSessionLock; +import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore; +import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.IncomingIdentityDefaultMessage; +import org.thoughtcrime.securesms.sms.IncomingIdentityUpdateMessage; +import org.thoughtcrime.securesms.sms.IncomingIdentityVerifiedMessage; +import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.OutgoingIdentityDefaultMessage; +import org.thoughtcrime.securesms.sms.OutgoingIdentityVerifiedMessage; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.state.IdentityKeyStore; +import org.whispersystems.libsignal.state.SessionRecord; +import org.whispersystems.libsignal.state.SessionStore; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; + +import java.util.List; + +public final class IdentityUtil { + + private IdentityUtil() {} + + private static final String TAG = Log.tag(IdentityUtil.class); + + public static ListenableFuture> getRemoteIdentityKey(final Context context, final Recipient recipient) { + final SettableFuture> future = new SettableFuture<>(); + final RecipientId recipientId = recipient.getId(); + + SimpleTask.run(SignalExecutors.BOUNDED, + () -> DatabaseFactory.getIdentityDatabase(context) + .getIdentity(recipientId), + future::set); + + return future; + } + + public static void markIdentityVerified(Context context, Recipient recipient, boolean verified, boolean remote) + { + long time = System.currentTimeMillis(); + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + try (GroupDatabase.Reader reader = groupDatabase.getGroups()) { + + GroupDatabase.GroupRecord groupRecord; + + while ((groupRecord = reader.getNext()) != null) { + if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive() && !groupRecord.isMms()) { + + if (remote) { + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, -1, null, Optional.of(groupRecord.getId()), 0, false); + + if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); + else incoming = new IncomingIdentityDefaultMessage(incoming); + + smsDatabase.insertMessageInbox(incoming); + } else { + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupRecord.getId()); + Recipient groupRecipient = Recipient.resolved(recipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + OutgoingTextMessage outgoing ; + + if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient); + else outgoing = new OutgoingIdentityDefaultMessage(recipient); + + DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null); + } + } + } + } + + if (remote) { + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, -1, null, Optional.absent(), 0, false); + + if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); + else incoming = new IncomingIdentityDefaultMessage(incoming); + + smsDatabase.insertMessageInbox(incoming); + } else { + OutgoingTextMessage outgoing; + + if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient); + else outgoing = new OutgoingIdentityDefaultMessage(recipient); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + Log.i(TAG, "Inserting verified outbox..."); + DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null); + } + } + + public static void markIdentityUpdate(@NonNull Context context, @NonNull RecipientId recipientId) { + long time = System.currentTimeMillis(); + MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + try (GroupDatabase.Reader reader = groupDatabase.getGroups()) { + GroupDatabase.GroupRecord groupRecord; + + while ((groupRecord = reader.getNext()) != null) { + if (groupRecord.getMembers().contains(recipientId) && groupRecord.isActive()) { + IncomingTextMessage incoming = new IncomingTextMessage(recipientId, 1, time, time, null, Optional.of(groupRecord.getId()), 0, false); + IncomingIdentityUpdateMessage groupUpdate = new IncomingIdentityUpdateMessage(incoming); + + smsDatabase.insertMessageInbox(groupUpdate); + } + } + } + + IncomingTextMessage incoming = new IncomingTextMessage(recipientId, 1, time, -1, null, Optional.absent(), 0, false); + IncomingIdentityUpdateMessage individualUpdate = new IncomingIdentityUpdateMessage(incoming); + Optional insertResult = smsDatabase.insertMessageInbox(individualUpdate); + + if (insertResult.isPresent()) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId()); + } + } + + public static void saveIdentity(Context context, String user, IdentityKey identityKey) { + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + IdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context); + SessionStore sessionStore = new TextSecureSessionStore(context); + SignalProtocolAddress address = new SignalProtocolAddress(user, 1); + + if (identityKeyStore.saveIdentity(address, identityKey)) { + if (sessionStore.containsSession(address)) { + SessionRecord sessionRecord = sessionStore.loadSession(address); + sessionRecord.archiveCurrentState(); + + sessionStore.storeSession(address, sessionRecord); + } + } + } + } + + public static void processVerifiedMessage(Context context, VerifiedMessage verifiedMessage) { + try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + Recipient recipient = Recipient.externalPush(context, verifiedMessage.getDestination()); + Optional identityRecord = identityDatabase.getIdentity(recipient.getId()); + + if (!identityRecord.isPresent() && verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.DEFAULT) { + Log.w(TAG, "No existing record for default status"); + return; + } + + if (verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.DEFAULT && + identityRecord.isPresent() && + identityRecord.get().getIdentityKey().equals(verifiedMessage.getIdentityKey()) && + identityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.DEFAULT) + { + identityDatabase.setVerified(recipient.getId(), identityRecord.get().getIdentityKey(), IdentityDatabase.VerifiedStatus.DEFAULT); + markIdentityVerified(context, recipient, false, true); + } + + if (verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.VERIFIED && + (!identityRecord.isPresent() || + (identityRecord.isPresent() && !identityRecord.get().getIdentityKey().equals(verifiedMessage.getIdentityKey())) || + (identityRecord.isPresent() && identityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED))) + { + saveIdentity(context, verifiedMessage.getDestination().getIdentifier(), verifiedMessage.getIdentityKey()); + identityDatabase.setVerified(recipient.getId(), verifiedMessage.getIdentityKey(), IdentityDatabase.VerifiedStatus.VERIFIED); + markIdentityVerified(context, recipient, true, true); + } + } + } + + + public static @Nullable String getUnverifiedBannerDescription(@NonNull Context context, + @NonNull List unverified) + { + return getPluralizedIdentityDescription(context, unverified, + R.string.IdentityUtil_unverified_banner_one, + R.string.IdentityUtil_unverified_banner_two, + R.string.IdentityUtil_unverified_banner_many); + } + + public static @Nullable String getUnverifiedSendDialogDescription(@NonNull Context context, + @NonNull List unverified) + { + return getPluralizedIdentityDescription(context, unverified, + R.string.IdentityUtil_unverified_dialog_one, + R.string.IdentityUtil_unverified_dialog_two, + R.string.IdentityUtil_unverified_dialog_many); + } + + public static @Nullable String getUntrustedSendDialogDescription(@NonNull Context context, + @NonNull List untrusted) + { + return getPluralizedIdentityDescription(context, untrusted, + R.string.IdentityUtil_untrusted_dialog_one, + R.string.IdentityUtil_untrusted_dialog_two, + R.string.IdentityUtil_untrusted_dialog_many); + } + + private static @Nullable String getPluralizedIdentityDescription(@NonNull Context context, + @NonNull List recipients, + @StringRes int resourceOne, + @StringRes int resourceTwo, + @StringRes int resourceMany) + { + if (recipients.isEmpty()) return null; + + if (recipients.size() == 1) { + String name = recipients.get(0).getDisplayName(context); + return context.getString(resourceOne, name); + } else { + String firstName = recipients.get(0).getDisplayName(context); + String secondName = recipients.get(1).getDisplayName(context); + + if (recipients.size() == 2) { + return context.getString(resourceTwo, firstName, secondName); + } else { + int othersCount = recipients.size() - 2; + String nMore = context.getResources().getQuantityString(R.plurals.identity_others, othersCount, othersCount); + + return context.getString(resourceMany, firstName, secondName, nMore); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java new file mode 100644 index 00000000..9e92fab0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.mms.GlideApp; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.ExecutionException; + +public final class ImageCompressionUtil { + + private ImageCompressionUtil () {} + + /** + * A result satisfying the provided constraints, or null if they could not be met. + */ + @WorkerThread + public static @Nullable Result compressWithinConstraints(@NonNull Context context, + @NonNull String mimeType, + @NonNull Object glideModel, + int maxDimension, + int maxBytes, + @IntRange(from = 0, to = 100) int quality) + throws BitmapDecodingException + { + Result result = compress(context, mimeType, glideModel, maxDimension, quality); + + if (result.getData().length <= maxBytes) { + return result; + } else { + return null; + } + } + + /** + * Compresses the image to match the requested parameters. + */ + @WorkerThread + public static @NonNull Result compress(@NonNull Context context, + @NonNull String mimeType, + @NonNull Object glideModel, + int maxDimension, + @IntRange(from = 0, to = 100) int quality) + throws BitmapDecodingException + { + Bitmap scaledBitmap; + + try { + scaledBitmap = GlideApp.with(context.getApplicationContext()) + .asBitmap() + .load(glideModel) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerInside() + .submit(maxDimension, maxDimension) + .get(); + } catch (ExecutionException | InterruptedException e) { + throw new BitmapDecodingException(e); + } + + if (scaledBitmap == null) { + throw new BitmapDecodingException("Unable to decode image"); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Bitmap.CompressFormat format = mimeTypeToCompressFormat(mimeType); + scaledBitmap.compress(format, quality, output); + + byte[] data = output.toByteArray(); + + return new Result(data, compressFormatToMimeType(format), scaledBitmap.getWidth(), scaledBitmap.getHeight()); + } + + private static @NonNull Bitmap.CompressFormat mimeTypeToCompressFormat(@NonNull String mimeType) { + if (MediaUtil.isJpegType(mimeType) || MediaUtil.isHeicType(mimeType) || MediaUtil.isHeifType(mimeType)) { + return Bitmap.CompressFormat.JPEG; + } else { + return Bitmap.CompressFormat.PNG; + } + } + + private static @NonNull String compressFormatToMimeType(@NonNull Bitmap.CompressFormat format) { + switch (format) { + case JPEG: + return MediaUtil.IMAGE_JPEG; + case PNG: + return MediaUtil.IMAGE_PNG; + default: + throw new AssertionError("Unsupported format!"); + } + } + + public static final class Result { + private final byte[] data; + private final String mimeType; + private final int height; + private final int width; + + public Result(@NonNull byte[] data, @NonNull String mimeType, int width, int height) { + this.data = data; + this.mimeType = mimeType; + this.width = width; + this.height = height; + } + + public byte[] getData() { + return data; + } + + public @NonNull String getMimeType() { + return mimeType; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java new file mode 100644 index 00000000..ae72c1e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IntentUtils.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.LabeledIntent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; + +public class IntentUtils { + + public static boolean isResolvable(@NonNull Context context, @NonNull Intent intent) { + List resolveInfoList = context.getPackageManager().queryIntentActivities(intent, 0); + return resolveInfoList != null && resolveInfoList.size() > 1; + } + + /** + * From: https://stackoverflow.com/a/12328282 + */ + public static @Nullable LabeledIntent getLabelintent(@NonNull Context context, @NonNull Intent origIntent, int name, int drawable) { + PackageManager pm = context.getPackageManager(); + ComponentName launchName = origIntent.resolveActivity(pm); + + if (launchName != null) { + Intent resolved = new Intent(); + resolved.setComponent(launchName); + resolved.setData(origIntent.getData()); + + return new LabeledIntent(resolved, context.getPackageName(), name, drawable); + } + return null; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/InterceptableLongClickCopyLinkSpan.java b/app/src/main/java/org/thoughtcrime/securesms/util/InterceptableLongClickCopyLinkSpan.java new file mode 100644 index 00000000..81aae809 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/InterceptableLongClickCopyLinkSpan.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util; + +import android.view.View; + +import androidx.annotation.NonNull; + +/** + * Passes clicked Urls to the supplied {@link UrlClickHandler}. + */ +public final class InterceptableLongClickCopyLinkSpan extends LongClickCopySpan { + + private final UrlClickHandler onClickListener; + + public InterceptableLongClickCopyLinkSpan(@NonNull String url, + @NonNull UrlClickHandler onClickListener) + { + super(url); + this.onClickListener = onClickListener; + } + + @Override + public void onClick(View widget) { + if (!onClickListener.handleOnClick(getURL())) { + super.onClick(widget); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java new file mode 100644 index 00000000..4a3cf0d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.util; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; + +public class JsonUtils { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); + objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); + } + + public static T fromJson(byte[] serialized, Class clazz) throws IOException { + return fromJson(new String(serialized), clazz); + } + + public static T fromJson(String serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static T fromJson(InputStream serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static T fromJson(Reader serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static String toJson(Object object) throws IOException { + return objectMapper.writeValueAsString(object); + } + + public static ObjectMapper getMapper() { + return objectMapper; + } + + public static class SaneJSONObject { + + private final JSONObject delegate; + + public SaneJSONObject(JSONObject delegate) { + this.delegate = delegate; + } + + public String getString(String name) throws JSONException { + if (delegate.isNull(name)) return null; + else return delegate.getString(name); + } + + public long getLong(String name) throws JSONException { + return delegate.getLong(name); + } + + public boolean isNull(String name) { + return delegate.isNull(name); + } + + public int getInt(String name) throws JSONException { + return delegate.getInt(name); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java b/app/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java new file mode 100644 index 00000000..b8930891 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LRUCache.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.util; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class LRUCache extends LinkedHashMap { + + private final int maxSize; + + public LRUCache(int maxSize) { + this.maxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry (Map.Entry eldest) { + return size() > maxSize; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java new file mode 100644 index 00000000..5ee60408 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util; + +import android.text.Layout; + +import androidx.annotation.NonNull; + +/** + * Utility functions for dealing with {@link Layout}. + * + * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin + */ +public class LayoutUtil { + private static final float DEFAULT_LINE_SPACING_EXTRA = 0f; + + private static final float DEFAULT_LINE_SPACING_MULTIPLIER = 1f; + + public static int getLineHeight(@NonNull Layout layout, int line) { + return layout.getLineTop(line + 1) - layout.getLineTop(line); + } + + public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) { + int lineTop = layout.getLineTop(line); + if (line == 0) { + lineTop -= layout.getTopPadding(); + } + return lineTop; + } + + public static int getLineBottomWithoutPadding(@NonNull Layout layout, int line) { + int lineBottom = getLineBottomWithoutSpacing(layout, line); + if (line == layout.getLineCount() - 1) { + lineBottom -= layout.getBottomPadding(); + } + return lineBottom; + } + + public static int getLineBottomWithoutSpacing(@NonNull Layout layout, int line) { + int lineBottom = layout.getLineBottom(line); + boolean isLastLine = line == layout.getLineCount() - 1; + float lineSpacingExtra = layout.getSpacingAdd(); + float lineSpacingMultiplier = layout.getSpacingMultiplier(); + boolean hasLineSpacing = lineSpacingExtra != DEFAULT_LINE_SPACING_EXTRA || lineSpacingMultiplier != DEFAULT_LINE_SPACING_MULTIPLIER; + + int lineBottomWithoutSpacing; + if (!hasLineSpacing || isLastLine) { + lineBottomWithoutSpacing = lineBottom; + } else { + float extra; + if (Float.compare(lineSpacingMultiplier, DEFAULT_LINE_SPACING_MULTIPLIER) != 0) { + int lineHeight = getLineHeight(layout, line); + extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier; + } else { + extra = lineSpacingExtra; + } + + lineBottomWithoutSpacing = (int) (lineBottom - extra); + } + + return lineBottomWithoutSpacing; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java b/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java new file mode 100644 index 00000000..c6d02356 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Handler; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.core.os.HandlerCompat; + +import org.signal.core.util.logging.Log; + +/** + * Imagine a bucket. Now imagine your tasks as little droplets. As your tasks are thrown into the + * bucket, the tasks are executed, and the bucket fills up. If the bucket is full, the tasks + * overflow and are discarded. + * + * However, the bucket has a leak! So it empties slowly over time, allowing you to put more tasks in + * if you're patient. + * + * This class lets you define a bucket with a given capacity and drip interval. Imagine you had a + * capacity of 10 and a drip interval of 1000ms. That means that you could execute 10 tasks in + * rapid succession, but afterwards you'd only be able to execute at most 1 task per second. If you + * waited 10 seconds, the bucket would be fully drained, and you'd be able to execute 10 tasks in + * rapid succession again. + * + * This class also does something a little extra -- it keeps track of the most-recently-overflowed + * task, and will run it the next time it 'drips' instead of leaking. This lets you have a sort of + * "throw tasks at the bucket and forget about it" attitude, because you know the task will + * eventually run. + * + * Of course, that's only if all of your tasks are equal! It's highly recommended, as with any sort + * of limiting construct, to only submit a series of equivalent or roughly-equivalent tasks. + * + * Using the assumption that all tasks are equal, this class will also remove any pending tasks that + * are waiting to run when a new one is enqueued. No point in causing a pile-up. + */ +public final class LeakyBucketLimiter { + + private static final String TAG = Log.tag(LeakyBucketLimiter.class); + + private final int bucketCapacity; + private final long dripInterval; + private final Handler handler; + + private int bucketLevel; + private Runnable lastOverflowedRunnable; + + private final Object RUNNABLE_TOKEN = new Object(); + + public LeakyBucketLimiter(int bucketCapacity, long dripInterval, @NonNull Handler handler) { + this.bucketCapacity = bucketCapacity; + this.dripInterval = dripInterval; + this.handler = handler; + } + + @AnyThread + public void run(@NonNull Runnable runnable) { + boolean shouldRun = false; + boolean scheduleDrip = false; + + synchronized (this) { + if (bucketLevel < bucketCapacity) { + bucketLevel++; + + shouldRun = true; + scheduleDrip = bucketLevel == 1; + } else { + lastOverflowedRunnable = runnable; + } + } + + if (shouldRun) { + handler.removeCallbacksAndMessages(RUNNABLE_TOKEN); + HandlerCompat.postDelayed(handler, runnable, RUNNABLE_TOKEN, 0); + } else { + Log.d(TAG, "Overflowed!"); + } + + if (scheduleDrip) { + handler.postDelayed(this::drip, dripInterval); + } + } + + private void drip() { + Runnable runnable = null; + boolean needsDrip = false; + + synchronized (this) { + if (lastOverflowedRunnable == null) { + bucketLevel = Math.max(bucketLevel - 1, 0); + } else { + Log.d(TAG, "Running most-recently-overflowed task."); + runnable = lastOverflowedRunnable; + lastOverflowedRunnable = null; + } + + needsDrip = bucketLevel > 0; + } + + if (runnable != null) { + runnable.run(); + } + + if (needsDrip) { + handler.postDelayed(this::drip, dripInterval); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java new file mode 100644 index 00000000..0f834477 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.util; + +import android.database.Cursor; +import android.database.CursorWrapper; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +/** + * Wraps a {@link Cursor} that will be closed automatically when the {@link Lifecycle.Event}.ON_DESTROY + * is fired from the lifecycle this object is observing. + */ +public class LifecycleCursorWrapper extends CursorWrapper implements DefaultLifecycleObserver { + + public LifecycleCursorWrapper(Cursor cursor) { + super(cursor); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + close(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java new file mode 100644 index 00000000..04150f96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class LifecycleRecyclerAdapter extends RecyclerView.Adapter { + + @Override + public void onViewAttachedToWindow(@NonNull VH holder) { + super.onViewAttachedToWindow(holder); + holder.onAttachedToWindow(); + } + + @Override + public void onViewDetachedFromWindow(@NonNull VH holder) { + super.onViewDetachedFromWindow(holder); + holder.onDetachedFromWindow(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java new file mode 100644 index 00000000..aa59015a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class LifecycleViewHolder extends RecyclerView.ViewHolder implements LifecycleOwner { + + private final LifecycleRegistry lifecycleRegistry; + + public LifecycleViewHolder(@NonNull View itemView) { + super(itemView); + + lifecycleRegistry = new LifecycleRegistry(this); + } + + void onAttachedToWindow() { + lifecycleRegistry.setCurrentState(Lifecycle.State.RESUMED); + } + + void onDetachedFromWindow() { + lifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED); + } + + @Override + public @NonNull Lifecycle getLifecycle() { + return lifecycleRegistry; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LimitedInputStream.java b/app/src/main/java/org/thoughtcrime/securesms/util/LimitedInputStream.java new file mode 100644 index 00000000..9092b785 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LimitedInputStream.java @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.util; + + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + + +/** + * An input stream, which limits its data size. This stream is + * used, if the content length is unknown. + */ +public class LimitedInputStream extends FilterInputStream { + + /** + * The maximum size of an item, in bytes. + */ + private long sizeMax; + + /** + * The current number of bytes. + */ + private long count; + + /** + * Whether this stream is already closed. + */ + private boolean closed; + + /** + * Creates a new instance. + * @param pIn The input stream, which shall be limited. + * @param pSizeMax The limit; no more than this number of bytes + * shall be returned by the source stream. + */ + public LimitedInputStream(InputStream pIn, long pSizeMax) { + super(pIn); + sizeMax = pSizeMax; + } + + /** + * Reads the next byte of data from this input stream. The value + * byte is returned as an int in the range + * 0 to 255. If no byte is available + * because the end of the stream has been reached, the value + * -1 is returned. This method blocks until input data + * is available, the end of the stream is detected, or an exception + * is thrown. + * + * This method + * simply performs in.read() and returns the result. + * + * @return the next byte of data, or -1 if the end of the + * stream is reached. + * @exception IOException if an I/O error occurs. + * @see java.io.FilterInputStream#in + */ + public int read() throws IOException { + if (count >= sizeMax) return -1; + + int res = super.read(); + if (res != -1) { + count++; + } + return res; + } + + /** + * Reads up to len bytes of data from this input stream + * into an array of bytes. If len is not zero, the method + * blocks until some input is available; otherwise, no + * bytes are read and 0 is returned. + * + * This method simply performs in.read(b, off, len) + * and returns the result. + * + * @param b the buffer into which the data is read. + * @param off The start offset in the destination array + * b. + * @param len the maximum number of bytes read. + * @return the total number of bytes read into the buffer, or + * -1 if there is no more data because the end of + * the stream has been reached. + * @exception NullPointerException If b is null. + * @exception IndexOutOfBoundsException If off is negative, + * len is negative, or len is greater than + * b.length - off + * @exception IOException if an I/O error occurs. + * @see java.io.FilterInputStream#in + */ + public int read(byte[] b, int off, int len) throws IOException { + if (count >= sizeMax) return -1; + + long correctLength = Math.min(len, sizeMax - count); + + int res = super.read(b, off, Util.toIntExact(correctLength)); + if (res > 0) { + count += res; + } + return res; + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java new file mode 100644 index 00000000..068772aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ListenableFutureTask.java @@ -0,0 +1,126 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; + +public class ListenableFutureTask extends FutureTask { + + private final List> listeners = new LinkedList<>(); + + @Nullable + private final Object identifier; + + @Nullable + private final Executor callbackExecutor; + + public ListenableFutureTask(Callable callable) { + this(callable, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier) { + this(callable, identifier, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier, @Nullable Executor callbackExecutor) { + super(callable); + this.identifier = identifier; + this.callbackExecutor = callbackExecutor; + } + + + public ListenableFutureTask(final V result) { + this(result, null); + } + + public ListenableFutureTask(final V result, @Nullable Object identifier) { + super(new Callable() { + @Override + public V call() throws Exception { + return result; + } + }); + this.identifier = identifier; + this.callbackExecutor = null; + this.run(); + } + + public synchronized void addListener(FutureTaskListener listener) { + if (this.isDone()) { + callback(listener); + } else { + this.listeners.add(listener); + } + } + + public synchronized void removeListener(FutureTaskListener listener) { + this.listeners.remove(listener); + } + + @Override + protected synchronized void done() { + callback(); + } + + private void callback() { + Runnable callbackRunnable = new Runnable() { + @Override + public void run() { + for (FutureTaskListener listener : listeners) { + callback(listener); + } + } + }; + + if (callbackExecutor == null) callbackRunnable.run(); + else callbackExecutor.execute(callbackRunnable); + } + + private void callback(FutureTaskListener listener) { + if (listener != null) { + try { + listener.onSuccess(get()); + } catch (InterruptedException e) { + throw new AssertionError(e); + } catch (ExecutionException e) { + listener.onFailure(e); + } + } + } + + @Override + public boolean equals(Object other) { + if (other != null && other instanceof ListenableFutureTask && this.identifier != null) { + return identifier.equals(other); + } else { + return super.equals(other); + } + } + + @Override + public int hashCode() { + if (identifier != null) return identifier.hashCode(); + else return super.hashCode(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java new file mode 100644 index 00000000..2061c0e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickCopySpan.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.TargetApi; +import android.content.ClipData; +import android.content.Context; +import android.text.TextPaint; +import android.text.style.URLSpan; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public class LongClickCopySpan extends URLSpan { + private static final String PREFIX_MAILTO = "mailto:"; + private static final String PREFIX_TEL = "tel:"; + + private boolean isHighlighted; + @ColorInt + private int highlightColor; + + public LongClickCopySpan(String url) { + super(url); + } + + void onLongClick(View widget) { + Context context = widget.getContext(); + String preparedUrl = prepareUrl(getURL()); + copyUrl(context, preparedUrl); + Toast.makeText(context, + context.getString(R.string.ConversationItem_copied_text, preparedUrl), Toast.LENGTH_SHORT).show(); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.bgColor = highlightColor; + ds.setUnderlineText(!isHighlighted); + } + + void setHighlighted(boolean highlighted, @ColorInt int highlightColor) { + this.isHighlighted = highlighted; + this.highlightColor = highlightColor; + } + + private void copyUrl(Context context, String url) { + int sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < android.os.Build.VERSION_CODES.HONEYCOMB) { + @SuppressWarnings("deprecation") android.text.ClipboardManager clipboard = + (android.text.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setText(url); + } else { + copyUriSdk11(context, url); + } + } + + @TargetApi(android.os.Build.VERSION_CODES.HONEYCOMB) + private void copyUriSdk11(Context context, String url) { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(context.getString(R.string.app_name), url); + clipboard.setPrimaryClip(clip); + } + + private String prepareUrl(String url) { + if (url.startsWith(PREFIX_MAILTO)) { + return url.substring(PREFIX_MAILTO.length()); + } else if (url.startsWith(PREFIX_TEL)) { + return url.substring(PREFIX_TEL.length()); + } + return url; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java new file mode 100644 index 00000000..51f07448 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +public class LongClickMovementMethod extends LinkMovementMethod { + @SuppressLint("StaticFieldLeak") + private static LongClickMovementMethod sInstance; + + private final GestureDetector gestureDetector; + private View widget; + private LongClickCopySpan currentSpan; + + private LongClickMovementMethod(final Context context) { + gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public void onLongPress(MotionEvent e) { + if (currentSpan != null && widget != null) { + currentSpan.onLongClick(widget); + widget = null; + currentSpan = null; + } + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (currentSpan != null && widget != null) { + currentSpan.onClick(widget); + widget = null; + currentSpan = null; + } + return true; + } + }); + } + + @Override + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP || + action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class); + if (longClickCopySpan.length != 0) { + LongClickCopySpan aSingleSpan = longClickCopySpan[0]; + if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), + buffer.getSpanEnd(aSingleSpan)); + aSingleSpan.setHighlighted(true, + ContextCompat.getColor(widget.getContext(), R.color.touch_highlight)); + } else { + Selection.removeSelection(buffer); + aSingleSpan.setHighlighted(false, Color.TRANSPARENT); + } + + this.currentSpan = aSingleSpan; + this.widget = widget; + return gestureDetector.onTouchEvent(event); + } + } else if (action == MotionEvent.ACTION_CANCEL) { + // Remove Selections. + LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), + Selection.getSelectionEnd(buffer), LongClickCopySpan.class); + for (LongClickCopySpan aSpan : spans) { + aSpan.setHighlighted(false, Color.TRANSPARENT); + } + Selection.removeSelection(buffer); + return gestureDetector.onTouchEvent(event); + } + return super.onTouchEvent(widget, buffer, event); + } + + public static LongClickMovementMethod getInstance(Context context) { + if (sInstance == null) { + sInstance = new LongClickMovementMethod(context.getApplicationContext()); + } + return sInstance; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java new file mode 100644 index 00000000..8ed655e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import org.whispersystems.libsignal.util.guava.Function; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * A reusable and composable {@link androidx.recyclerview.widget.RecyclerView.Adapter} built on-top of {@link ListAdapter} to + * provide async item diffing support. + *

+ * The adapter makes use of mapping a model class to view holder factory at runtime via one of the {@link #registerFactory(Class, Factory)} + * methods. The factory creates a view holder specifically designed to handle the paired model type. This allows the view holder concretely + * deal with the model type it cares about. Due to the enforcement of matching generics during factory registration we can safely ignore or + * override compiler typing recommendations when binding and diffing. + *

+ * General pattern for implementation: + *
    + *
  1. Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.
  2. + *
  3. Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.
  4. + *
  5. Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.
  6. + *
+ * Event listeners, click or otherwise, are handled at the view holder level and should be passed into the appropriate view holder factories. This + * pattern mimics how we pass data into view models via factories. + *

+ * NOTE: There can only be on factory registered per model type. Registering two for the same type will result in the last one being used. However, the + * same factory can be registered multiple times for multiple model types (if the model type class hierarchy supports it). + */ +public class MappingAdapter extends ListAdapter, MappingViewHolder> { + + private final Map> factories; + private final Map, Integer> itemTypes; + private int typeCount; + + public MappingAdapter() { + super(new MappingDiffCallback()); + + factories = new HashMap<>(); + itemTypes = new HashMap<>(); + typeCount = 0; + } + + @Override + public void onViewAttachedToWindow(@NonNull MappingViewHolder holder) { + super.onViewAttachedToWindow(holder); + holder.onAttachedToWindow(); + } + + @Override + public void onViewDetachedFromWindow(@NonNull MappingViewHolder holder) { + super.onViewDetachedFromWindow(holder); + holder.onDetachedFromWindow(); + } + + public > void registerFactory(Class clazz, Factory factory) { + int type = typeCount++; + factories.put(type, factory); + itemTypes.put(clazz, type); + } + + @Override + public int getItemViewType(int position) { + Integer type = itemTypes.get(getItem(position).getClass()); + if (type != null) { + return type; + } + throw new AssertionError("No view holder factory for type: " + getItem(position).getClass()); + } + + @Override + public @NonNull MappingViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return Objects.requireNonNull(factories.get(viewType)).createViewHolder(parent); + } + + @Override + public void onBindViewHolder(@NonNull MappingViewHolder holder, int position) { + //noinspection unchecked + holder.bind(getItem(position)); + } + + private static class MappingDiffCallback extends DiffUtil.ItemCallback> { + @Override + public boolean areItemsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) { + if (oldItem.getClass() == newItem.getClass()) { + //noinspection unchecked + return oldItem.areItemsTheSame(newItem); + } + return false; + } + + @SuppressLint("DiffUtilEquals") + @Override + public boolean areContentsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) { + if (oldItem.getClass() == newItem.getClass()) { + //noinspection unchecked + return oldItem.areContentsTheSame(newItem); + } + return false; + } + } + + public interface Factory> { + @NonNull MappingViewHolder createViewHolder(@NonNull ViewGroup parent); + } + + public static class LayoutFactory> implements Factory { + private Function> creator; + private final int layout; + + public LayoutFactory(@NonNull Function> creator, @LayoutRes int layout) { + this.creator = creator; + this.layout = layout; + } + + @Override + public @NonNull MappingViewHolder createViewHolder(@NonNull ViewGroup parent) { + return creator.apply(LayoutInflater.from(parent.getContext()).inflate(layout, parent, false)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java new file mode 100644 index 00000000..0e5233d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +public interface MappingModel { + boolean areItemsTheSame(@NonNull T newItem); + boolean areContentsTheSame(@NonNull T newItem); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingModelList.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModelList.java new file mode 100644 index 00000000..0413eefb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModelList.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collector; +import com.annimon.stream.function.BiConsumer; +import com.annimon.stream.function.Function; +import com.annimon.stream.function.Supplier; + +import java.util.ArrayList; + +public class MappingModelList extends ArrayList> { + + public static @NonNull Collector, MappingModelList, MappingModelList> toMappingModelList() { + return new Collector, MappingModelList, MappingModelList>() { + @Override + public @NonNull Supplier supplier() { + return MappingModelList::new; + } + + @Override + public @NonNull BiConsumer> accumulator() { + return MappingModelList::add; + } + + @Override + public @NonNull Function finisher() { + return mappingModels -> mappingModels; + } + }; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java new file mode 100644 index 00000000..7648e2d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleOwner; + +public abstract class MappingViewHolder> extends LifecycleViewHolder implements LifecycleOwner { + + protected final Context context; + + public MappingViewHolder(@NonNull View itemView) { + super(itemView); + context = itemView.getContext(); + } + + public T findViewById(@IdRes int id) { + return itemView.findViewById(id); + } + + public @NonNull Context getContext() { + return itemView.getContext(); + } + + public abstract void bind(@NonNull Model model); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MathUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/MathUtils.java new file mode 100644 index 00000000..45ef3d48 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MathUtils.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +public class MathUtils { + + /** + * For more info: + * StackOverflow: How to check point is in rectangle + * + * @param pt point to check + * @param v1 vertex 1 of the triangle + * @param v2 vertex 2 of the triangle + * @param v3 vertex 3 of the triangle + * @return true if point (x, y) is inside the triangle + */ + public static boolean pointInTriangle(@NonNull PointF pt, @NonNull PointF v1, + @NonNull PointF v2, @NonNull PointF v3) { + + boolean b1 = crossProduct(pt, v1, v2) < 0.0f; + boolean b2 = crossProduct(pt, v2, v3) < 0.0f; + boolean b3 = crossProduct(pt, v3, v1) < 0.0f; + + return (b1 == b2) && (b2 == b3); + } + + /** + * calculates cross product of vectors AB and AC + * + * @param a beginning of 2 vectors + * @param b end of vector 1 + * @param c end of vector 2 + * @return cross product AB * AC + */ + private static float crossProduct(@NonNull PointF a, @NonNull PointF b, @NonNull PointF c) { + return crossProduct(a.x, a.y, b.x, b.y, c.x, c.y); + } + + /** + * calculates cross product of vectors AB and AC + * + * @param ax X coordinate of point A + * @param ay Y coordinate of point A + * @param bx X coordinate of point B + * @param by Y coordinate of point B + * @param cx X coordinate of point C + * @param cy Y coordinate of point C + * @return cross product AB * AC + */ + private static float crossProduct(float ax, float ay, float bx, float by, float cx, float cy) { + return (ax - cx) * (by - cy) - (bx - cx) * (ay - cy); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaMetadataRetrieverUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaMetadataRetrieverUtil.java new file mode 100644 index 00000000..04be39b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaMetadataRetrieverUtil.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.util; + +import android.media.MediaDataSource; +import android.media.MediaMetadataRetriever; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.io.IOException; + +public final class MediaMetadataRetrieverUtil { + + private MediaMetadataRetrieverUtil() {} + + /** + * {@link MediaMetadataRetriever#setDataSource(MediaDataSource)} tends to crash in native code on + * specific devices, so this just a wrapper to convert that into an {@link IOException}. + */ + @RequiresApi(23) + public static void setDataSource(@NonNull MediaMetadataRetriever retriever, + @NonNull MediaDataSource dataSource) + throws IOException + { + try { + retriever.setDataSource(dataSource); + } catch (Exception e) { + throw new IOException(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java new file mode 100644 index 00000000..845369d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -0,0 +1,444 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.media.MediaDataSource; +import android.media.MediaMetadataRetriever; +import android.media.ThumbnailUtils; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.util.Pair; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.gif.GifDrawable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.MmsSlide; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.mms.TextSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.mms.ViewOnceSlide; +import org.thoughtcrime.securesms.providers.BlobProvider; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLConnection; +import java.util.concurrent.ExecutionException; + +public class MediaUtil { + + private static final String TAG = MediaUtil.class.getSimpleName(); + + public static final String IMAGE_PNG = "image/png"; + public static final String IMAGE_JPEG = "image/jpeg"; + public static final String IMAGE_HEIC = "image/heic"; + public static final String IMAGE_HEIF = "image/heif"; + public static final String IMAGE_WEBP = "image/webp"; + public static final String IMAGE_GIF = "image/gif"; + public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_UNSPECIFIED = "audio/*"; + public static final String VIDEO_MP4 = "video/mp4"; + public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String VCARD = "text/x-vcard"; + public static final String LONG_TEXT = "text/x-signal-plain"; + public static final String VIEW_ONCE = "application/x-signal-view-once"; + public static final String UNKNOWN = "*/*"; + + public static SlideType getSlideTypeFromContentType(@NonNull String contentType) { + if (isGif(contentType)) { + return SlideType.GIF; + } else if (isImageType(contentType)) { + return SlideType.IMAGE; + } else if (isVideoType(contentType)) { + return SlideType.VIDEO; + } else if (isAudioType(contentType)) { + return SlideType.AUDIO; + } else if (isMms(contentType)) { + return SlideType.MMS; + } else if (isLongTextType(contentType)) { + return SlideType.LONG_TEXT; + } else if (isViewOnceType(contentType)) { + return SlideType.VIEW_ONCE; + } else { + return SlideType.DOCUMENT; + } + } + + public static @NonNull Slide getSlideForAttachment(Context context, Attachment attachment) { + if (attachment.isSticker()) { + return new StickerSlide(context, attachment); + } + + switch (getSlideTypeFromContentType(attachment.getContentType())) { + case GIF : return new GifSlide(context, attachment); + case IMAGE : return new ImageSlide(context, attachment); + case VIDEO : return new VideoSlide(context, attachment); + case AUDIO : return new AudioSlide(context, attachment); + case MMS : return new MmsSlide(context, attachment); + case LONG_TEXT : return new TextSlide(context, attachment); + case VIEW_ONCE : return new ViewOnceSlide(context, attachment); + case DOCUMENT : return new DocumentSlide(context, attachment); + default : throw new AssertionError(); + } + } + + public static @Nullable String getMimeType(@NonNull Context context, @Nullable Uri uri) { + if (uri == null) return null; + + if (PartAuthority.isLocalUri(uri)) { + return PartAuthority.getAttachmentContentType(context, uri); + } + + String type = context.getContentResolver().getType(uri); + if (type == null) { + final String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + } + + return getCorrectedMimeType(type); + } + + public static @Nullable String getExtension(@NonNull Context context, @Nullable Uri uri) { + return MimeTypeMap.getSingleton() + .getExtensionFromMimeType(getMimeType(context, uri)); + } + + public static @Nullable String getCorrectedMimeType(@Nullable String mimeType) { + if (mimeType == null) return null; + + switch(mimeType) { + case "image/jpg": + return MimeTypeMap.getSingleton().hasMimeType(IMAGE_JPEG) + ? IMAGE_JPEG + : mimeType; + default: + return mimeType; + } + } + + public static long getMediaSize(Context context, Uri uri) throws IOException { + InputStream in = PartAuthority.getAttachmentStream(context, uri); + if (in == null) throw new IOException("Couldn't obtain input stream."); + + long size = 0; + byte[] buffer = new byte[4096]; + int read; + + while ((read = in.read(buffer)) != -1) { + size += read; + } + in.close(); + + return size; + } + + @WorkerThread + public static Pair getDimensions(@NonNull Context context, @Nullable String contentType, @Nullable Uri uri) { + if (uri == null || (!MediaUtil.isImageType(contentType) && !MediaUtil.isVideoType(contentType))) { + return new Pair<>(0, 0); + } + + Pair dimens = null; + + if (MediaUtil.isGif(contentType)) { + try { + GifDrawable drawable = GlideApp.with(context) + .asGif() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .load(new DecryptableUri(uri)) + .submit() + .get(); + dimens = new Pair<>(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } catch (InterruptedException e) { + Log.w(TAG, "Was unable to complete work for GIF dimensions.", e); + } catch (ExecutionException e) { + Log.w(TAG, "Glide experienced an exception while trying to get GIF dimensions.", e); + } + } else if (MediaUtil.hasVideoThumbnail(context, uri)) { + Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, 1000); + + if (thumbnail != null) { + dimens = new Pair<>(thumbnail.getWidth(), thumbnail.getHeight()); + } + } else { + InputStream attachmentStream = null; + try { + if (MediaUtil.isJpegType(contentType)) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getExifDimensions(attachmentStream); + attachmentStream.close(); + attachmentStream = null; + } + if (dimens == null) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getDimensions(attachmentStream); + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find file when retrieving media dimensions.", e); + } catch (IOException e) { + Log.w(TAG, "Experienced a read error when retrieving media dimensions.", e); + } catch (BitmapDecodingException e) { + Log.w(TAG, "Bitmap decoding error when retrieving dimensions.", e); + } finally { + if (attachmentStream != null) { + try { + attachmentStream.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close stream after retrieving dimensions.", e); + } + } + } + } + if (dimens == null) { + dimens = new Pair<>(0, 0); + } + Log.d(TAG, "Dimensions for [" + uri + "] are " + dimens.first + " x " + dimens.second); + return dimens; + } + + public static boolean isMms(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms"); + } + + public static boolean isGif(Attachment attachment) { + return isGif(attachment.getContentType()); + } + + public static boolean isJpeg(Attachment attachment) { + return isJpegType(attachment.getContentType()); + } + + public static boolean isHeic(Attachment attachment) { + return isHeicType(attachment.getContentType()); + } + + public static boolean isHeif(Attachment attachment) { + return isHeifType(attachment.getContentType()); + } + + public static boolean isImage(Attachment attachment) { + return isImageType(attachment.getContentType()); + } + + public static boolean isAudio(Attachment attachment) { + return isAudioType(attachment.getContentType()); + } + + public static boolean isVideo(Attachment attachment) { + return isVideoType(attachment.getContentType()); + } + + public static boolean isVideo(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/"); + } + + public static boolean isVcard(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD); + } + + public static boolean isGif(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif"); + } + + public static boolean isJpegType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG); + } + + public static boolean isHeicType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIC); + } + + public static boolean isHeifType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIF); + } + + public static boolean isFile(Attachment attachment) { + return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment); + } + + public static boolean isTextType(String contentType) { + return (null != contentType) && contentType.startsWith("text/"); + } + + public static boolean isImageType(String contentType) { + return (null != contentType) && contentType.startsWith("image/"); + } + + public static boolean isAudioType(String contentType) { + return (null != contentType) && contentType.startsWith("audio/"); + } + + public static boolean isVideoType(String contentType) { + return (null != contentType) && contentType.startsWith("video/"); + } + + public static boolean isImageOrVideoType(String contentType) { + return isImageType(contentType) || isVideoType(contentType); + } + + public static boolean isLongTextType(String contentType) { + return (null != contentType) && contentType.equals(LONG_TEXT); + } + + public static boolean isViewOnceType(String contentType) { + return (null != contentType) && contentType.equals(VIEW_ONCE); + } + + public static boolean hasVideoThumbnail(@NonNull Context context, @Nullable Uri uri) { + if (uri == null) { + return false; + } + + if (BlobProvider.isAuthority(uri) && MediaUtil.isVideo(BlobProvider.getMimeType(uri)) && Build.VERSION.SDK_INT >= 23) { + return true; + } + + if (!isSupportedVideoUriScheme(uri.getScheme())) { + return false; + } + + if ("com.android.providers.media.documents".equals(uri.getAuthority())) { + return uri.getLastPathSegment().contains("video"); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + return true; + } else if (uri.toString().startsWith("file://") && + MediaUtil.isVideo(URLConnection.guessContentTypeFromName(uri.toString()))) { + return true; + } else if (PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri))) { + return true; + } else { + return false; + } + } + + @WorkerThread + public static @Nullable Bitmap getVideoThumbnail(@NonNull Context context, @Nullable Uri uri, long timeUs) { + if (uri == null) { + return null; + } else if ("com.android.providers.media.documents".equals(uri.getAuthority())) { + long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]); + + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), + videoId, + MediaStore.Images.Thumbnails.MINI_KIND, + null); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + long videoId = Long.parseLong(uri.getLastPathSegment()); + + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), + videoId, + MediaStore.Images.Thumbnails.MINI_KIND, + null); + } else if (uri.toString().startsWith("file://") && + MediaUtil.isVideo(URLConnection.guessContentTypeFromName(uri.toString()))) { + return ThumbnailUtils.createVideoThumbnail(uri.toString().replace("file://", ""), + MediaStore.Video.Thumbnails.MINI_KIND); + } else if (Build.VERSION.SDK_INT >= 23 && + BlobProvider.isAuthority(uri) && + MediaUtil.isVideo(BlobProvider.getMimeType(uri))) + { + try { + MediaDataSource source = BlobProvider.getInstance().getMediaDataSource(context, uri); + return extractFrame(source, timeUs); + } catch (IOException e) { + Log.w(TAG, "Failed to extract frame for URI: " + uri, e); + } + } else if (Build.VERSION.SDK_INT >= 23 && + PartAuthority.isAttachmentUri(uri) && + MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri))) + { + try { + AttachmentId attachmentId = PartAuthority.requireAttachmentId(uri); + MediaDataSource source = DatabaseFactory.getAttachmentDatabase(context).mediaDataSourceFor(attachmentId); + return extractFrame(source, timeUs); + } catch (IOException e) { + Log.w(TAG, "Failed to extract frame for URI: " + uri, e); + } + } + + return null; + } + + @RequiresApi(23) + private static @Nullable Bitmap extractFrame(@Nullable MediaDataSource dataSource, long timeUs) throws IOException { + if (dataSource == null) { + return null; + } + + MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); + + MediaMetadataRetrieverUtil.setDataSource(mediaMetadataRetriever, dataSource); + return mediaMetadataRetriever.getFrameAtTime(timeUs); + } + + public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) { + final String[] sections = mimeType.split("/", 2); + return sections.length > 1 ? sections[0] : null; + } + + public static class ThumbnailData implements AutoCloseable { + + @NonNull private final Bitmap bitmap; + private final float aspectRatio; + + public ThumbnailData(@NonNull Bitmap bitmap) { + this.bitmap = bitmap; + this.aspectRatio = (float) bitmap.getWidth() / (float) bitmap.getHeight(); + } + + public @NonNull Bitmap getBitmap() { + return bitmap; + } + + public float getAspectRatio() { + return aspectRatio; + } + + public InputStream toDataStream() { + return BitmapUtil.toCompressedJpeg(bitmap); + } + + @Override + public void close() { + bitmap.recycle(); + } + } + + private static boolean isSupportedVideoUriScheme(@Nullable String scheme) { + return ContentResolver.SCHEME_CONTENT.equals(scheme) || + ContentResolver.SCHEME_FILE.equals(scheme); + } + + public enum SlideType { + GIF, + IMAGE, + VIDEO, + AUDIO, + MMS, + LONG_TEXT, + VIEW_ONCE, + DOCUMENT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java new file mode 100644 index 00000000..f75902a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java @@ -0,0 +1,192 @@ +package org.thoughtcrime.securesms.util; + +import android.app.ActivityManager; +import android.content.Context; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.io.Closeable; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.text.NumberFormat; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicLong; + +public final class MemoryFileDescriptor implements Closeable { + + private static final String TAG = Log.tag(MemoryFileDescriptor.class); + + private static Boolean supported; + + private final ParcelFileDescriptor parcelFileDescriptor; + private final AtomicLong sizeEstimate; + + /** + * Does this device support memory file descriptor. + */ + public synchronized static boolean supported() { + if (supported == null) { + try { + int fileDescriptor = FileUtils.createMemoryFileDescriptor("CHECK"); + + if (fileDescriptor < 0) { + supported = false; + Log.w(TAG, "MemoryFileDescriptor is not available."); + } else { + supported = true; + ParcelFileDescriptor.adoptFd(fileDescriptor).close(); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + return supported; + } + + /** + * memfd files do not show on the available RAM, so we must track our allocations in addition. + */ + private static long sizeOfAllMemoryFileDescriptors; + + private MemoryFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor, long sizeEstimate) { + this.parcelFileDescriptor = parcelFileDescriptor; + this.sizeEstimate = new AtomicLong(sizeEstimate); + } + + /** + * @param debugName The name supplied in name is used as a filename and will be displayed + * as the target of the corresponding symbolic link in the directory + * /proc/self/fd/. The displayed name is always prefixed with memfd: + * and serves only for debugging purposes. Names do not affect the + * behavior of the file descriptor, and as such multiple files can have + * the same name without any side effects. + * @param sizeEstimate An estimated upper bound on this file. This is used to check there will be + * enough RAM available and to register with a global counter of reservations. + * Use zero to avoid RAM check. + * @return MemoryFileDescriptor + * @throws MemoryLimitException If there is not enough available RAM to comfortably fit this file. + * @throws MemoryFileCreationException If fails to create a memory file descriptor. + */ + public static MemoryFileDescriptor newMemoryFileDescriptor(@NonNull Context context, + @NonNull String debugName, + long sizeEstimate) + throws MemoryFileException + { + if (sizeEstimate < 0) throw new IllegalArgumentException(); + + if (sizeEstimate > 0) { + ActivityManager activityManager = ServiceUtil.getActivityManager(context); + ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); + + synchronized (MemoryFileDescriptor.class) { + activityManager.getMemoryInfo(memoryInfo); + + long remainingRam = memoryInfo.availMem - memoryInfo.threshold - sizeEstimate - sizeOfAllMemoryFileDescriptors; + + if (remainingRam <= 0) { + NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); + Log.w(TAG, String.format("Not enough RAM available without taking the system into a low memory state.%n" + + "Available: %s%n" + + "Low memory threshold: %s%n" + + "Requested: %s%n" + + "Total MemoryFileDescriptor limit: %s%n" + + "Shortfall: %s", + numberFormat.format(memoryInfo.availMem), + numberFormat.format(memoryInfo.threshold), + numberFormat.format(sizeEstimate), + numberFormat.format(sizeOfAllMemoryFileDescriptors), + numberFormat.format(remainingRam) + )); + throw new MemoryLimitException(); + } + + sizeOfAllMemoryFileDescriptors += sizeEstimate; + } + } + + int fileDescriptor = FileUtils.createMemoryFileDescriptor(debugName); + + if (fileDescriptor < 0) { + Log.w(TAG, "Failed to create file descriptor: " + fileDescriptor); + throw new MemoryFileCreationException(); + } + + return new MemoryFileDescriptor(ParcelFileDescriptor.adoptFd(fileDescriptor), sizeEstimate); + } + + @Override + public void close() throws IOException { + try { + clearAndRemoveAllocation(); + } catch (Exception e) { + Log.w(TAG, "Failed to clear data in MemoryFileDescriptor", e); + } finally { + parcelFileDescriptor.close(); + } + } + + private void clearAndRemoveAllocation() throws IOException { + clear(); + + long oldEstimate = sizeEstimate.getAndSet(0); + + synchronized (MemoryFileDescriptor.class) { + sizeOfAllMemoryFileDescriptors -= oldEstimate; + } + } + + /** Rewinds and clears all bytes. */ + private void clear() throws IOException { + long size; + try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { + FileChannel channel = fileInputStream.getChannel(); + size = channel.size(); + + if (size == 0) return; + + channel.position(0); + } + byte[] zeros = new byte[16 * 1024]; + + try (FileOutputStream output = new FileOutputStream(getFileDescriptor())) { + while (size > 0) { + int limit = (int) Math.min(size, zeros.length); + + output.write(zeros, 0, limit); + + size -= limit; + } + } + } + + public FileDescriptor getFileDescriptor() { + return parcelFileDescriptor.getFileDescriptor(); + } + + public void seek(long position) throws IOException { + try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { + fileInputStream.getChannel().position(position); + } + } + + public long size() throws IOException { + try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { + return fileInputStream.getChannel().size(); + } + } + + public static class MemoryFileException extends IOException { + } + + private static final class MemoryLimitException extends MemoryFileException { + } + + private static final class MemoryFileCreationException extends MemoryFileException { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java new file mode 100644 index 00000000..3f238678 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; +import android.os.ProxyFileDescriptorCallback; +import android.os.storage.StorageManager; +import android.system.ErrnoException; +import android.system.OsConstants; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +@RequiresApi(api = 26) +final class MemoryFileDescriptorProxy { + + private static final String TAG = Log.tag(MemoryFileDescriptorProxy.class); + + public static ParcelFileDescriptor create(@NonNull Context context, + @NonNull MemoryFile file) + throws IOException + { + StorageManager storageManager = Objects.requireNonNull(context.getSystemService(StorageManager.class)); + HandlerThread thread = new HandlerThread("MemoryFile"); + + thread.start(); + Log.i(TAG, "Thread started"); + + Handler handler = new Handler(thread.getLooper()); + ProxyCallback proxyCallback = new ProxyCallback(file, () -> { + if (thread.quitSafely()) { + Log.i(TAG, "Thread quitSafely true"); + } else { + Log.w(TAG, "Thread quitSafely false"); + } + }); + + ParcelFileDescriptor parcelFileDescriptor = storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, + proxyCallback, + handler); + + Log.i(TAG, "Created"); + return parcelFileDescriptor; + } + + private static final class ProxyCallback extends ProxyFileDescriptorCallback { + + private final MemoryFile memoryFile; + private final Runnable onClose; + + ProxyCallback(@NonNull MemoryFile memoryFile, Runnable onClose) { + this.memoryFile = memoryFile; + this.onClose = onClose; + } + + @Override + public long onGetSize() { + return memoryFile.length(); + } + + @Override + public int onRead(long offset, int size, byte[] data) throws ErrnoException { + try { + InputStream inputStream = memoryFile.getInputStream(); + if(inputStream.skip(offset) != offset){ + throw new AssertionError(); + } + return inputStream.read(data, 0, size); + } catch (IOException e) { + throw new ErrnoException("onRead", OsConstants.EBADF); + } + } + + @Override + public void onRelease() { + Log.i(TAG, "onRelease()"); + memoryFile.close(); + onClose.run(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java new file mode 100644 index 00000000..d8725e9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public final class MemoryFileUtil { + + private MemoryFileUtil() {} + + public static ParcelFileDescriptor getParcelFileDescriptor(@NonNull MemoryFile file) + throws IOException + { + if (Build.VERSION.SDK_INT >= 26) { + return MemoryFileDescriptorProxy.create(ApplicationDependencies.getApplication(), file); + } else { + return getParcelFileDescriptorLegacy(file); + } + } + + @SuppressWarnings("JavaReflectionMemberAccess") + @SuppressLint("PrivateApi") + public static ParcelFileDescriptor getParcelFileDescriptorLegacy(@NonNull MemoryFile file) + throws IOException + { + try { + Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); + FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file); + + Field field = fileDescriptor.getClass().getDeclaredField("descriptor"); + field.setAccessible(true); + + int fd = field.getInt(fileDescriptor); + + return ParcelFileDescriptor.adoptFd(fd); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | NoSuchFieldException e) { + throw new IOException(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java new file mode 100644 index 00000000..b77fb476 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.text.DecimalFormat; + +/** + * Used for the pretty formatting of bytes for user display. + */ +public enum MemoryUnitFormat { + BYTES(" B"), + KILO_BYTES(" kB"), + MEGA_BYTES(" MB"), + GIGA_BYTES(" GB"), + TERA_BYTES(" TB"); + + private static final DecimalFormat ONE_DP = new DecimalFormat("#,##0.0"); + private static final DecimalFormat OPTIONAL_ONE_DP = new DecimalFormat("#,##0.#"); + + private final String unitString; + + MemoryUnitFormat(String unitString) { + this.unitString = unitString; + } + + public double fromBytes(long bytes) { + return bytes / Math.pow(1000, ordinal()); + } + + /** + * Creates a string suitable to present to the user from the specified {@param bytes}. + * It will pick a suitable unit of measure to display depending on the size of the bytes. + * It will not select a unit of measure lower than the specified {@param minimumUnit}. + * + * @param forceOneDp If true, will include 1 decimal place, even if 0. If false, will only show 1 dp when it's non-zero. + */ + public static String formatBytes(long bytes, @NonNull MemoryUnitFormat minimumUnit, boolean forceOneDp) { + if (bytes <= 0) bytes = 0; + + int ordinal = bytes != 0 ? (int) (Math.log10(bytes) / 3) : 0; + + if (ordinal >= MemoryUnitFormat.values().length) { + ordinal = MemoryUnitFormat.values().length - 1; + } + + MemoryUnitFormat unit = MemoryUnitFormat.values()[ordinal]; + + if (unit.ordinal() < minimumUnit.ordinal()) { + unit = minimumUnit; + } + + return (forceOneDp ? ONE_DP : OPTIONAL_ONE_DP).format(unit.fromBytes(bytes)) + unit.unitString; + } + + public static String formatBytes(long bytes) { + return formatBytes(bytes, BYTES, false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java new file mode 100644 index 00000000..955ebbbf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.mms.Slide; + +public final class MessageRecordUtil { + + private MessageRecordUtil() { + } + + public static boolean isMediaMessage(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null; + } + + public static boolean hasSticker(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null; + } + + public static boolean hasSharedContact(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty(); + } + + public static boolean hasLocation(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && Stream.of(((MmsMessageRecord) messageRecord).getSlideDeck().getSlides()) + .anyMatch(Slide::hasLocation); + } + + public static boolean hasAudio(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java new file mode 100644 index 00000000..e166c15d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.mms.TextSlide; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public final class MessageUtil { + + private MessageUtil() {} + + /** + * @return If the message is longer than the allowed text size, this will return trimmed text with + * an accompanying TextSlide. Otherwise it'll just return the original text. + */ + public static SplitResult getSplitMessage(@NonNull Context context, @NonNull String rawText, int maxPrimaryMessageSize) { + String bodyText = rawText; + Optional textSlide = Optional.absent(); + + if (bodyText.length() > maxPrimaryMessageSize) { + bodyText = rawText.substring(0, maxPrimaryMessageSize); + + byte[] textData = rawText.getBytes(); + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date()); + String filename = String.format("signal-%s.txt", timestamp); + Uri textUri = BlobProvider.getInstance() + .forData(textData) + .withMimeType(MediaUtil.LONG_TEXT) + .withFileName(filename) + .createForSingleSessionInMemory(); + + textSlide = Optional.of(new TextSlide(context, textUri, filename, textData.length)); + } + + return new SplitResult(bodyText, textSlide); + } + + public static class SplitResult { + private final String body; + private final Optional textSlide; + + private SplitResult(@NonNull String body, @NonNull Optional textSlide) { + this.body = body; + this.textSlide = textSlide; + } + + public @NonNull String getBody() { + return body; + } + + public @NonNull Optional getTextSlide() { + return textSlide; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java new file mode 100644 index 00000000..c25d47a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.util; + +public class MmsCharacterCalculator extends CharacterCalculator { + + private static final int MAX_SIZE = 5000; + + @Override + public CharacterState calculateCharacters(String messageBody) { + return new CharacterState(1, MAX_SIZE - messageBody.length(), MAX_SIZE, MAX_SIZE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtil.java new file mode 100644 index 00000000..e031a241 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtil.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import androidx.annotation.NonNull; + +import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public final class NetworkUtil { + + private NetworkUtil() {} + + public static boolean isConnectedWifi(@NonNull Context context) { + final NetworkInfo info = getNetworkInfo(context); + return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_WIFI; + } + + public static boolean isConnectedMobile(@NonNull Context context) { + final NetworkInfo info = getNetworkInfo(context); + return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_MOBILE; + } + + public static boolean isConnectedRoaming(@NonNull Context context) { + final NetworkInfo info = getNetworkInfo(context); + return info != null && info.isConnected() && info.isRoaming() && info.getType() == ConnectivityManager.TYPE_MOBILE; + } + + public static @NonNull CallManager.BandwidthMode getCallingBandwidthMode(@NonNull Context context) { + return useLowBandwidthCalling(context) ? CallManager.BandwidthMode.LOW : CallManager.BandwidthMode.NORMAL; + } + + private static boolean useLowBandwidthCalling(@NonNull Context context) { + switch (SignalStore.settings().getCallBandwidthMode()) { + case HIGH_ON_WIFI: + return !NetworkUtil.isConnectedWifi(context); + case HIGH_ALWAYS: + return false; + default: + return true; + } + } + + private static NetworkInfo getNetworkInfo(@NonNull Context context) { + return ServiceUtil.getConnectivityManager(context).getActiveNetworkInfo(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ObservingLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/util/ObservingLiveData.java new file mode 100644 index 00000000..7bc2e2eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ObservingLiveData.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.util; + +import android.database.ContentObserver; + +import androidx.annotation.NonNull; +import androidx.lifecycle.MutableLiveData; + +import org.signal.core.util.StreamUtil; +import org.thoughtcrime.securesms.database.ObservableContent; + +import java.io.Closeable; + +/** + * Implementation of {@link androidx.lifecycle.LiveData} that will handle closing the contained + * {@link Closeable} when the value changes. + */ +public class ObservingLiveData extends MutableLiveData { + + private ContentObserver observer; + + @Override + public void setValue(E value) { + E previous = getValue(); + + if (previous != null) { + previous.unregisterContentObserver(observer); + StreamUtil.close(previous); + } + + value.registerContentObserver(observer); + + super.setValue(value); + } + + public void close() { + E value = getValue(); + + if (value != null) { + value.unregisterContentObserver(observer); + StreamUtil.close(value); + } + } + + public void registerContentObserver(@NonNull ContentObserver observer) { + this.observer = observer; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/OkHttpUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/OkHttpUtil.java new file mode 100644 index 00000000..631aaed1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/OkHttpUtil.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.Objects; + +import okhttp3.MediaType; +import okhttp3.ResponseBody; + +import static okhttp3.internal.Util.UTF_8; + +public final class OkHttpUtil { + + private OkHttpUtil() {} + + public static byte[] readAsBytes(@NonNull InputStream bodyStream, long sizeLimit) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + byte[] buffer = new byte[(int) ByteUnit.KILOBYTES.toBytes(32)]; + int readLength = 0; + int totalLength = 0; + + while ((readLength = bodyStream.read(buffer)) >= 0) { + if (totalLength + readLength > sizeLimit) { + throw new IOException("Exceeded maximum size during read!"); + } + + outputStream.write(buffer, 0, readLength); + totalLength += readLength; + } + + return outputStream.toByteArray(); + } + public static String readAsString(@NonNull ResponseBody body, long sizeLimit) throws IOException { + if (body.contentLength() > sizeLimit) { + throw new IOException("Content-Length exceeded maximum size!"); + } + + byte[] data = readAsBytes(body.byteStream(), sizeLimit); + MediaType contentType = body.contentType(); + Charset charset = contentType != null ? contentType.charset(UTF_8) : UTF_8; + + return new String(data, Objects.requireNonNull(charset)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java new file mode 100644 index 00000000..4a16e223 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +public class ParcelUtil { + + public static byte[] serialize(Parcelable parceable) { + Parcel parcel = Parcel.obtain(); + parceable.writeToParcel(parcel, 0); + byte[] bytes = parcel.marshall(); + parcel.recycle(); + return bytes; + } + + public static Parcel deserialize(byte[] bytes) { + Parcel parcel = Parcel.obtain(); + parcel.unmarshall(bytes, 0, bytes.length); + parcel.setDataPosition(0); + return parcel; + } + + public static T deserialize(byte[] bytes, Parcelable.Creator creator) { + Parcel parcel = deserialize(bytes); + return creator.createFromParcel(parcel); + } + + public static void writeStringCollection(@NonNull Parcel dest, @NonNull Collection collection) { + dest.writeStringList(new ArrayList<>(collection)); + } + + public static @NonNull Collection readStringCollection(@NonNull Parcel in) { + List list = new ArrayList<>(); + in.readStringList(list); + return list; + } + + public static void writeParcelableCollection(@NonNull Parcel dest, @NonNull Collection collection) { + Parcelable[] values = collection.toArray(new Parcelable[0]); + dest.writeParcelableArray(values, 0); + } + + public static @NonNull Collection readParcelableCollection(@NonNull Parcel in, Class clazz) { + //noinspection unchecked + return Arrays.asList((E[]) in.readParcelableArray(clazz.getClassLoader())); + } + + public static void writeBoolean(@NonNull Parcel dest, boolean value) { + dest.writeByte(value ? (byte) 1 : 0); + } + + public static boolean readBoolean(@NonNull Parcel in) { + return in.readByte() != 0; + } + + public static void writeByteArray(@NonNull Parcel dest, @Nullable byte[] data) { + if (data == null) { + dest.writeInt(-1); + } else { + dest.writeInt(data.length); + dest.writeByteArray(data); + } + } + + public static @Nullable byte[] readByteArray(@NonNull Parcel in) { + int length = in.readInt(); + if (length == -1) { + return null; + } + byte[] data = new byte[length]; + in.readByteArray(data); + return data; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java new file mode 100644 index 00000000..07928f3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.util; + + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +import org.signal.core.util.logging.Log; + +public class PlayServicesUtil { + + private static final String TAG = PlayServicesUtil.class.getSimpleName(); + + public enum PlayServicesStatus { + SUCCESS, + MISSING, + NEEDS_UPDATE, + TRANSIENT_ERROR + } + + public static PlayServicesStatus getPlayServicesStatus(Context context) { + int gcmStatus = 0; + + try { + gcmStatus = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + } catch (Throwable t) { + Log.w(TAG, t); + return PlayServicesStatus.MISSING; + } + + Log.i(TAG, "Play Services: " + gcmStatus); + + switch (gcmStatus) { + case ConnectionResult.SUCCESS: + return PlayServicesStatus.SUCCESS; + case ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED: + try { + ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo("com.google.android.gms", 0); + + if (applicationInfo != null && !applicationInfo.enabled) { + return PlayServicesStatus.MISSING; + } + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, e); + } + + return PlayServicesStatus.NEEDS_UPDATE; + case ConnectionResult.SERVICE_DISABLED: + case ConnectionResult.SERVICE_MISSING: + case ConnectionResult.SERVICE_INVALID: + case ConnectionResult.API_UNAVAILABLE: + case ConnectionResult.SERVICE_MISSING_PERMISSION: + return PlayServicesStatus.MISSING; + default: + return PlayServicesStatus.TRANSIENT_ERROR; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java new file mode 100644 index 00000000..80df712e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.BuildConfig; + +public final class PlayStoreUtil { + + private PlayStoreUtil() { + } + + public static void openPlayStoreOrOurApkDownloadPage(@NonNull Context context) { + if (BuildConfig.PLAY_STORE_DISABLED) { + CommunicationActions.openBrowserLink(context, "https://signal.org/android/apk"); + } else { + openPlayStore(context); + } + } + + private static void openPlayStore(@NonNull Context context) { + String packageName = context.getPackageName(); + + try { + context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + packageName))); + } catch (ActivityNotFoundException e) { + CommunicationActions.openBrowserLink(context, "https://play.google.com/store/apps/details?id=" + packageName); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PopulationFeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/PopulationFeatureFlags.java new file mode 100644 index 00000000..43d69cd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PopulationFeatureFlags.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses a comma-separated list of country codes colon-separated from how many buckets out of 1 million + * should be enabled to see this megaphone in that country code. At the end of the list, an optional + * element saying how many buckets out of a million should be enabled for all countries not listed previously + * in the list. For example, "1:20000,*:40000" would mean 2% of the NANPA phone numbers and 4% of the rest of + * the world should see the megaphone. + */ +public final class PopulationFeatureFlags { + + private static final String TAG = Log.tag(PopulationFeatureFlags.class); + + private static final String COUNTRY_WILDCARD = "*"; + + /** + * In research megaphone group for given country code + */ + public static boolean isInResearchMegaphone() { + return false; + } + + /** + * In donate megaphone group for given country code + */ + public static boolean isInDonateMegaphone() { + return isEnabled(FeatureFlags.DONATE_MEGAPHONE, FeatureFlags.donateMegaphone()); + } + + private static boolean isEnabled(@NonNull String flag, @NonNull String serialized) { + Map countryCountEnabled = parseCountryCounts(serialized); + Recipient self = Recipient.self(); + + if (countryCountEnabled.isEmpty() || !self.getE164().isPresent() || !self.getUuid().isPresent()) { + return false; + } + + long countEnabled = determineCountEnabled(countryCountEnabled, self.getE164().or("")); + long currentUserBucket = BucketingUtil.bucket(flag, self.requireUuid(), 1_000_000); + + return countEnabled > currentUserBucket; + } + + @VisibleForTesting + static @NonNull Map parseCountryCounts(@NonNull String buckets) { + Map countryCountEnabled = new HashMap<>(); + + for (String bucket : buckets.split(",")) { + String[] parts = bucket.split(":"); + if (parts.length == 2 && !parts[0].isEmpty()) { + countryCountEnabled.put(parts[0], Util.parseInt(parts[1], 0)); + } + } + return countryCountEnabled; + } + + @VisibleForTesting + static long determineCountEnabled(@NonNull Map countryCountEnabled, @NonNull String e164) { + Integer countEnabled = countryCountEnabled.get(COUNTRY_WILDCARD); + try { + String countryCode = String.valueOf(PhoneNumberUtil.getInstance().parse(e164, "").getCountryCode()); + if (countryCountEnabled.containsKey(countryCode)) { + countEnabled = countryCountEnabled.get(countryCode); + } + } catch (NumberParseException e) { + Log.d(TAG, "Unable to determine country code for bucketing."); + return 0; + } + + return countEnabled != null ? countEnabled : 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PowerManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/util/PowerManagerCompat.java new file mode 100644 index 00000000..312278d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PowerManagerCompat.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Build; +import android.os.PowerManager; + +import androidx.annotation.NonNull; + +public class PowerManagerCompat { + + public static boolean isDeviceIdleMode(@NonNull PowerManager powerManager) { + if (Build.VERSION.SDK_INT >= 23) { + return powerManager.isDeviceIdleMode(); + } + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java new file mode 100644 index 00000000..ca394022 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -0,0 +1,224 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.SignalServiceMessagePipe; +import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.util.StreamDetails; +import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture; +import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; + +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Aids in the retrieval and decryption of profiles. + */ +public final class ProfileUtil { + + private static final String TAG = Log.tag(ProfileUtil.class); + + private ProfileUtil() { + } + + @WorkerThread + public static @NonNull ProfileAndCredential retrieveProfileSync(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull SignalServiceProfile.RequestType requestType) + throws IOException + { + try { + return retrieveProfile(context, recipient, requestType).get(10, TimeUnit.SECONDS); + } catch (ExecutionException e) { + if (e.getCause() instanceof PushNetworkException) { + throw (PushNetworkException) e.getCause(); + } else if (e.getCause() instanceof NotFoundException) { + throw (NotFoundException) e.getCause(); + } else { + throw new IOException(e); + } + } catch (InterruptedException | TimeoutException e) { + throw new PushNetworkException(e); + } + } + + public static @NonNull ListenableFuture retrieveProfile(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull SignalServiceProfile.RequestType requestType) + { + SignalServiceAddress address = toSignalServiceAddress(context, recipient); + Optional unidentifiedAccess = getUnidentifiedAccess(context, recipient); + Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); + + if (unidentifiedAccess.isPresent()) { + return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address, profileKey, unidentifiedAccess, requestType), + () -> getSocketRetrievalFuture(address, profileKey, unidentifiedAccess, requestType), + () -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType), + () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)), + e -> !(e instanceof NotFoundException)); + } else { + return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType), + () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)), + e -> !(e instanceof NotFoundException)); + } + } + + public static @Nullable String decryptName(@NonNull ProfileKey profileKey, @Nullable String encryptedName) + throws InvalidCiphertextException, IOException + { + if (encryptedName == null) { + return null; + } + + ProfileCipher profileCipher = new ProfileCipher(profileKey); + return new String(profileCipher.decryptName(Base64.decode(encryptedName))); + } + + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * profile name instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithName(@NonNull Context context, @NonNull ProfileName profileName) throws IOException { + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + uploadProfile(context, + profileName, + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + avatar); + } + } + + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * about/emoji instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithAbout(@NonNull Context context, @NonNull String about, @NonNull String emoji) throws IOException { + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + uploadProfile(context, + Recipient.self().getProfileName(), + about, + emoji, + avatar); + } + } + + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * avatar instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithAvatar(@NonNull Context context, @Nullable StreamDetails avatar) throws IOException { + uploadProfile(context, + Recipient.self().getProfileName(), + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + avatar); + } + + /** + * Uploads the profile based on all state that's already written to disk. + */ + public static void uploadProfile(@NonNull Context context) throws IOException { + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + uploadProfile(context, + Recipient.self().getProfileName(), + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + avatar); + } + } + + private static void uploadProfile(@NonNull Context context, + @NonNull ProfileName profileName, + @Nullable String about, + @Nullable String aboutEmoji, + @Nullable StreamDetails avatar) + throws IOException + { + Log.d(TAG, "Uploading " + (!Util.isEmpty(about) ? "non-" : "") + "empty about."); + Log.d(TAG, "Uploading " + (!Util.isEmpty(aboutEmoji) ? "non-" : "") + "empty emoji."); + + ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + String avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), about, aboutEmoji, avatar).orNull(); + + DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); + } + + private static @NonNull ListenableFuture getPipeRetrievalFuture(@NonNull SignalServiceAddress address, + @NonNull Optional profileKey, + @NonNull Optional unidentifiedAccess, + @NonNull SignalServiceProfile.RequestType requestType) + throws IOException + { + SignalServiceMessagePipe authPipe = IncomingMessageObserver.getPipe(); + SignalServiceMessagePipe unidentifiedPipe = IncomingMessageObserver.getUnidentifiedPipe(); + SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent() ? unidentifiedPipe + : authPipe; + if (pipe != null) { + return pipe.getProfile(address, profileKey, unidentifiedAccess, requestType); + } + + throw new IOException("No pipe available!"); + } + + private static @NonNull ListenableFuture getSocketRetrievalFuture(@NonNull SignalServiceAddress address, + @NonNull Optional profileKey, + @NonNull Optional unidentifiedAccess, + @NonNull SignalServiceProfile.RequestType requestType) + { + SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver(); + return receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType); + } + + private static Optional getUnidentifiedAccess(@NonNull Context context, @NonNull Recipient recipient) { + Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient, false); + + if (unidentifiedAccess.isPresent()) { + return unidentifiedAccess.get().getTargetUnidentifiedAccess(); + } + + return Optional.absent(); + } + + private static @NonNull SignalServiceAddress toSignalServiceAddress(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.getRegistered() == RecipientDatabase.RegisteredState.NOT_REGISTERED) { + return new SignalServiceAddress(recipient.getUuid().orNull(), recipient.getE164().orNull()); + } else { + return RecipientUtil.toSignalServiceAddressBestEffort(context, recipient); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java new file mode 100644 index 00000000..93561487 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2015 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +public class PushCharacterCalculator extends CharacterCalculator { + private static final int MAX_TOTAL_SIZE = 64 * 1024; + private static final int MAX_PRIMARY_SIZE = 2000; + @Override + public CharacterState calculateCharacters(String messageBody) { + return new CharacterState(1, MAX_TOTAL_SIZE - messageBody.length(), MAX_TOTAL_SIZE, MAX_PRIMARY_SIZE); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RedPhoneCallTypes.java b/app/src/main/java/org/thoughtcrime/securesms/util/RedPhoneCallTypes.java new file mode 100644 index 00000000..d5f1a136 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RedPhoneCallTypes.java @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +public interface RedPhoneCallTypes { + public static final int INCOMING = 1023; + public static final int OUTGOING = 1024; + public static final int MISSED = 1025; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java new file mode 100644 index 00000000..4c42c52b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +public final class RemoteDeleteUtil { + + private static final long RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1); + private static final long SEND_THRESHOLD = TimeUnit.HOURS.toMillis(3); + + private RemoteDeleteUtil() {} + + public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull Recipient deleteSender, long deleteServerTimestamp) { + boolean isValidIncomingOutgoing = (deleteSender.isSelf() && targetMessage.isOutgoing()) || + (!deleteSender.isSelf() && !targetMessage.isOutgoing()); + + boolean isValidSender = targetMessage.getIndividualRecipient().equals(deleteSender) || + deleteSender.isSelf() && targetMessage.isOutgoing(); + + long messageTimestamp = deleteSender.isSelf() && targetMessage.isOutgoing() ? targetMessage.getDateSent() + : targetMessage.getServerTimestamp(); + + return isValidIncomingOutgoing && + isValidSender && + (deleteServerTimestamp - messageTimestamp) < RECEIVE_THRESHOLD; + } + + public static boolean isValidSend(@NonNull Collection targetMessages, long currentTime) { + // TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages + return Stream.of(targetMessages).allMatch(message -> isValidSend(message, currentTime)); + } + + private static boolean isValidSend(MessageRecord message, long currentTime) { + return !message.isUpdate() && + message.isOutgoing() && + message.isPush() && + (!message.getRecipient().isGroup() || message.getRecipient().isActiveGroup()) && + !message.getRecipient().isSelf() && + !message.isRemoteDelete() && + (currentTime - message.getDateSent()) < SEND_THRESHOLD; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java new file mode 100644 index 00000000..d5a416bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeprecation.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; + +import java.io.IOException; +import java.util.Objects; + +public final class RemoteDeprecation { + + private static final String TAG = Log.tag(RemoteDeprecation.class); + + private RemoteDeprecation() { } + + /** + * @return The amount of time (in milliseconds) until this client version expires, or -1 if + * there's no pending expiration. + */ + public static long getTimeUntilDeprecation() { + return getTimeUntilDeprecation(FeatureFlags.clientExpiration(), System.currentTimeMillis(), BuildConfig.VERSION_NAME); + } + + /** + * @return The amount of time (in milliseconds) until this client version expires, or -1 if + * there's no pending expiration. + */ + @VisibleForTesting + static long getTimeUntilDeprecation(String json, long currentTime, @NonNull String currentVersion) { + if (Util.isEmpty(json)) { + return -1; + } + + try { + SemanticVersion ourVersion = Objects.requireNonNull(SemanticVersion.parse(currentVersion)); + ClientExpiration[] expirations = JsonUtils.fromJson(json, ClientExpiration[].class); + + ClientExpiration expiration = Stream.of(expirations) + .filter(c -> c.getVersion() != null && c.getExpiration() != -1) + .filter(c -> c.requireVersion().compareTo(ourVersion) > 0) + .sortBy(ClientExpiration::getExpiration) + .findFirst() + .orElse(null); + + if (expiration != null) { + return Math.max(expiration.getExpiration() - currentTime, 0); + } + } catch (IOException e) { + Log.w(TAG, e); + } + + return -1; + } + + private static final class ClientExpiration { + @JsonProperty + private final String minVersion; + + @JsonProperty + private final String iso8601; + + ClientExpiration(@Nullable @JsonProperty("minVersion") String minVersion, + @Nullable @JsonProperty("iso8601") String iso8601) + { + this.minVersion = minVersion; + this.iso8601 = iso8601; + } + + public @Nullable SemanticVersion getVersion() { + return SemanticVersion.parse(minVersion); + } + + public @NonNull SemanticVersion requireVersion() { + return Objects.requireNonNull(getVersion()); + } + + public long getExpiration() { + return DateUtils.parseIso8601(iso8601); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RequestCodes.java b/app/src/main/java/org/thoughtcrime/securesms/util/RequestCodes.java new file mode 100644 index 00000000..8d833b27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RequestCodes.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.util; + +public final class RequestCodes { + + public static final int NOT_SET = -1; + + private RequestCodes() { } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java new file mode 100644 index 00000000..76947b39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ResUtil.java @@ -0,0 +1,70 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Resources.Theme; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; + +import androidx.annotation.ArrayRes; +import androidx.annotation.AttrRes; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; + +public class ResUtil { + + public static int getColor(Context context, @AttrRes int attr) { + final TypedArray styledAttributes = context.obtainStyledAttributes(new int[]{attr}); + final int result = styledAttributes.getColor(0, -1); + styledAttributes.recycle(); + return result; + } + + public static int getDrawableRes(Context c, @AttrRes int attr) { + return getDrawableRes(c.getTheme(), attr); + } + + public static int getDrawableRes(Theme theme, @AttrRes int attr) { + final TypedValue out = new TypedValue(); + theme.resolveAttribute(attr, out, true); + return out.resourceId; + } + + public static Drawable getDrawable(Context c, @AttrRes int attr) { + return AppCompatResources.getDrawable(c, getDrawableRes(c, attr)); + } + + public static int[] getResourceIds(Context c, @ArrayRes int array) { + final TypedArray typedArray = c.getResources().obtainTypedArray(array); + final int[] resourceIds = new int[typedArray.length()]; + for (int i = 0; i < typedArray.length(); i++) { + resourceIds[i] = typedArray.getResourceId(i, 0); + } + typedArray.recycle(); + return resourceIds; + } + + public static float getFloat(@NonNull Context context, @DimenRes int resId) { + TypedValue value = new TypedValue(); + context.getResources().getValue(resId, value, true); + return value.getFloat(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java b/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java new file mode 100644 index 00000000..deba6b65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.util; + +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +public class Rfc5724Uri { + + private final String uri; + private final String schema; + private final String path; + private final Map queryParams; + + public Rfc5724Uri(String uri) throws URISyntaxException { + this.uri = uri; + this.schema = parseSchema(); + this.path = parsePath(); + this.queryParams = parseQueryParams(); + } + + private String parseSchema() throws URISyntaxException { + String[] parts = uri.split(":"); + + if (parts.length < 1 || parts[0].isEmpty()) throw new URISyntaxException(uri, "invalid schema"); + else return parts[0]; + } + + private String parsePath() throws URISyntaxException { + String[] parts = uri.split("\\?")[0].split(":", 2); + + if (parts.length < 2 || parts[1].isEmpty()) throw new URISyntaxException(uri, "invalid path"); + else return parts[1]; + } + + private Map parseQueryParams() throws URISyntaxException { + Map queryParams = new HashMap<>(); + if (uri.split("\\?").length < 2) { + return queryParams; + } + + for (String keyValue : uri.split("\\?")[1].split("&")) { + String[] parts = keyValue.split("="); + + if (parts.length == 1) queryParams.put(parts[0], ""); + else queryParams.put(parts[0], URLDecoder.decode(parts[1])); + } + + return queryParams; + } + + public String getSchema() { + return schema; + } + + public String getPath() { + return path; + } + + public Map getQueryParams() { + return queryParams; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java new file mode 100644 index 00000000..0e8e55d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java @@ -0,0 +1,310 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.whispersystems.libsignal.util.Pair; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.ref.WeakReference; +import java.text.SimpleDateFormat; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public class SaveAttachmentTask extends ProgressDialogAsyncTask> { + private static final String TAG = SaveAttachmentTask.class.getSimpleName(); + + static final int SUCCESS = 0; + private static final int FAILURE = 1; + private static final int WRITE_ACCESS_FAILURE = 2; + + private final WeakReference contextReference; + + private final int attachmentCount; + + public SaveAttachmentTask(Context context) { + this(context, 1); + } + + public SaveAttachmentTask(Context context, int count) { + super(context, + context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), + context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count)); + this.contextReference = new WeakReference<>(context); + this.attachmentCount = count; + } + + @Override + protected Pair doInBackground(SaveAttachmentTask.Attachment... attachments) { + if (attachments == null || attachments.length == 0) { + throw new AssertionError("must pass in at least one attachment"); + } + + try { + Context context = contextReference.get(); + String directory = null; + + if (!StorageUtil.canWriteToMediaStore()) { + return new Pair<>(WRITE_ACCESS_FAILURE, null); + } + + if (context == null) { + return new Pair<>(FAILURE, null); + } + + for (Attachment attachment : attachments) { + if (attachment != null) { + directory = saveAttachment(context, attachment); + if (directory == null) return new Pair<>(FAILURE, null); + } + } + + if (attachments.length > 1) return new Pair<>(SUCCESS, null); + else return new Pair<>(SUCCESS, directory); + } catch (IOException ioe) { + Log.w(TAG, ioe); + return new Pair<>(FAILURE, null); + } + } + + private @Nullable String saveAttachment(Context context, Attachment attachment) throws IOException + { + String contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType)); + String fileName = attachment.fileName; + + if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date); + fileName = sanitizeOutputFileName(fileName); + + Uri outputUri = getMediaStoreContentUriForType(contentType); + Uri mediaUri = createOutputUri(outputUri, contentType, fileName); + ContentValues updateValues = new ContentValues(); + + try (InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)) { + + if (inputStream == null) { + return null; + } + + if (Objects.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + try (OutputStream outputStream = new FileOutputStream(mediaUri.getPath())) { + StreamUtil.copy(inputStream, outputStream); + MediaScannerConnection.scanFile(context, new String[]{mediaUri.getPath()}, new String[]{contentType}, null); + } + } else { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(mediaUri, "w")) { + long total = StreamUtil.copy(inputStream, outputStream); + if (total > 0) { + updateValues.put(MediaStore.MediaColumns.SIZE, total); + } + } + } + } + + if (Build.VERSION.SDK_INT > 28) { + updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0); + } + + if (updateValues.size() > 0) { + getContext().getContentResolver().update(mediaUri, updateValues, null, null); + } + + return outputUri.getLastPathSegment(); + } + + private @NonNull Uri getMediaStoreContentUriForType(@NonNull String contentType) { + if (contentType.startsWith("video/")) { + return StorageUtil.getVideoUri(); + } else if (contentType.startsWith("audio/")) { + return StorageUtil.getAudioUri(); + } else if (contentType.startsWith("image/")) { + return StorageUtil.getImageUri(); + } else { + return StorageUtil.getDownloadUri(); + } + } + + public String getExternalPathToFileForType(String contentType) { + File storage; + if (contentType.startsWith("video/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + } else if (contentType.startsWith("audio/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + } else if (contentType.startsWith("image/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + } else { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); + } + return storage.getAbsolutePath(); + } + + private String generateOutputFileName(@NonNull String contentType, long timestamp) { + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = mimeTypeMap.getExtensionFromMimeType(contentType); + SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss"); + String base = "signal-" + dateFormatter.format(timestamp); + + if (extension == null) extension = "attach"; + + return base + "." + extension; + } + + private String sanitizeOutputFileName(@NonNull String fileName) { + return new File(fileName).getName(); + } + + private Uri createOutputUri(@NonNull Uri outputUri, @NonNull String contentType, @NonNull String fileName) + throws IOException + { + String[] fileParts = getFileNameParts(fileName); + String base = fileParts[0]; + String extension = fileParts[1]; + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())); + contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())); + + if (Build.VERSION.SDK_INT > 28) { + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1); + } else if (Objects.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + File outputDirectory = new File(outputUri.getPath()); + File outputFile = new File(outputDirectory, base + "." + extension); + + int i = 0; + while (outputFile.exists()) { + outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension); + } + + if (outputFile.isHidden()) { + throw new IOException("Specified name would not be visible"); + } + + return Uri.fromFile(outputFile); + } else { + String outputFileName = fileName; + String dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName); + int i = 0; + while (pathTaken(outputUri, dataPath)) { + Log.d(TAG, "The content exists. Rename and check again."); + outputFileName = base + "-" + (++i) + "." + extension; + dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName); + } + contentValues.put(MediaStore.MediaColumns.DATA, dataPath); + } + + return getContext().getContentResolver().insert(outputUri, contentValues); + } + + private boolean pathTaken(@NonNull Uri outputUri, @NonNull String dataPath) throws IOException { + try (Cursor cursor = getContext().getContentResolver().query(outputUri, + new String[] { MediaStore.MediaColumns.DATA }, + MediaStore.MediaColumns.DATA + " = ?", + new String[] { dataPath }, + null)) + { + if (cursor == null) { + throw new IOException("Something is wrong with the filename to save"); + } + return cursor.moveToFirst(); + } + } + + private String[] getFileNameParts(String fileName) { + String[] result = new String[2]; + String[] tokens = fileName.split("\\.(?=[^\\.]+$)"); + + result[0] = tokens[0]; + + if (tokens.length > 1) result[1] = tokens[1]; + else result[1] = ""; + + return result; + } + + @Override + protected void onPostExecute(final Pair result) { + super.onPostExecute(result); + final Context context = contextReference.get(); + if (context == null) return; + + switch (result.first()) { + case FAILURE: + Toast.makeText(context, + context.getResources().getQuantityText(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, + attachmentCount), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + String message = !TextUtils.isEmpty(result.second()) ? context.getResources().getString(R.string.SaveAttachmentTask_saved_to, result.second()) + : context.getResources().getString(R.string.SaveAttachmentTask_saved); + Toast.makeText(context, message, Toast.LENGTH_LONG).show(); + break; + case WRITE_ACCESS_FAILURE: + Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation, + Toast.LENGTH_LONG).show(); + break; + } + } + + public static class Attachment { + public Uri uri; + public String fileName; + public String contentType; + public long date; + + public Attachment(@NonNull Uri uri, @NonNull String contentType, + long date, @Nullable String fileName) + { + if (uri == null || contentType == null || date < 0) { + throw new AssertionError("uri, content type, and date must all be specified"); + } + this.uri = uri; + this.fileName = fileName; + this.contentType = contentType; + this.date = date; + } + } + + public static void showWarningDialog(Context context, OnClickListener onAcceptListener) { + showWarningDialog(context, onAcceptListener, 1); + } + + public static void showWarningDialog(Context context, OnClickListener onAcceptListener, int count) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.ConversationFragment_save_to_sd_card); + builder.setIcon(R.drawable.ic_warning); + builder.setCancelable(true); + builder.setMessage(context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_media_to_storage_warning, + count, count)); + builder.setPositiveButton(R.string.yes, onAcceptListener); + builder.setNegativeButton(R.string.no, null); + builder.show(); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java new file mode 100644 index 00000000..f61f56ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.util; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.CharacterStyle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.whispersystems.libsignal.util.Pair; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +public class SearchUtil { + + public static Spannable getHighlightedSpan(@NonNull Locale locale, + @NonNull StyleFactory styleFactory, + @Nullable String text, + @Nullable String highlight) + { + if (TextUtils.isEmpty(text)) { + return new SpannableString(""); + } + + text = text.replaceAll("\n", " "); + + return getHighlightedSpan(locale, styleFactory, new SpannableString(text), highlight); + } + + public static Spannable getHighlightedSpan(@NonNull Locale locale, + @NonNull StyleFactory styleFactory, + @Nullable Spannable text, + @Nullable String highlight) + { + if (TextUtils.isEmpty(text)) { + return new SpannableString(""); + } + + + if (TextUtils.isEmpty(highlight)) { + return text; + } + + List> ranges = getHighlightRanges(locale, text.toString(), highlight); + SpannableString spanned = new SpannableString(text); + + for (Pair range : ranges) { + spanned.setSpan(styleFactory.create(), range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + + return spanned; + } + + static List> getHighlightRanges(@NonNull Locale locale, + @NonNull String text, + @NonNull String highlight) + { + if (text.length() == 0) { + return Collections.emptyList(); + } + + String normalizedText = text.toLowerCase(locale); + String normalizedHighlight = highlight.toLowerCase(locale); + List highlightTokens = Stream.of(normalizedHighlight.split("\\s")).filter(s -> s.trim().length() > 0).toList(); + + List> ranges = new LinkedList<>(); + + int lastHighlightEndIndex = 0; + + for (String highlightToken : highlightTokens) { + int index; + + do { + index = normalizedText.indexOf(highlightToken, lastHighlightEndIndex); + lastHighlightEndIndex = index + highlightToken.length(); + } while (index > 0 && !Character.isWhitespace(normalizedText.charAt(index - 1))); + + if (index >= 0) { + ranges.add(new Pair<>(index, lastHighlightEndIndex)); + } + + if (index < 0 || lastHighlightEndIndex >= normalizedText.length()) { + break; + } + } + + if (ranges.size() != highlightTokens.size()) { + return Collections.emptyList(); + } + + return ranges; + } + + public interface StyleFactory { + CharacterStyle create(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java b/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java new file mode 100644 index 00000000..c122af67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SemanticVersion.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; + +import com.annimon.stream.ComparatorCompat; + +import java.util.Comparator; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class SemanticVersion implements Comparable { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^([0-9]+)\\.([0-9]+)\\.([0-9]+)$"); + + private static final Comparator MAJOR_COMPARATOR = (s1, s2) -> Integer.compare(s1.major, s2.major); + private static final Comparator MINOR_COMPARATOR = (s1, s2) -> Integer.compare(s1.minor, s2.minor); + private static final Comparator PATCH_COMPARATOR = (s1, s2) -> Integer.compare(s1.patch, s2.patch); + private static final Comparator COMPARATOR = ComparatorCompat.chain(MAJOR_COMPARATOR) + .thenComparing(MINOR_COMPARATOR) + .thenComparing(PATCH_COMPARATOR); + + private final int major; + private final int minor; + private final int patch; + + public SemanticVersion(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + public static @Nullable SemanticVersion parse(@Nullable String value) { + if (value == null) { + return null; + } + + Matcher matcher = VERSION_PATTERN.matcher(value); + if (Util.isEmpty(value) || !matcher.matches()) { + return null; + } + + int major = Integer.parseInt(matcher.group(1)); + int minor = Integer.parseInt(matcher.group(2)); + int patch = Integer.parseInt(matcher.group(3)); + + return new SemanticVersion(major, minor, patch); + } + + @Override + public int compareTo(SemanticVersion other) { + return COMPARATOR.compare(this, other); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SemanticVersion that = (SemanticVersion) o; + return major == that.major && + minor == that.minor && + patch == that.patch; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor, patch); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java new file mode 100644 index 00000000..67baf5b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.app.ActivityManager; +import android.app.AlarmManager; +import android.app.NotificationManager; +import android.app.job.JobScheduler; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.pm.ShortcutManager; +import android.hardware.SensorManager; +import android.hardware.display.DisplayManager; +import android.location.LocationManager; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.os.PowerManager; +import android.os.Vibrator; +import android.os.storage.StorageManager; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityManager; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; + +public class ServiceUtil { + public static InputMethodManager getInputMethodManager(Context context) { + return (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + @RequiresApi(25) + public static @Nullable ShortcutManager getShortcutManager(@NonNull Context context) { + return ContextCompat.getSystemService(context, ShortcutManager.class); + } + + public static WindowManager getWindowManager(Context context) { + return (WindowManager) context.getSystemService(Activity.WINDOW_SERVICE); + } + + public static StorageManager getStorageManager(Context context) { + return ContextCompat.getSystemService(context, StorageManager.class); + } + + public static ConnectivityManager getConnectivityManager(Context context) { + return (ConnectivityManager) context.getSystemService(Activity.CONNECTIVITY_SERVICE); + } + + public static NotificationManager getNotificationManager(Context context) { + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + public static TelephonyManager getTelephonyManager(Context context) { + return (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); + } + + public static AudioManager getAudioManager(Context context) { + return (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); + } + + public static SensorManager getSensorManager(Context context) { + return (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + } + + public static PowerManager getPowerManager(Context context) { + return (PowerManager)context.getSystemService(Context.POWER_SERVICE); + } + + public static AlarmManager getAlarmManager(Context context) { + return (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); + } + + public static Vibrator getVibrator(Context context) { + return (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE); + } + + public static DisplayManager getDisplayManager(@NonNull Context context) { + return (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + } + + public static AccessibilityManager getAccessibilityManager(@NonNull Context context) { + return (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + } + + public static ClipboardManager getClipboardManager(@NonNull Context context) { + return (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + } + + @RequiresApi(26) + public static JobScheduler getJobScheduler(Context context) { + return (JobScheduler) context.getSystemService(JobScheduler.class); + } + + @RequiresApi(22) + public static @Nullable SubscriptionManager getSubscriptionManager(@NonNull Context context) { + return (SubscriptionManager) context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE); + } + + public static ActivityManager getActivityManager(@NonNull Context context) { + return (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + } + + public static LocationManager getLocationManager(@NonNull Context context) { + return ContextCompat.getSystemService(context, LocationManager.class); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java new file mode 100644 index 00000000..b739b265 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; + +import com.google.android.collect.Sets; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class SetUtil { + private SetUtil() {} + + public static Set intersection(Collection a, Collection b) { + Set intersection = new LinkedHashSet<>(a); + intersection.retainAll(b); + return intersection; + } + + public static Set difference(Collection a, Collection b) { + Set difference = new LinkedHashSet<>(a); + difference.removeAll(b); + return difference; + } + + public static Set union(Set a, Set b) { + Set result = new LinkedHashSet<>(a); + result.addAll(b); + return result; + } + + @SuppressLint("NewApi") + public static HashSet newHashSet(E... elements) { + return Sets.newHashSet(elements); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ShortCodeUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ShortCodeUtil.java new file mode 100644 index 00000000..08a86bd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ShortCodeUtil.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.i18n.phonenumbers.ShortNumberInfo; + +import org.signal.core.util.logging.Log; + +import java.util.HashSet; +import java.util.Set; + +public class ShortCodeUtil { + + private static final String TAG = ShortCodeUtil.class.getSimpleName(); + + private static final Set SHORT_COUNTRIES = new HashSet() {{ + add("NU"); + add("TK"); + add("NC"); + add("AC"); + }}; + + public static boolean isShortCode(@NonNull String localNumber, @NonNull String number) { + try { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + Phonenumber.PhoneNumber localNumberObject = util.parse(localNumber, null); + String localCountryCode = util.getRegionCodeForNumber(localNumberObject); + String bareNumber = number.replaceAll("[^0-9+]", ""); + + // libphonenumber seems incorrect for Russia and a few other countries with 4 digit short codes. + if (bareNumber.length() <= 4 && !SHORT_COUNTRIES.contains(localCountryCode)) { + return true; + } + + Phonenumber.PhoneNumber shortCode = util.parse(number, localCountryCode); + return ShortNumberInfo.getInstance().isPossibleShortNumberForRegion(shortCode, localCountryCode); + } catch (NumberParseException e) { + Log.w(TAG, e); + return false; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java new file mode 100644 index 00000000..8cdb6aff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.Observer; + +import org.conscrypt.Conscrypt; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.net.PipeConnectivityListener; +import org.thoughtcrime.securesms.push.AccountManagerFactory; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.internal.configuration.SignalProxy; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class SignalProxyUtil { + + private static final String TAG = Log.tag(SignalProxyUtil.class); + + private static final String PROXY_LINK_HOST = "signal.tube"; + + private static final Pattern PROXY_LINK_PATTERN = Pattern.compile("^(https|sgnl)://" + PROXY_LINK_HOST + "/#([^:]+).*$"); + private static final Pattern HOST_PATTERN = Pattern.compile("^([^:]+).*$"); + + private SignalProxyUtil() {} + + public static void startListeningToWebsocket() { + if (SignalStore.proxy().isProxyEnabled() && ApplicationDependencies.getPipeListener().getState().getValue() == PipeConnectivityListener.State.FAILURE) { + Log.w(TAG, "Proxy is in a failed state. Restarting."); + ApplicationDependencies.closeConnectionsAfterProxyFailure(); + } + + ApplicationDependencies.getIncomingMessageObserver(); + } + + /** + * Handles all things related to enabling a proxy, including saving it and resetting the relevant + * network connections. + */ + public static void enableProxy(@NonNull SignalProxy proxy) { + SignalStore.proxy().enableProxy(proxy); + Conscrypt.setUseEngineSocketByDefault(true); + ApplicationDependencies.resetNetworkConnectionsAfterProxyChange(); + startListeningToWebsocket(); + } + + /** + * Handles all things related to disabling a proxy, including saving the change and resetting the + * relevant network connections. + */ + public static void disableProxy() { + SignalStore.proxy().disableProxy(); + Conscrypt.setUseEngineSocketByDefault(false); + ApplicationDependencies.resetNetworkConnectionsAfterProxyChange(); + startListeningToWebsocket(); + } + + /** + * A blocking call that will wait until the websocket either successfully connects, or fails. + * It is assumed that the app state is already configured how you would like it, e.g. you've + * already configured a proxy if relevant. + * + * @return True if the connection is successful within the specified timeout, otherwise false. + */ + @WorkerThread + public static boolean testWebsocketConnection(long timeout) { + startListeningToWebsocket(); + + if (TextSecurePreferences.getLocalNumber(ApplicationDependencies.getApplication()) == null) { + Log.i(TAG, "User is unregistered! Doing simple check."); + return testWebsocketConnectionUnregistered(timeout); + } + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean success = new AtomicBoolean(false); + + Observer observer = state -> { + if (state == PipeConnectivityListener.State.CONNECTED) { + success.set(true); + latch.countDown(); + } else if (state == PipeConnectivityListener.State.FAILURE) { + success.set(false); + latch.countDown(); + } + }; + + Util.runOnMainSync(() -> ApplicationDependencies.getPipeListener().getState().observeForever(observer)); + + try { + latch.await(timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted!", e); + } finally { + Util.runOnMainSync(() -> ApplicationDependencies.getPipeListener().getState().removeObserver(observer)); + } + + return success.get(); + } + + /** + * If this is a valid proxy deep link, this will return the embedded host. If not, it will return + * null. + */ + public static @Nullable String parseHostFromProxyDeepLink(@Nullable String proxyLink) { + if (proxyLink == null) { + return null; + } + + Matcher matcher = PROXY_LINK_PATTERN.matcher(proxyLink); + + if (matcher.matches()) { + return matcher.group(2); + } else { + return null; + } + } + + /** + * Takes in an address that could be in various formats, and converts it to the format we should + * be storing and connecting to. + */ + public static @NonNull String convertUserEnteredAddressToHost(@NonNull String host) { + String parsedHost = SignalProxyUtil.parseHostFromProxyDeepLink(host); + if (parsedHost != null) { + return parsedHost; + } + + Matcher matcher = HOST_PATTERN.matcher(host); + + if (matcher.matches()) { + String result = matcher.group(1); + return result != null ? result : ""; + } else { + return host; + } + } + + public static @NonNull String generateProxyUrl(@NonNull String link) { + String host = link; + String parsed = parseHostFromProxyDeepLink(link); + + if (parsed != null) { + host = parsed; + } + + Matcher matcher = HOST_PATTERN.matcher(host); + + if (matcher.matches()) { + host = matcher.group(1); + } + + return "https://" + PROXY_LINK_HOST + "/#" + host; + } + + private static boolean testWebsocketConnectionUnregistered(long timeout) { + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean success = new AtomicBoolean(false); + SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(ApplicationDependencies.getApplication(), "", ""); + + SignalExecutors.UNBOUNDED.execute(() -> { + try { + accountManager.checkNetworkConnection(); + success.set(true); + latch.countDown(); + } catch (IOException e) { + latch.countDown(); + } + }); + + try { + latch.await(timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted!", e); + } + + return success.get(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java b/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java new file mode 100644 index 00000000..5ad6da99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public class SignalUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + + private static final String TAG = SignalUncaughtExceptionHandler.class.getSimpleName(); + + private final Thread.UncaughtExceptionHandler originalHandler; + + public SignalUncaughtExceptionHandler(@NonNull Thread.UncaughtExceptionHandler originalHandler) { + this.originalHandler = originalHandler; + } + + @Override + public void uncaughtException(Thread t, Throwable e) { + Log.e(TAG, "", e); + SignalStore.blockUntilAllWritesFinished(); + Log.blockUntilAllWritesFinished(); + ApplicationDependencies.getJobManager().flush(); + originalHandler.uncaughtException(t, e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SingleLiveEvent.java b/app/src/main/java/org/thoughtcrime/securesms/util/SingleLiveEvent.java new file mode 100644 index 00000000..3f6a7f13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SingleLiveEvent.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.util; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; + +import org.signal.core.util.logging.Log; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + *

+ * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + *

+ * Note that only one observer is going to be notified of changes. + */ +public class SingleLiveEvent extends MutableLiveData { + + private static final String TAG = SingleLiveEvent.class.getSimpleName(); + + private final AtomicBoolean mPending = new AtomicBoolean(false); + + @MainThread + public void observe(@NonNull LifecycleOwner owner, @NonNull final Observer observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes."); + } + + // Observe the internal MutableLiveData + super.observe(owner, t -> { + if (mPending.compareAndSet(true, false)) { + observer.onChanged(t); + } + }); + } + + @MainThread + public void setValue(@Nullable T t) { + mPending.set(true); + super.setValue(t); + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + public void call() { + setValue(null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java new file mode 100644 index 00000000..c3854d43 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.telephony.SmsMessage; + +import org.signal.core.util.logging.Log; + +public class SmsCharacterCalculator extends CharacterCalculator { + + private static final String TAG = SmsCharacterCalculator.class.getSimpleName(); + + @Override + public CharacterState calculateCharacters(String messageBody) { + int[] length; + int messagesSpent; + int charactersSpent; + int charactersRemaining; + + try { + length = SmsMessage.calculateLength(messageBody, false); + messagesSpent = length[0]; + charactersSpent = length[1]; + charactersRemaining = length[2]; + } catch (NullPointerException e) { + Log.w(TAG, e); + messagesSpent = 1; + charactersSpent = messageBody.length(); + charactersRemaining = 1000; + } + + int maxMessageSize; + + if (messagesSpent > 0) { + maxMessageSize = (charactersSpent + charactersRemaining) / messagesSpent; + } else { + maxMessageSize = (charactersSpent + charactersRemaining); + } + + return new CharacterState(messagesSpent, charactersRemaining, maxMessageSize, maxMessageSize); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SmsUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SmsUtil.java new file mode 100644 index 00000000..a0a7d062 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SmsUtil.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.util; + +import android.app.role.RoleManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.provider.Telephony; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +public final class SmsUtil { + + private SmsUtil() { + } + + /** + * Must be used with {@code startActivityForResult} + */ + public static @NonNull Intent getSmsRoleIntent(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 29) { + RoleManager roleManager = ContextCompat.getSystemService(context, RoleManager.class); + return roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS); + } else { + Intent intent = new Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT); + intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, context.getPackageName()); + return intent; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java new file mode 100644 index 00000000..a18a8800 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java @@ -0,0 +1,207 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; + +import java.util.Objects; + +/** + * Helper class to scroll to the top of a RecyclerView when new data is inserted. + * This works for both newly inserted data and moved data. It applies the following rules: + * + *

    + *
  • If the user is currently scrolled to some position, then we will not snap.
  • + *
  • If the user is currently dragging, then we will not snap.
  • + *
  • If the user has requested a scroll position, then we will only snap to that position.
  • + *
+ */ +public class SnapToTopDataObserver extends RecyclerView.AdapterDataObserver { + + private static final String TAG = Log.tag(SnapToTopDataObserver.class); + + private final RecyclerView recyclerView; + private final LinearLayoutManager layoutManager; + private final Deferred deferred; + private final ScrollRequestValidator scrollRequestValidator; + private final ScrollToTop scrollToTop; + + public SnapToTopDataObserver(@NonNull RecyclerView recyclerView) { + this(recyclerView, null, null); + } + + public SnapToTopDataObserver(@NonNull RecyclerView recyclerView, + @Nullable ScrollRequestValidator scrollRequestValidator, + @Nullable ScrollToTop scrollToTop) + { + this.recyclerView = recyclerView; + this.layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + this.deferred = new Deferred(); + this.scrollRequestValidator = scrollRequestValidator; + this.scrollToTop = scrollToTop == null ? () -> layoutManager.scrollToPosition(0) + : scrollToTop; + } + + /** + * Requests a scroll to a specific position. This call will defer until the position is loaded or + * becomes invalid. + * + * @param position The position to scroll to. + */ + public void requestScrollPosition(int position) { + buildScrollPosition(position).submit(); + } + + /** + * Creates a ScrollRequestBuilder which can be used to customize a particular scroll request with + * different callbacks. Don't forget to call `submit()`! + * + * @param position The position to scroll to. + * @return A ScrollRequestBuilder that must be submitted once you are satisfied with it. + */ + @CheckResult(suggest = "#requestScrollPosition(int)") + public ScrollRequestBuilder buildScrollPosition(int position) { + return new ScrollRequestBuilder(position); + } + + /** + * Requests that instead of snapping to top, we should scroll to a specific position in the adapter. + * It is up to the caller to ensure that the adapter will load the appropriate data, either by + * invalidating and restarting the page load at the appropriate position or by utilizing + * PagedList#loadAround(int). + * + * @param position The position to scroll to. + * @param onPerformScroll Callback allowing the caller to perform the scroll themselves. + * @param onScrollRequestComplete Notification that the scroll has completed successfully. + * @param onInvalidPosition Notification that the requested position has become invalid. + */ + private void requestScrollPositionInternal(int position, + @NonNull OnPerformScroll onPerformScroll, + @NonNull Runnable onScrollRequestComplete, + @NonNull Runnable onInvalidPosition) + { + Objects.requireNonNull(scrollRequestValidator, "Cannot request positions when SnapToTopObserver was initialized without a validator."); + + if (!scrollRequestValidator.isPositionStillValid(position)) { + Log.d(TAG, "requestScrollPositionInternal(" + position + ") Invalid"); + onInvalidPosition.run(); + } else if (scrollRequestValidator.isItemAtPositionLoaded(position)) { + Log.d(TAG, "requestScrollPositionInternal(" + position + ") Scrolling"); + onPerformScroll.onPerformScroll(layoutManager, position); + onScrollRequestComplete.run(); + } else { + Log.d(TAG, "requestScrollPositionInternal(" + position + ") Deferring"); + deferred.setDeferred(true); + deferred.defer(() -> { + Log.d(TAG, "requestScrollPositionInternal(" + position + ") Executing deferred"); + requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition); + }); + } + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + snapToTopIfNecessary(toPosition); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + snapToTopIfNecessary(positionStart); + } + + private void snapToTopIfNecessary(int newItemPosition) { + if (deferred.isDeferred()) { + deferred.setDeferred(false); + return; + } + + if (newItemPosition != 0 || + recyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE || + recyclerView.canScrollVertically(layoutManager.getReverseLayout() ? 1 : -1)) + { + return; + } + + if (layoutManager.findFirstVisibleItemPosition() == 0) { + Log.d(TAG, "Scrolling to top."); + scrollToTop.scrollToTop(); + } + } + + public interface ScrollRequestValidator { + /** + * This method is responsible for determining whether a given position is still a valid jump target. + * @param position The position to validate + * @return Whether the position is valid + */ + boolean isPositionStillValid(int position); + + /** + * This method is responsible for checking whether the desired position is available to be jumped to. + * In the case of a PagedListAdapter, it is whether getItem returns a non-null value. + * @param position The position to check for. + * @return Whether or not the data for the given position is loaded. + */ + boolean isItemAtPositionLoaded(int position); + } + + public interface OnPerformScroll { + /** + * This method is responsible for actually performing the requested scroll. It is always called + * immediately before the onScrollRequestComplete callback, and is always run via recyclerView.post(...) + * so you don't have to do this yourself. + * + * By default, SnapToTopDataObserver will utilize layoutManager.scrollToPosition. This lets you modify that + * behavior, and also gives you a chance to perform actions just before scrolling occurs. + * + * @param layoutManager The layoutManager containing your items. + * @param position The position to scroll to. + */ + void onPerformScroll(@NonNull LinearLayoutManager layoutManager, int position); + } + + /** + * Method Object for scrolling to the top of a view, in case special handling is desired. + */ + public interface ScrollToTop { + void scrollToTop(); + } + + public final class ScrollRequestBuilder { + private final int position; + + private OnPerformScroll onPerformScroll = LinearLayoutManager::scrollToPosition; + private Runnable onScrollRequestComplete = () -> {}; + private Runnable onInvalidPosition = () -> {}; + + public ScrollRequestBuilder(int position) { + this.position = position; + } + + @CheckResult + public ScrollRequestBuilder withOnPerformScroll(@NonNull OnPerformScroll onPerformScroll) { + this.onPerformScroll = onPerformScroll; + return this; + } + + @CheckResult + public ScrollRequestBuilder withOnScrollRequestComplete(@NonNull Runnable onScrollRequestComplete) { + this.onScrollRequestComplete = onScrollRequestComplete; + return this; + } + + @CheckResult + public ScrollRequestBuilder withOnInvalidPosition(@NonNull Runnable onInvalidPosition) { + this.onInvalidPosition = onInvalidPosition; + return this; + } + + public void submit() { + requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SoftHashMap.java b/app/src/main/java/org/thoughtcrime/securesms/util/SoftHashMap.java new file mode 100644 index 00000000..2d3396eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SoftHashMap.java @@ -0,0 +1,328 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A SoftHashMap is a memory-constrained map that stores its values in + * {@link SoftReference SoftReference}s. (Contrast this with the JDK's + * {@link WeakHashMap WeakHashMap}, which uses weak references for its keys, which is of little value if you + * want the cache to auto-resize itself based on memory constraints). + *

+ * Having the values wrapped by soft references allows the cache to automatically reduce its size based on memory + * limitations and garbage collection. This ensures that the cache will not cause memory leaks by holding strong + * references to all of its values. + *

+ * This class is a generics-enabled Map based on initial ideas from Heinz Kabutz's and Sydney Redelinghuys's + * publicly posted version (with their approval), with + * continued modifications. + *

+ * This implementation is thread-safe and usable in concurrent environments. + * + * @since 1.0 + */ +public class SoftHashMap implements Map { + + /** + * The default value of the RETENTION_SIZE attribute, equal to 100. + */ + private static final int DEFAULT_RETENTION_SIZE = 100; + + /** + * The internal HashMap that will hold the SoftReference. + */ + private final Map> map; + + /** + * The number of strong references to hold internally, that is, the number of instances to prevent + * from being garbage collected automatically (unlike other soft references). + */ + private final int RETENTION_SIZE; + + /** + * The FIFO list of strong references (not to be garbage collected), order of last access. + */ + private final Queue strongReferences; //guarded by 'strongReferencesLock' + private final ReentrantLock strongReferencesLock; + + /** + * Reference queue for cleared SoftReference objects. + */ + private final ReferenceQueue queue; + + /** + * Creates a new SoftHashMap with a default retention size size of + * {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries). + * + * @see #SoftHashMap(int) + */ + public SoftHashMap() { + this(DEFAULT_RETENTION_SIZE); + } + + /** + * Creates a new SoftHashMap with the specified retention size. + *

+ * The retention size (n) is the total number of most recent entries in the map that will be strongly referenced + * (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to + * allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n) + * elements retained after a GC due to the strong references. + *

+ * Note that in a highly concurrent environments the exact total number of strong references may differ slightly + * than the actual retentionSize value. This number is intended to be a best-effort retention low + * water mark. + * + * @param retentionSize the total number of most recent entries in the map that will be strongly referenced + * (retained), preventing them from being eagerly garbage collected by the JVM. + */ + @SuppressWarnings({"unchecked"}) + public SoftHashMap(int retentionSize) { + super(); + RETENTION_SIZE = Math.max(0, retentionSize); + queue = new ReferenceQueue(); + strongReferencesLock = new ReentrantLock(); + map = new ConcurrentHashMap>(); + strongReferences = new ConcurrentLinkedQueue(); + } + + /** + * Creates a {@code SoftHashMap} backed by the specified {@code source}, with a default retention + * size of {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries). + * + * @param source the backing map to populate this {@code SoftHashMap} + * @see #SoftHashMap(Map,int) + */ + public SoftHashMap(Map source) { + this(DEFAULT_RETENTION_SIZE); + putAll(source); + } + + /** + * Creates a {@code SoftHashMap} backed by the specified {@code source}, with the specified retention size. + *

+ * The retention size (n) is the total number of most recent entries in the map that will be strongly referenced + * (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to + * allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n) + * elements retained after a GC due to the strong references. + *

+ * Note that in a highly concurrent environments the exact total number of strong references may differ slightly + * than the actual retentionSize value. This number is intended to be a best-effort retention low + * water mark. + * + * @param source the backing map to populate this {@code SoftHashMap} + * @param retentionSize the total number of most recent entries in the map that will be strongly referenced + * (retained), preventing them from being eagerly garbage collected by the JVM. + */ + public SoftHashMap(Map source, int retentionSize) { + this(retentionSize); + putAll(source); + } + + public V get(Object key) { + processQueue(); + + V result = null; + SoftValue value = map.get(key); + + if (value != null) { + //unwrap the 'real' value from the SoftReference + result = value.get(); + if (result == null) { + //The wrapped value was garbage collected, so remove this entry from the backing map: + //noinspection SuspiciousMethodCalls + map.remove(key); + } else { + //Add this value to the beginning of the strong reference queue (FIFO). + addToStrongReferences(result); + } + } + return result; + } + + private void addToStrongReferences(V result) { + strongReferencesLock.lock(); + try { + strongReferences.add(result); + trimStrongReferencesIfNecessary(); + } finally { + strongReferencesLock.unlock(); + } + + } + + //Guarded by the strongReferencesLock in the addToStrongReferences method + + private void trimStrongReferencesIfNecessary() { + //trim the strong ref queue if necessary: + while (strongReferences.size() > RETENTION_SIZE) { + strongReferences.poll(); + } + } + + /** + * Traverses the ReferenceQueue and removes garbage-collected SoftValue objects from the backing map + * by looking them up using the SoftValue.key data member. + */ + private void processQueue() { + SoftValue sv; + while ((sv = (SoftValue) queue.poll()) != null) { + //noinspection SuspiciousMethodCalls + map.remove(sv.key); // we can access private data! + } + } + + public boolean isEmpty() { + processQueue(); + return map.isEmpty(); + } + + public boolean containsKey(Object key) { + processQueue(); + return map.containsKey(key); + } + + public boolean containsValue(Object value) { + processQueue(); + Collection values = values(); + return values != null && values.contains(value); + } + + public void putAll(@NonNull Map m) { + if (m == null || m.isEmpty()) { + processQueue(); + return; + } + for (Map.Entry entry : m.entrySet()) { + put(entry.getKey(), entry.getValue()); + } + } + + public @NonNull Set keySet() { + processQueue(); + return map.keySet(); + } + + public @NonNull Collection values() { + processQueue(); + Collection keys = map.keySet(); + if (keys.isEmpty()) { + //noinspection unchecked + return Collections.EMPTY_SET; + } + Collection values = new ArrayList(keys.size()); + for (K key : keys) { + V v = get(key); + if (v != null) { + values.add(v); + } + } + return values; + } + + /** + * Creates a new entry, but wraps the value in a SoftValue instance to enable auto garbage collection. + */ + public V put(@NonNull K key, @NonNull V value) { + processQueue(); // throw out garbage collected values first + SoftValue sv = new SoftValue(value, key, queue); + SoftValue previous = map.put(key, sv); + addToStrongReferences(value); + return previous != null ? previous.get() : null; + } + + public V remove(Object key) { + processQueue(); // throw out garbage collected values first + SoftValue raw = map.remove(key); + return raw != null ? raw.get() : null; + } + + public void clear() { + strongReferencesLock.lock(); + try { + strongReferences.clear(); + } finally { + strongReferencesLock.unlock(); + } + processQueue(); // throw out garbage collected values + map.clear(); + } + + public int size() { + processQueue(); // throw out garbage collected values first + return map.size(); + } + + public @NonNull Set> entrySet() { + processQueue(); // throw out garbage collected values first + Collection keys = map.keySet(); + if (keys.isEmpty()) { + //noinspection unchecked + return Collections.EMPTY_SET; + } + + Map kvPairs = new HashMap(keys.size()); + for (K key : keys) { + V v = get(key); + if (v != null) { + kvPairs.put(key, v); + } + } + return kvPairs.entrySet(); + } + + /** + * We define our own subclass of SoftReference which contains + * not only the value but also the key to make it easier to find + * the entry in the HashMap after it's been garbage collected. + */ + private static class SoftValue extends SoftReference { + + private final K key; + + /** + * Constructs a new instance, wrapping the value, key, and queue, as + * required by the superclass. + * + * @param value the map value + * @param key the map key + * @param queue the soft reference queue to poll to determine if the entry had been reaped by the GC. + */ + private SoftValue(V value, K key, ReferenceQueue queue) { + super(value, queue); + this.key = key; + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java new file mode 100644 index 00000000..ec5cd2f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.BulletSpan; +import android.text.style.DynamicDrawableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; + +import androidx.annotation.NonNull; + +public class SpanUtil { + + public static CharSequence italic(CharSequence sequence) { + return italic(sequence, sequence.length()); + } + + public static CharSequence italic(CharSequence sequence, int length) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence small(CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence ofSize(CharSequence sequence, int size) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new AbsoluteSizeSpan(size, true), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence bold(CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence color(int color, CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new ForegroundColorSpan(color), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static @NonNull CharSequence bullet(@NonNull CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new BulletSpan(), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence buildImageSpan(@NonNull Drawable drawable) { + SpannableString imageSpan = new SpannableString(" "); + + int flag = Build.VERSION.SDK_INT >= 29 ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE; + + imageSpan.setSpan(new ImageSpan(drawable, flag), 0, imageSpan.length(), 0); + + return imageSpan; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java new file mode 100644 index 00000000..506df0fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class SqlUtil { + private SqlUtil() {} + + + public static boolean tableExists(@NonNull SQLiteDatabase db, @NonNull String table) { + try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type=? AND name=?", new String[] { "table", table })) { + return cursor != null && cursor.moveToNext(); + } + } + + public static boolean isEmpty(@NonNull SQLiteDatabase db, @NonNull String table) { + try (Cursor cursor = db.rawQuery("SELECT COUNT(*) FROM " + table, null)) { + if (cursor.moveToFirst()) { + return cursor.getInt(0) == 0; + } else { + return true; + } + } + } + + public static boolean columnExists(@NonNull SQLiteDatabase db, @NonNull String table, @NonNull String column) { + try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) { + int nameColumnIndex = cursor.getColumnIndexOrThrow("name"); + + while (cursor.moveToNext()) { + String name = cursor.getString(nameColumnIndex); + + if (name.equals(column)) { + return true; + } + } + } + + return false; + } + + public static String[] buildArgs(Object... objects) { + String[] args = new String[objects.length]; + + for (int i = 0; i < objects.length; i++) { + if (objects[i] == null) { + throw new NullPointerException("Cannot have null arg!"); + } else if (objects[i] instanceof RecipientId) { + args[i] = ((RecipientId) objects[i]).serialize(); + } else { + args[i] = objects[i].toString(); + } + } + + return args; + } + + /** + * Returns an updated query and args pairing that will only update rows that would *actually* + * change. In other words, if {@link SQLiteDatabase#update(String, ContentValues, String, String[])} + * returns > 0, then you know something *actually* changed. + */ + public static @NonNull Query buildTrueUpdateQuery(@NonNull String selection, + @NonNull String[] args, + @NonNull ContentValues contentValues) + { + StringBuilder qualifier = new StringBuilder(); + Set> valueSet = contentValues.valueSet(); + List fullArgs = new ArrayList<>(args.length + valueSet.size()); + + fullArgs.addAll(Arrays.asList(args)); + + int i = 0; + + for (Map.Entry entry : valueSet) { + if (entry.getValue() != null) { + qualifier.append(entry.getKey()).append(" != ? OR ").append(entry.getKey()).append(" IS NULL"); + fullArgs.add(String.valueOf(entry.getValue())); + } else { + qualifier.append(entry.getKey()).append(" NOT NULL"); + } + + if (i != valueSet.size() - 1) { + qualifier.append(" OR "); + } + + i++; + } + + return new Query("(" + selection + ") AND (" + qualifier + ")", fullArgs.toArray(new String[0])); + } + + public static @NonNull Query buildCollectionQuery(@NonNull String column, @NonNull Collection values) { + Preconditions.checkArgument(values.size() > 0); + + StringBuilder query = new StringBuilder(); + Object[] args = new Object[values.size()]; + + int i = 0; + + for (Object value : values) { + query.append("?"); + args[i] = value; + + if (i != values.size() - 1) { + query.append(", "); + } + + i++; + } + + return new Query(column + " IN (" + query.toString() + ")", buildArgs(args)); + } + + public static String[] appendArg(@NonNull String[] args, String addition) { + String[] output = new String[args.length + 1]; + + System.arraycopy(args, 0, output, 0, args.length); + output[output.length - 1] = addition; + + return output; + } + + public static class Query { + private final String where; + private final String[] whereArgs; + + private Query(@NonNull String where, @NonNull String[] whereArgs) { + this.where = where; + this.whereArgs = whereArgs; + } + + public String getWhere() { + return where; + } + + public String[] getWhereArgs() { + return whereArgs; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java new file mode 100644 index 00000000..dfa555ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java @@ -0,0 +1,238 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Canvas; +import android.graphics.Rect; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; + +import java.util.HashMap; +import java.util.Map; + +/** + * A sticky header decoration for android's RecyclerView. + * Currently only supports LinearLayoutManager in VERTICAL orientation. + */ +public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(StickyHeaderDecoration.class); + + private final Map headerCache; + private final StickyHeaderAdapter adapter; + private final boolean renderInline; + private final boolean sticky; + private final int type; + + /** + * @param adapter the sticky header adapter to use + */ + public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky, int type) { + this.adapter = adapter; + this.headerCache = new HashMap<>(); + this.renderInline = renderInline; + this.sticky = sticky; + this.type = type; + } + + /** + * {@inheritDoc} + */ + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) + { + int position = parent.getChildAdapterPosition(view); + int headerHeight = 0; + + if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) { + View header = getHeader(parent, adapter, position).itemView; + headerHeight = getHeaderHeightForLayout(header); + } + + outRect.set(0, headerHeight, 0, 0); + } + + protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) { + long headerId = adapter.getHeaderId(adapterPos); + + if (headerId == StickyHeaderAdapter.NO_HEADER_ID) { + return false; + } + + boolean isReverse = isReverseLayout(parent); + int itemCount = ((RecyclerView.Adapter)adapter).getItemCount(); + + if ((isReverse && adapterPos == itemCount - 1 && adapter.getHeaderId(adapterPos) != -1) || + (!isReverse && adapterPos == 0)) + { + return true; + } + + int previous = adapterPos + (isReverse ? 1 : -1); + long previousHeaderId = adapter.getHeaderId(previous); + + return previousHeaderId != StickyHeaderAdapter.NO_HEADER_ID && headerId != previousHeaderId; + } + + protected @NonNull ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) { + final long key = adapter.getHeaderId(position); + + ViewHolder headerHolder = headerCache.get(key); + if (headerHolder == null) { + + if (key != StickyHeaderAdapter.NO_HEADER_ID) { + headerHolder = adapter.onCreateHeaderViewHolder(parent, position, type); + //noinspection unchecked + adapter.onBindHeaderViewHolder(headerHolder, position, type); + } + + if (headerHolder == null) { + headerHolder = new RecyclerView.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.null_recyclerview_header, parent, false)) { + }; + } + + headerCache.put(key, headerHolder); + } + + final View header = headerHolder.itemView; + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, + parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, + parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); + + header.measure(childWidth, childHeight); + header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); + + return headerHolder; + } + + /** + * {@inheritDoc} + */ + @Override + public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + final int count = parent.getChildCount(); + + int start = 0; + for (int layoutPos = 0; layoutPos < count; layoutPos++) { + final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos)); + + final int adapterPos = parent.getChildAdapterPosition(child); + + final long key = adapter.getHeaderId(adapterPos); + if (key == StickyHeaderAdapter.NO_HEADER_ID) { + start = layoutPos + 1; + } + + if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == start && sticky) || hasHeader(parent, adapter, adapterPos))) { + View header = getHeader(parent, adapter, adapterPos).itemView; + c.save(); + final int left = child.getLeft(); + final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); + c.translate(left, top); + header.draw(c); + c.restore(); + } + } + } + + protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, + int layoutPos) + { + int headerHeight = getHeaderHeightForLayout(header); + int top = getChildY(child) - headerHeight; + if (sticky && layoutPos == 0) { + final int count = parent.getChildCount(); + final long currentId = adapter.getHeaderId(adapterPos); + // find next view with header and compute the offscreen push if needed + for (int i = 1; i < count; i++) { + int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i))); + if (adapterPosHere != RecyclerView.NO_POSITION) { + long nextId = adapter.getHeaderId(adapterPosHere); + if (nextId != currentId) { + final View next = parent.getChildAt(translatedChildPosition(parent, i)); + final int offset = getChildY(next) - (headerHeight + getHeader(parent, adapter, adapterPosHere).itemView.getHeight()); + if (offset < 0) { + return offset; + } else { + break; + } + } + } + } + + if (sticky) top = Math.max(0, top); + } + + return top; + } + + private static int translatedChildPosition(RecyclerView parent, int position) { + return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position; + } + + private static int getChildY(@NonNull View child) { + return (int) child.getY(); + } + + private int getHeaderHeightForLayout(View header) { + return renderInline ? 0 : header.getHeight(); + } + + private static boolean isReverseLayout(final RecyclerView parent) { + return (parent.getLayoutManager() instanceof LinearLayoutManager) && + ((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout(); + } + + /** + * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. + */ + public interface StickyHeaderAdapter { + + long NO_HEADER_ID = -1L; + + /** + * Returns the header id for the item at the given position. + *

+ * Return {@link #NO_HEADER_ID} if it does not have one. + * + * @param position the item position + * @return the header id + */ + long getHeaderId(int position); + + /** + * Creates a new header ViewHolder. + *

+ * Only called if getHeaderId returns {@link #NO_HEADER_ID}. + * + * @param parent the header's view parent + * @param position position in the adapter + * @return a view holder for the created view + */ + T onCreateHeaderViewHolder(ViewGroup parent, int position, int type); + + /** + * Updates the header view to reflect the header data for the given position. + * + * @param viewHolder the header view holder + * @param position the header's item position + */ + void onBindHeaderViewHolder(T viewHolder, int position, int type); + + int getItemCount(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java new file mode 100644 index 00000000..22ee9d67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.LinkedList; +import java.util.List; + +public class Stopwatch { + + private final long startTime; + private final String title; + private final List splits; + + public Stopwatch(@NonNull String title) { + this.startTime = System.currentTimeMillis(); + this.title = title; + this.splits = new LinkedList<>(); + } + + public void split(@NonNull String label) { + splits.add(new Split(System.currentTimeMillis(), label)); + } + + public void stop(@NonNull String tag) { + StringBuilder out = new StringBuilder(); + out.append("[").append(title).append("] "); + + if (splits.size() > 0) { + out.append(splits.get(0).label).append(": "); + out.append(splits.get(0).time - startTime); + out.append(" "); + } + + if (splits.size() > 1) { + for (int i = 1; i < splits.size(); i++) { + out.append(splits.get(i).label).append(": "); + out.append(splits.get(i).time - splits.get(i - 1).time); + out.append(" "); + } + + out.append("total: ").append(splits.get(splits.size() - 1).time - startTime); + } + + Log.d(tag, out.toString()); + } + + private static class Split { + final long time; + final String label; + + Split(long time, String label) { + this.time = time; + this.label = label; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java new file mode 100644 index 00000000..73cb0f42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.util; + +import android.Manifest; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.permissions.Permissions; + +import java.io.File; +import java.util.List; +import java.util.Objects; + +public class StorageUtil { + + private static final String PRODUCTION_PACKAGE_ID = "org.thoughtcrime.securesms"; + + public static File getOrCreateBackupDirectory() throws NoExternalStorageException { + File storage = Environment.getExternalStorageDirectory(); + + if (!storage.canWrite()) { + throw new NoExternalStorageException(); + } + + File backups = getBackupDirectory(); + + if (!backups.exists()) { + if (!backups.mkdirs()) { + throw new NoExternalStorageException("Unable to create backup directory..."); + } + } + + return backups; + } + + public static File getBackupDirectory() throws NoExternalStorageException { + File storage = Environment.getExternalStorageDirectory(); + File signal = new File(storage, "Signal"); + File backups = new File(signal, "Backups"); + + //noinspection ConstantConditions + if (BuildConfig.APPLICATION_ID.startsWith(PRODUCTION_PACKAGE_ID + ".")) { + backups = new File(backups, BuildConfig.APPLICATION_ID.substring(PRODUCTION_PACKAGE_ID.length() + 1)); + } + + return backups; + } + + @RequiresApi(24) + public static @NonNull String getDisplayPath(@NonNull Context context, @NonNull Uri uri) { + String lastPathSegment = Objects.requireNonNull(uri.getLastPathSegment()); + String backupVolume = lastPathSegment.replaceFirst(":.*", ""); + String backupName = lastPathSegment.replaceFirst(".*:", ""); + + StorageManager storageManager = ServiceUtil.getStorageManager(context); + List storageVolumes = storageManager.getStorageVolumes(); + StorageVolume storageVolume = null; + + for (StorageVolume volume : storageVolumes) { + if (Objects.equals(volume.getUuid(), backupVolume)) { + storageVolume = volume; + break; + } + } + + if (storageVolume == null) { + return backupName; + } else { + return context.getString(R.string.StorageUtil__s_s, storageVolume.getDescription(context), backupName); + } + } + + public static File getBackupCacheDirectory(Context context) { + return context.getExternalCacheDir(); + } + + private static File getSignalStorageDir() throws NoExternalStorageException { + final File storage = Environment.getExternalStorageDirectory(); + + if (!storage.canWrite()) { + throw new NoExternalStorageException(); + } + + return storage; + } + + public static boolean canWriteInSignalStorageDir() { + File storage; + + try { + storage = getSignalStorageDir(); + } catch (NoExternalStorageException e) { + return false; + } + + return storage.canWrite(); + } + + public static File getLegacyBackupDirectory() throws NoExternalStorageException { + return getSignalStorageDir(); + } + + public static boolean canWriteToMediaStore() { + return Build.VERSION.SDK_INT > 28 || + Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + + public static boolean canReadFromMediaStore() { + return Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.READ_EXTERNAL_STORAGE); + } + + public static @NonNull Uri getVideoUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_MOVIES); + } else { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getAudioUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_MUSIC); + } else { + return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getImageUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_PICTURES); + } else { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getDownloadUri() { + if (Build.VERSION.SDK_INT < 29) { + return getLegacyUri(Environment.DIRECTORY_DOWNLOADS); + } else { + return MediaStore.Downloads.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getLegacyUri(@NonNull String directory) { + return Uri.fromFile(Environment.getExternalStoragePublicDirectory(directory)); + } + + public static @Nullable String getCleanFileName(@Nullable String fileName) { + if (fileName == null) return null; + + fileName = fileName.replace('\u202D', '\uFFFD'); + fileName = fileName.replace('\u202E', '\uFFFD'); + + return fileName; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java new file mode 100644 index 00000000..395379da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java @@ -0,0 +1,234 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.BidiFormatter; + +import java.nio.charset.StandardCharsets; +import java.util.Set; + +public final class StringUtil { + + private static final Set WHITESPACE = SetUtil.newHashSet('\u200E', // left-to-right mark + '\u200F', // right-to-left mark + '\u2007'); // figure space + + private static final class Bidi { + /** Override text direction */ + private static final Set OVERRIDES = SetUtil.newHashSet("\u202a".codePointAt(0), /* LRE */ + "\u202b".codePointAt(0), /* RLE */ + "\u202d".codePointAt(0), /* LRO */ + "\u202e".codePointAt(0) /* RLO */); + + /** Set direction and isolate surrounding text */ + private static final Set ISOLATES = SetUtil.newHashSet("\u2066".codePointAt(0), /* LRI */ + "\u2067".codePointAt(0), /* RLI */ + "\u2068".codePointAt(0) /* FSI */); + /** Closes things in {@link #OVERRIDES} */ + private static final int PDF = "\u202c".codePointAt(0); + + /** Closes things in {@link #ISOLATES} */ + private static final int PDI = "\u2069".codePointAt(0); + + /** Auto-detecting isolate */ + private static final int FSI = "\u2068".codePointAt(0); + } + + private StringUtil() { + } + + /** + * Trims a name string to fit into the byte length requirement. + */ + public static @NonNull String trimToFit(@Nullable String name, int maxLength) { + if (name == null) return ""; + + // At least one byte per char, so shorten string to reduce loop + if (name.length() > maxLength) { + name = name.substring(0, maxLength); + } + + // Remove one char at a time until fits in byte allowance + while (name.getBytes(StandardCharsets.UTF_8).length > maxLength) { + name = name.substring(0, name.length() - 1); + } + + return name; + } + + /** + * @return A charsequence with no leading or trailing whitespace. Only creates a new charsequence + * if it has to. + */ + public static @NonNull CharSequence trim(@NonNull CharSequence charSequence) { + if (charSequence.length() == 0) { + return charSequence; + } + + int start = 0; + int end = charSequence.length() - 1; + + while (start < charSequence.length() && Character.isWhitespace(charSequence.charAt(start))) { + start++; + } + + while (end >= 0 && end > start && Character.isWhitespace(charSequence.charAt(end))) { + end--; + } + + if (start > 0 || end < charSequence.length() - 1) { + return charSequence.subSequence(start, end + 1); + } else { + return charSequence; + } + } + + /** + * @return True if the string is empty, or if it contains nothing but whitespace characters. + * Accounts for various unicode whitespace characters. + */ + public static boolean isVisuallyEmpty(@Nullable String value) { + if (value == null || value.length() == 0) { + return true; + } + + return indexOfFirstNonEmptyChar(value) == -1; + } + + /** + * @return String without any leading or trailing whitespace. + * Accounts for various unicode whitespace characters. + */ + public static String trimToVisualBounds(@NonNull String value) { + int start = indexOfFirstNonEmptyChar(value); + + if (start == -1) { + return ""; + } + + int end = indexOfLastNonEmptyChar(value); + + return value.substring(start, end + 1); + } + + private static int indexOfFirstNonEmptyChar(@NonNull String value) { + int length = value.length(); + + for (int i = 0; i < length; i++) { + if (!isVisuallyEmpty(value.charAt(i))) { + return i; + } + } + + return -1; + } + + private static int indexOfLastNonEmptyChar(@NonNull String value) { + for (int i = value.length() - 1; i >= 0; i--) { + if (!isVisuallyEmpty(value.charAt(i))) { + return i; + } + } + return -1; + } + + /** + * @return True if the character is invisible or whitespace. Accounts for various unicode + * whitespace characters. + */ + public static boolean isVisuallyEmpty(char c) { + return Character.isWhitespace(c) || WHITESPACE.contains(c); + } + + /** + * @return A string representation of the provided unicode code point. + */ + public static @NonNull String codePointToString(int codePoint) { + return new String(Character.toChars(codePoint)); + } + + /** + * Isolates bi-directional text from influencing surrounding text. You should use this whenever + * you're injecting user-generated text into a larger string. + * + * You'd think we'd be able to trust {@link BidiFormatter}, but unfortunately it just misses some + * corner cases, so here we are. + * + * The general idea is just to balance out the opening and closing codepoints, and then wrap the + * whole thing in FSI/PDI to isolate it. + * + * For more details, see: + * https://www.w3.org/International/questions/qa-bidi-unicode-controls + */ + public static @NonNull String isolateBidi(@Nullable String text) { + if (text == null) { + return ""; + } + + if (Util.isEmpty(text)) { + return text; + } + + int overrideCount = 0; + int overrideCloseCount = 0; + int isolateCount = 0; + int isolateCloseCount = 0; + + for (int i = 0, len = text.codePointCount(0, text.length()); i < len; i++) { + int codePoint = text.codePointAt(i); + + if (Bidi.OVERRIDES.contains(codePoint)) { + overrideCount++; + } else if (codePoint == Bidi.PDF) { + overrideCloseCount++; + } else if (Bidi.ISOLATES.contains(codePoint)) { + isolateCount++; + } else if (codePoint == Bidi.PDI) { + isolateCloseCount++; + } + } + + StringBuilder suffix = new StringBuilder(); + + while (overrideCount > overrideCloseCount) { + suffix.appendCodePoint(Bidi.PDF); + overrideCloseCount++; + } + + while (isolateCount > isolateCloseCount) { + suffix.appendCodePoint(Bidi.FSI); + isolateCloseCount++; + } + + StringBuilder out = new StringBuilder(); + + return out.appendCodePoint(Bidi.FSI) + .append(text) + .append(suffix) + .appendCodePoint(Bidi.PDI) + .toString(); + } + + public static @Nullable String stripBidiProtection(@Nullable String text) { + if (text == null) return null; + + return text.replaceAll("[\\u2068\\u2069\\u202c]", ""); + } + + /** + * Trims a {@link CharSequence} of starting and trailing whitespace. Behavior matches + * {@link String#trim()} to preserve expectations around results. + */ + public static CharSequence trimSequence(CharSequence text) { + int length = text.length(); + int startIndex = 0; + + while ((startIndex < length) && (text.charAt(startIndex) <= ' ')) { + startIndex++; + } + while ((startIndex < length) && (text.charAt(length - 1) <= ' ')) { + length--; + } + return (startIndex > 0 || length < text.length()) ? text.subSequence(startIndex, length) : text; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SupportEmailUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SupportEmailUtil.java new file mode 100644 index 00000000..306f042c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SupportEmailUtil.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.signal.core.util.ResourceUtil; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.util.Locale; + +public final class SupportEmailUtil { + + private SupportEmailUtil() { } + + public static @NonNull String getSupportEmailAddress(@NonNull Context context) { + return context.getString(R.string.SupportEmailUtil_support_email); + } + + /** + * Generates a support email body with system info near the top. + */ + public static @NonNull String generateSupportEmailBody(@NonNull Context context, + @StringRes int subject, + @Nullable String prefix, + @Nullable String suffix) + { + prefix = Util.firstNonNull(prefix, ""); + suffix = Util.firstNonNull(suffix, ""); + return String.format("%s\n%s\n%s", prefix, buildSystemInfo(context, subject), suffix); + } + + private static @NonNull String buildSystemInfo(@NonNull Context context, @StringRes int subject) { + Resources englishResources = ResourceUtil.getEnglishResources(context); + + return "--- " + context.getString(R.string.HelpFragment__support_info) + " ---" + + "\n" + + context.getString(R.string.SupportEmailUtil_filter) + " " + englishResources.getString(subject) + + "\n" + + context.getString(R.string.SupportEmailUtil_device_info) + " " + getDeviceInfo() + + "\n" + + context.getString(R.string.SupportEmailUtil_android_version) + " " + getAndroidVersion() + + "\n" + + context.getString(R.string.SupportEmailUtil_signal_version) + " " + getSignalVersion() + + "\n" + + context.getString(R.string.SupportEmailUtil_signal_package) + " " + getSignalPackage(context) + + "\n" + + context.getString(R.string.SupportEmailUtil_registration_lock) + " " + getRegistrationLockEnabled(context) + + "\n" + + context.getString(R.string.SupportEmailUtil_locale) + " " + Locale.getDefault().toString(); + } + + private static CharSequence getDeviceInfo() { + return String.format("%s %s (%s)", Build.MANUFACTURER, Build.MODEL, Build.PRODUCT); + } + + private static CharSequence getAndroidVersion() { + return String.format("%s (%s, %s)", Build.VERSION.RELEASE, Build.VERSION.INCREMENTAL, Build.DISPLAY); + } + + private static CharSequence getSignalVersion() { + return BuildConfig.VERSION_NAME; + } + + private static CharSequence getSignalPackage(@NonNull Context context) { + return String.format("%s (%s)", BuildConfig.APPLICATION_ID, AppSignatureUtil.getAppSignature(context).or("Unknown")); + } + + private static CharSequence getRegistrationLockEnabled(@NonNull Context context) { + return String.valueOf(TextSecurePreferences.isV1RegistrationLockEnabled(context) || SignalStore.kbsValues().isV2RegistrationLockEnabled()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TaggedFutureTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/TaggedFutureTask.java new file mode 100644 index 00000000..a20c13d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TaggedFutureTask.java @@ -0,0 +1,43 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.thoughtcrime.securesms.util; + +import java.util.concurrent.Callable; +import java.util.concurrent.FutureTask; + +/** + * FutureTask with a reference identifier tag. + * + * @author Jake McGinty + */ +public class TaggedFutureTask extends FutureTask { + private final Object tag; + public TaggedFutureTask(Runnable runnable, V result, Object tag) { + super(runnable, result); + this.tag = tag; + } + + public TaggedFutureTask(Callable callable, Object tag) { + super(callable); + this.tag = tag; + } + + public Object getTag() { + return tag; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TelephonyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/TelephonyUtil.java new file mode 100644 index 00000000..06de5e97 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TelephonyUtil.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.net.ConnectivityManager; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.Locale; + +public class TelephonyUtil { + private static final String TAG = TelephonyUtil.class.getSimpleName(); + + public static TelephonyManager getManager(final Context context) { + return (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); + } + + public static String getMccMnc(final Context context) { + final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final int configMcc = context.getResources().getConfiguration().mcc; + final int configMnc = context.getResources().getConfiguration().mnc; + if (tm.getSimState() == TelephonyManager.SIM_STATE_READY) { + Log.i(TAG, "Choosing MCC+MNC info from TelephonyManager.getSimOperator()"); + return tm.getSimOperator(); + } else if (tm.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) { + Log.i(TAG, "Choosing MCC+MNC info from TelephonyManager.getNetworkOperator()"); + return tm.getNetworkOperator(); + } else if (configMcc != 0 && configMnc != 0) { + Log.i(TAG, "Choosing MCC+MNC info from current context's Configuration"); + return String.format(Locale.ROOT, "%03d%d", + configMcc, + configMnc == Configuration.MNC_ZERO ? 0 : configMnc); + } else { + return null; + } + } + + public static String getApn(final Context context) { + final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + return cm.getNetworkInfo(ConnectivityManager.TYPE_MOBILE_MMS).getExtraInfo(); + } + + public static boolean isAnyPstnLineBusy(@NonNull Context context) { + return getManager(context).getCallState() != TelephonyManager.CALL_STATE_IDLE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java new file mode 100644 index 00000000..c763cce0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -0,0 +1,1218 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.hardware.Camera.CameraInfo; +import android.net.Uri; +import android.os.Build; +import android.preference.PreferenceManager; +import android.provider.Settings; + +import androidx.annotation.ArrayRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver; +import org.thoughtcrime.securesms.keyvalue.SettingsValues; +import org.thoughtcrime.securesms.lock.RegistrationLockReminders; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.whispersystems.libsignal.util.Medium; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class TextSecurePreferences { + + private static final String TAG = TextSecurePreferences.class.getSimpleName(); + + public static final String IDENTITY_PREF = "pref_choose_identity"; + public static final String CHANGE_PASSPHRASE_PREF = "pref_change_passphrase"; + public static final String DISABLE_PASSPHRASE_PREF = "pref_disable_passphrase"; + public static final String THEME_PREF = "pref_theme"; + public static final String LANGUAGE_PREF = "pref_language"; + private static final String MMSC_CUSTOM_HOST_PREF = "pref_apn_mmsc_custom_host"; + public static final String MMSC_HOST_PREF = "pref_apn_mmsc_host"; + private static final String MMSC_CUSTOM_PROXY_PREF = "pref_apn_mms_custom_proxy"; + public static final String MMSC_PROXY_HOST_PREF = "pref_apn_mms_proxy"; + private static final String MMSC_CUSTOM_PROXY_PORT_PREF = "pref_apn_mms_custom_proxy_port"; + public static final String MMSC_PROXY_PORT_PREF = "pref_apn_mms_proxy_port"; + private static final String MMSC_CUSTOM_USERNAME_PREF = "pref_apn_mmsc_custom_username"; + public static final String MMSC_USERNAME_PREF = "pref_apn_mmsc_username"; + private static final String MMSC_CUSTOM_PASSWORD_PREF = "pref_apn_mmsc_custom_password"; + public static final String MMSC_PASSWORD_PREF = "pref_apn_mmsc_password"; + public static final String ENABLE_MANUAL_MMS_PREF = "pref_enable_manual_mms"; + + private static final String LAST_VERSION_CODE_PREF = "last_version_code"; + private static final String LAST_EXPERIENCE_VERSION_PREF = "last_experience_version_code"; + private static final String EXPERIENCE_DISMISSED_PREF = "experience_dismissed"; + public static final String RINGTONE_PREF = "pref_key_ringtone"; + public static final String VIBRATE_PREF = "pref_key_vibrate"; + private static final String NOTIFICATION_PREF = "pref_key_enable_notifications"; + public static final String LED_COLOR_PREF = "pref_led_color"; + public static final String LED_BLINK_PREF = "pref_led_blink"; + private static final String LED_BLINK_PREF_CUSTOM = "pref_led_blink_custom"; + public static final String ALL_MMS_PREF = "pref_all_mms"; + public static final String ALL_SMS_PREF = "pref_all_sms"; + public static final String PASSPHRASE_TIMEOUT_INTERVAL_PREF = "pref_timeout_interval"; + public static final String PASSPHRASE_TIMEOUT_PREF = "pref_timeout_passphrase"; + public static final String SCREEN_SECURITY_PREF = "pref_screen_security"; + private static final String ENTER_SENDS_PREF = "pref_enter_sends"; + private static final String ENTER_PRESENT_PREF = "pref_enter_key"; + private static final String SMS_DELIVERY_REPORT_PREF = "pref_delivery_report_sms"; + public static final String MMS_USER_AGENT = "pref_mms_user_agent"; + private static final String MMS_CUSTOM_USER_AGENT = "pref_custom_mms_user_agent"; + private static final String LOCAL_NUMBER_PREF = "pref_local_number"; + private static final String LOCAL_UUID_PREF = "pref_local_uuid"; + private static final String LOCAL_USERNAME_PREF = "pref_local_username"; + public static final String REGISTERED_GCM_PREF = "pref_gcm_registered"; + private static final String GCM_PASSWORD_PREF = "pref_gcm_password"; + private static final String SEEN_WELCOME_SCREEN_PREF = "pref_seen_welcome_screen"; + private static final String PROMPTED_PUSH_REGISTRATION_PREF = "pref_prompted_push_registration"; + private static final String PROMPTED_OPTIMIZE_DOZE_PREF = "pref_prompted_optimize_doze"; + private static final String DIRECTORY_FRESH_TIME_PREF = "pref_directory_refresh_time"; + private static final String UPDATE_APK_REFRESH_TIME_PREF = "pref_update_apk_refresh_time"; + private static final String UPDATE_APK_DOWNLOAD_ID = "pref_update_apk_download_id"; + private static final String UPDATE_APK_DIGEST = "pref_update_apk_digest"; + private static final String SIGNED_PREKEY_ROTATION_TIME_PREF = "pref_signed_pre_key_rotation_time"; + + private static final String IN_THREAD_NOTIFICATION_PREF = "pref_key_inthread_notifications"; + private static final String SHOW_INVITE_REMINDER_PREF = "pref_show_invite_reminder"; + public static final String MESSAGE_BODY_TEXT_SIZE_PREF = "pref_message_body_text_size"; + + private static final String LOCAL_REGISTRATION_ID_PREF = "pref_local_registration_id"; + private static final String SIGNED_PREKEY_REGISTERED_PREF = "pref_signed_prekey_registered"; + private static final String WIFI_SMS_PREF = "pref_wifi_sms"; + + private static final String GCM_DISABLED_PREF = "pref_gcm_disabled"; + private static final String GCM_REGISTRATION_ID_PREF = "pref_gcm_registration_id"; + private static final String GCM_REGISTRATION_ID_VERSION_PREF = "pref_gcm_registration_id_version"; + private static final String GCM_REGISTRATION_ID_TIME_PREF = "pref_gcm_registration_id_last_set_time"; + private static final String WEBSOCKET_REGISTERED_PREF = "pref_websocket_registered"; + private static final String RATING_LATER_PREF = "pref_rating_later"; + private static final String RATING_ENABLED_PREF = "pref_rating_enabled"; + private static final String SIGNED_PREKEY_FAILURE_COUNT_PREF = "pref_signed_prekey_failure_count"; + + public static final String REPEAT_ALERTS_PREF = "pref_repeat_alerts"; + public static final String NOTIFICATION_PRIVACY_PREF = "pref_notification_privacy"; + public static final String NOTIFICATION_PRIORITY_PREF = "pref_notification_priority"; + public static final String NEW_CONTACTS_NOTIFICATIONS = "pref_enable_new_contacts_notifications"; + public static final String WEBRTC_CALLING_PREF = "pref_webrtc_calling"; + + public static final String MEDIA_DOWNLOAD_MOBILE_PREF = "pref_media_download_mobile"; + public static final String MEDIA_DOWNLOAD_WIFI_PREF = "pref_media_download_wifi"; + public static final String MEDIA_DOWNLOAD_ROAMING_PREF = "pref_media_download_roaming"; + + public static final String CALL_BANDWIDTH_PREF = "pref_data_call_bandwidth"; + + public static final String SYSTEM_EMOJI_PREF = "pref_system_emoji"; + private static final String MULTI_DEVICE_PROVISIONED_PREF = "pref_multi_device"; + public static final String DIRECT_CAPTURE_CAMERA_ID = "pref_direct_capture_camera_id"; + private static final String ALWAYS_RELAY_CALLS_PREF = "pref_turn_only"; + private static final String PROFILE_NAME_PREF = "pref_profile_name"; + private static final String PROFILE_AVATAR_ID_PREF = "pref_profile_avatar_id"; + public static final String READ_RECEIPTS_PREF = "pref_read_receipts"; + public static final String INCOGNITO_KEYBORAD_PREF = "pref_incognito_keyboard"; + private static final String UNAUTHORIZED_RECEIVED = "pref_unauthorized_received"; + private static final String SUCCESSFUL_DIRECTORY_PREF = "pref_successful_directory"; + + private static final String DATABASE_ENCRYPTED_SECRET = "pref_database_encrypted_secret"; + private static final String DATABASE_UNENCRYPTED_SECRET = "pref_database_unencrypted_secret"; + private static final String ATTACHMENT_ENCRYPTED_SECRET = "pref_attachment_encrypted_secret"; + private static final String ATTACHMENT_UNENCRYPTED_SECRET = "pref_attachment_unencrypted_secret"; + private static final String NEEDS_SQLCIPHER_MIGRATION = "pref_needs_sql_cipher_migration"; + + public static final String CALL_NOTIFICATIONS_PREF = "pref_call_notifications"; + public static final String CALL_RINGTONE_PREF = "pref_call_ringtone"; + public static final String CALL_VIBRATE_PREF = "pref_call_vibrate"; + + private static final String NEXT_PRE_KEY_ID = "pref_next_pre_key_id"; + private static final String ACTIVE_SIGNED_PRE_KEY_ID = "pref_active_signed_pre_key_id"; + private static final String NEXT_SIGNED_PRE_KEY_ID = "pref_next_signed_pre_key_id"; + + public static final String BACKUP = "pref_backup"; + public static final String BACKUP_ENABLED = "pref_backup_enabled"; + private static final String BACKUP_PASSPHRASE = "pref_backup_passphrase"; + private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase"; + private static final String BACKUP_TIME = "pref_backup_next_time"; + + public static final String SCREEN_LOCK = "pref_android_screen_lock"; + public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout"; + + @Deprecated + public static final String REGISTRATION_LOCK_PREF_V1 = "pref_registration_lock"; + @Deprecated + private static final String REGISTRATION_LOCK_PIN_PREF_V1 = "pref_registration_lock_pin"; + + public static final String REGISTRATION_LOCK_PREF_V2 = "pref_registration_lock_v2"; + + private static final String REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS = "pref_registration_lock_last_reminder_time_post_kbs"; + private static final String REGISTRATION_LOCK_NEXT_REMINDER_INTERVAL = "pref_registration_lock_next_reminder_interval"; + + public static final String SIGNAL_PIN_CHANGE = "pref_kbs_change"; + + private static final String SERVICE_OUTAGE = "pref_service_outage"; + private static final String LAST_OUTAGE_CHECK_TIME = "pref_last_outage_check_time"; + + private static final String LAST_FULL_CONTACT_SYNC_TIME = "pref_last_full_contact_sync_time"; + private static final String NEEDS_FULL_CONTACT_SYNC = "pref_needs_full_contact_sync"; + + private static final String LOG_ENCRYPTED_SECRET = "pref_log_encrypted_secret"; + private static final String LOG_UNENCRYPTED_SECRET = "pref_log_unencrypted_secret"; + + private static final String NOTIFICATION_CHANNEL_VERSION = "pref_notification_channel_version"; + private static final String NOTIFICATION_MESSAGES_CHANNEL_VERSION = "pref_notification_messages_channel_version"; + + private static final String NEEDS_MESSAGE_PULL = "pref_needs_message_pull"; + + private static final String UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF = "pref_unidentified_access_certificate_rotation_time"; + public static final String UNIVERSAL_UNIDENTIFIED_ACCESS = "pref_universal_unidentified_access"; + public static final String SHOW_UNIDENTIFIED_DELIVERY_INDICATORS = "pref_show_unidentifed_delivery_indicators"; + private static final String UNIDENTIFIED_DELIVERY_ENABLED = "pref_unidentified_delivery_enabled"; + + public static final String TYPING_INDICATORS = "pref_typing_indicators"; + + public static final String LINK_PREVIEWS = "pref_link_previews"; + + private static final String GIF_GRID_LAYOUT = "pref_gif_grid_layout"; + + private static final String SEEN_STICKER_INTRO_TOOLTIP = "pref_seen_sticker_intro_tooltip"; + + private static final String MEDIA_KEYBOARD_MODE = "pref_media_keyboard_mode"; + + private static final String VIEW_ONCE_TOOLTIP_SEEN = "pref_revealable_message_tooltip_seen"; + + private static final String SEEN_CAMERA_FIRST_TOOLTIP = "pref_seen_camera_first_tooltip"; + + private static final String JOB_MANAGER_VERSION = "pref_job_manager_version"; + + private static final String APP_MIGRATION_VERSION = "pref_app_migration_version"; + + private static final String FIRST_INSTALL_VERSION = "pref_first_install_version"; + + private static final String HAS_SEEN_SWIPE_TO_REPLY = "pref_has_seen_swipe_to_reply"; + + private static final String HAS_SEEN_VIDEO_RECORDING_TOOLTIP = "camerax.fragment.has.dismissed.video.recording.tooltip"; + + private static final String STORAGE_MANIFEST_VERSION = "pref_storage_manifest_version"; + + private static final String ARGON2_TESTED = "argon2_tested"; + + public static boolean isScreenLockEnabled(@NonNull Context context) { + return getBooleanPreference(context, SCREEN_LOCK, false); + } + + public static void setScreenLockEnabled(@NonNull Context context, boolean value) { + setBooleanPreference(context, SCREEN_LOCK, value); + } + + public static long getScreenLockTimeout(@NonNull Context context) { + return getLongPreference(context, SCREEN_LOCK_TIMEOUT, 0); + } + + public static void setScreenLockTimeout(@NonNull Context context, long value) { + setLongPreference(context, SCREEN_LOCK_TIMEOUT, value); + } + + public static boolean isV1RegistrationLockEnabled(@NonNull Context context) { + //noinspection deprecation + return getBooleanPreference(context, REGISTRATION_LOCK_PREF_V1, false); + } + + /** + * @deprecated Use only during re-reg where user had pinV1. + */ + @Deprecated + public static void setV1RegistrationLockEnabled(@NonNull Context context, boolean value) { + //noinspection deprecation + setBooleanPreference(context, REGISTRATION_LOCK_PREF_V1, value); + } + + /** + * @deprecated Use only for migrations to the Key Backup Store registration pinV2. + */ + @Deprecated + public static @Nullable String getDeprecatedV1RegistrationLockPin(@NonNull Context context) { + //noinspection deprecation + return getStringPreference(context, REGISTRATION_LOCK_PIN_PREF_V1, null); + } + + public static void clearRegistrationLockV1(@NonNull Context context) { + //noinspection deprecation + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove(REGISTRATION_LOCK_PIN_PREF_V1) + .apply(); + } + + /** + * @deprecated Use only for migrations to the Key Backup Store registration pinV2. + */ + @Deprecated + public static void setV1RegistrationLockPin(@NonNull Context context, String pin) { + //noinspection deprecation + setStringPreference(context, REGISTRATION_LOCK_PIN_PREF_V1, pin); + } + + public static long getRegistrationLockLastReminderTime(@NonNull Context context) { + return getLongPreference(context, REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS, 0); + } + + public static void setRegistrationLockLastReminderTime(@NonNull Context context, long time) { + setLongPreference(context, REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS, time); + } + + public static long getRegistrationLockNextReminderInterval(@NonNull Context context) { + return getLongPreference(context, REGISTRATION_LOCK_NEXT_REMINDER_INTERVAL, RegistrationLockReminders.INITIAL_INTERVAL); + } + + public static void setRegistrationLockNextReminderInterval(@NonNull Context context, long value) { + setLongPreference(context, REGISTRATION_LOCK_NEXT_REMINDER_INTERVAL, value); + } + + public static void setBackupPassphrase(@NonNull Context context, @Nullable String passphrase) { + setStringPreference(context, BACKUP_PASSPHRASE, passphrase); + } + + public static @Nullable String getBackupPassphrase(@NonNull Context context) { + return getStringPreference(context, BACKUP_PASSPHRASE, null); + } + + public static void setEncryptedBackupPassphrase(@NonNull Context context, @Nullable String encryptedPassphrase) { + setStringPreference(context, ENCRYPTED_BACKUP_PASSPHRASE, encryptedPassphrase); + } + + public static @Nullable String getEncryptedBackupPassphrase(@NonNull Context context) { + return getStringPreference(context, ENCRYPTED_BACKUP_PASSPHRASE, null); + } + + public static void setBackupEnabled(@NonNull Context context, boolean value) { + setBooleanPreference(context, BACKUP_ENABLED, value); + } + + public static boolean isBackupEnabled(@NonNull Context context) { + return getBooleanPreference(context, BACKUP_ENABLED, false); + } + + public static void setNextBackupTime(@NonNull Context context, long time) { + setLongPreference(context, BACKUP_TIME, time); + } + + public static long getNextBackupTime(@NonNull Context context) { + return getLongPreference(context, BACKUP_TIME, -1); + } + + public static int getNextPreKeyId(@NonNull Context context) { + return getIntegerPreference(context, NEXT_PRE_KEY_ID, new SecureRandom().nextInt(Medium.MAX_VALUE)); + } + + public static void setNextPreKeyId(@NonNull Context context, int value) { + setIntegerPrefrence(context, NEXT_PRE_KEY_ID, value); + } + + public static int getNextSignedPreKeyId(@NonNull Context context) { + return getIntegerPreference(context, NEXT_SIGNED_PRE_KEY_ID, new SecureRandom().nextInt(Medium.MAX_VALUE)); + } + + public static void setNextSignedPreKeyId(@NonNull Context context, int value) { + setIntegerPrefrence(context, NEXT_SIGNED_PRE_KEY_ID, value); + } + + public static int getActiveSignedPreKeyId(@NonNull Context context) { + return getIntegerPreference(context, ACTIVE_SIGNED_PRE_KEY_ID, -1); + } + + public static void setActiveSignedPreKeyId(@NonNull Context context, int value) { + setIntegerPrefrence(context, ACTIVE_SIGNED_PRE_KEY_ID, value);; + } + + public static void setNeedsSqlCipherMigration(@NonNull Context context, boolean value) { + setBooleanPreference(context, NEEDS_SQLCIPHER_MIGRATION, value); + EventBus.getDefault().post(new SqlCipherMigrationConstraintObserver.SqlCipherNeedsMigrationEvent()); + } + + public static boolean getNeedsSqlCipherMigration(@NonNull Context context) { + return getBooleanPreference(context, NEEDS_SQLCIPHER_MIGRATION, false); + } + + public static void setAttachmentEncryptedSecret(@NonNull Context context, @NonNull String secret) { + setStringPreference(context, ATTACHMENT_ENCRYPTED_SECRET, secret); + } + + public static void setAttachmentUnencryptedSecret(@NonNull Context context, @Nullable String secret) { + setStringPreference(context, ATTACHMENT_UNENCRYPTED_SECRET, secret); + } + + public static @Nullable String getAttachmentEncryptedSecret(@NonNull Context context) { + return getStringPreference(context, ATTACHMENT_ENCRYPTED_SECRET, null); + } + + public static @Nullable String getAttachmentUnencryptedSecret(@NonNull Context context) { + return getStringPreference(context, ATTACHMENT_UNENCRYPTED_SECRET, null); + } + + public static void setDatabaseEncryptedSecret(@NonNull Context context, @NonNull String secret) { + setStringPreference(context, DATABASE_ENCRYPTED_SECRET, secret); + } + + public static void setDatabaseUnencryptedSecret(@NonNull Context context, @Nullable String secret) { + setStringPreference(context, DATABASE_UNENCRYPTED_SECRET, secret); + } + + public static @Nullable String getDatabaseUnencryptedSecret(@NonNull Context context) { + return getStringPreference(context, DATABASE_UNENCRYPTED_SECRET, null); + } + + public static @Nullable String getDatabaseEncryptedSecret(@NonNull Context context) { + return getStringPreference(context, DATABASE_ENCRYPTED_SECRET, null); + } + + public static void setHasSuccessfullyRetrievedDirectory(Context context, boolean value) { + setBooleanPreference(context, SUCCESSFUL_DIRECTORY_PREF, value); + } + + public static boolean hasSuccessfullyRetrievedDirectory(Context context) { + return getBooleanPreference(context, SUCCESSFUL_DIRECTORY_PREF, false); + } + + public static void setUnauthorizedReceived(Context context, boolean value) { + setBooleanPreference(context, UNAUTHORIZED_RECEIVED, value); + } + + public static boolean isUnauthorizedRecieved(Context context) { + return getBooleanPreference(context, UNAUTHORIZED_RECEIVED, false); + } + + public static boolean isIncognitoKeyboardEnabled(Context context) { + return getBooleanPreference(context, INCOGNITO_KEYBORAD_PREF, false); + } + + public static boolean isReadReceiptsEnabled(Context context) { + return getBooleanPreference(context, READ_RECEIPTS_PREF, false); + } + + public static void setReadReceiptsEnabled(Context context, boolean enabled) { + setBooleanPreference(context, READ_RECEIPTS_PREF, enabled); + } + + public static boolean isTypingIndicatorsEnabled(Context context) { + return getBooleanPreference(context, TYPING_INDICATORS, false); + } + + public static void setTypingIndicatorsEnabled(Context context, boolean enabled) { + setBooleanPreference(context, TYPING_INDICATORS, enabled); + } + + /** + * Only kept so that we can avoid showing the megaphone for the new link previews setting + * ({@link SettingsValues#isLinkPreviewsEnabled()}) when users upgrade. This can be removed after + * we stop showing the link previews megaphone. + */ + public static boolean wereLinkPreviewsEnabled(Context context) { + return getBooleanPreference(context, LINK_PREVIEWS, true); + } + + public static boolean isGifSearchInGridLayout(Context context) { + return getBooleanPreference(context, GIF_GRID_LAYOUT, false); + } + + public static void setIsGifSearchInGridLayout(Context context, boolean isGrid) { + setBooleanPreference(context, GIF_GRID_LAYOUT, isGrid); + } + + public static int getNotificationPriority(Context context) { + return Integer.valueOf(getStringPreference(context, NOTIFICATION_PRIORITY_PREF, String.valueOf(NotificationCompat.PRIORITY_HIGH))); + } + + public static int getMessageBodyTextSize(Context context) { + return Integer.valueOf(getStringPreference(context, MESSAGE_BODY_TEXT_SIZE_PREF, "16")); + } + + public static boolean isTurnOnly(Context context) { + return getBooleanPreference(context, ALWAYS_RELAY_CALLS_PREF, false); + } + + public static boolean isFcmDisabled(Context context) { + return getBooleanPreference(context, GCM_DISABLED_PREF, false); + } + + public static void setFcmDisabled(Context context, boolean disabled) { + setBooleanPreference(context, GCM_DISABLED_PREF, disabled); + } + + public static boolean isWebrtcCallingEnabled(Context context) { + return getBooleanPreference(context, WEBRTC_CALLING_PREF, false); + } + + public static void setWebrtcCallingEnabled(Context context, boolean enabled) { + setBooleanPreference(context, WEBRTC_CALLING_PREF, enabled); + } + + public static void setDirectCaptureCameraId(Context context, int value) { + setIntegerPrefrence(context, DIRECT_CAPTURE_CAMERA_ID, value); + } + + @SuppressWarnings("deprecation") + public static int getDirectCaptureCameraId(Context context) { + return getIntegerPreference(context, DIRECT_CAPTURE_CAMERA_ID, CameraInfo.CAMERA_FACING_FRONT); + } + + public static void setMultiDevice(Context context, boolean value) { + setBooleanPreference(context, MULTI_DEVICE_PROVISIONED_PREF, value); + } + + public static boolean isMultiDevice(Context context) { + return getBooleanPreference(context, MULTI_DEVICE_PROVISIONED_PREF, false); + } + + public static void setSignedPreKeyFailureCount(Context context, int value) { + setIntegerPrefrence(context, SIGNED_PREKEY_FAILURE_COUNT_PREF, value); + } + + public static int getSignedPreKeyFailureCount(Context context) { + return getIntegerPreference(context, SIGNED_PREKEY_FAILURE_COUNT_PREF, 0); + } + + public static NotificationPrivacyPreference getNotificationPrivacy(Context context) { + return new NotificationPrivacyPreference(getStringPreference(context, NOTIFICATION_PRIVACY_PREF, "all")); + } + + public static void setNewContactsNotificationEnabled(Context context, boolean isEnabled) { + setBooleanPreference(context, NEW_CONTACTS_NOTIFICATIONS, isEnabled); + } + + public static boolean isNewContactsNotificationEnabled(Context context) { + return getBooleanPreference(context, NEW_CONTACTS_NOTIFICATIONS, true); + } + + public static long getRatingLaterTimestamp(Context context) { + return getLongPreference(context, RATING_LATER_PREF, 0); + } + + public static void setRatingLaterTimestamp(Context context, long timestamp) { + setLongPreference(context, RATING_LATER_PREF, timestamp); + } + + public static boolean isRatingEnabled(Context context) { + return getBooleanPreference(context, RATING_ENABLED_PREF, true); + } + + public static void setRatingEnabled(Context context, boolean enabled) { + setBooleanPreference(context, RATING_ENABLED_PREF, enabled); + } + + public static boolean isWebsocketRegistered(Context context) { + return getBooleanPreference(context, WEBSOCKET_REGISTERED_PREF, false); + } + + public static void setWebsocketRegistered(Context context, boolean registered) { + setBooleanPreference(context, WEBSOCKET_REGISTERED_PREF, registered); + } + + public static boolean isWifiSmsEnabled(Context context) { + return getBooleanPreference(context, WIFI_SMS_PREF, false); + } + + public static int getRepeatAlertsCount(Context context) { + try { + return Integer.parseInt(getStringPreference(context, REPEAT_ALERTS_PREF, "0")); + } catch (NumberFormatException e) { + Log.w(TAG, e); + return 0; + } + } + + public static void setRepeatAlertsCount(Context context, int count) { + setStringPreference(context, REPEAT_ALERTS_PREF, String.valueOf(count)); + } + + public static boolean isSignedPreKeyRegistered(Context context) { + return getBooleanPreference(context, SIGNED_PREKEY_REGISTERED_PREF, false); + } + + public static void setSignedPreKeyRegistered(Context context, boolean value) { + setBooleanPreference(context, SIGNED_PREKEY_REGISTERED_PREF, value); + } + + public static void setFcmToken(Context context, String registrationId) { + setStringPreference(context, GCM_REGISTRATION_ID_PREF, registrationId); + setIntegerPrefrence(context, GCM_REGISTRATION_ID_VERSION_PREF, Util.getCanonicalVersionCode()); + } + + public static String getFcmToken(Context context) { + int storedRegistrationIdVersion = getIntegerPreference(context, GCM_REGISTRATION_ID_VERSION_PREF, 0); + + if (storedRegistrationIdVersion != Util.getCanonicalVersionCode()) { + return null; + } else { + return getStringPreference(context, GCM_REGISTRATION_ID_PREF, null); + } + } + + public static long getFcmTokenLastSetTime(Context context) { + return getLongPreference(context, GCM_REGISTRATION_ID_TIME_PREF, 0); + } + + public static void setFcmTokenLastSetTime(Context context, long timestamp) { + setLongPreference(context, GCM_REGISTRATION_ID_TIME_PREF, timestamp); + } + + public static boolean isSmsEnabled(Context context) { + return Util.isDefaultSmsProvider(context); + } + + public static int getLocalRegistrationId(Context context) { + return getIntegerPreference(context, LOCAL_REGISTRATION_ID_PREF, 0); + } + + public static void setLocalRegistrationId(Context context, int registrationId) { + setIntegerPrefrence(context, LOCAL_REGISTRATION_ID_PREF, registrationId); + } + + public static boolean isInThreadNotifications(Context context) { + return getBooleanPreference(context, IN_THREAD_NOTIFICATION_PREF, true); + } + + public static long getUnidentifiedAccessCertificateRotationTime(Context context) { + return getLongPreference(context, UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF, 0L); + } + + public static void setUnidentifiedAccessCertificateRotationTime(Context context, long value) { + setLongPreference(context, UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF, value); + } + + public static boolean isUniversalUnidentifiedAccess(Context context) { + return getBooleanPreference(context, UNIVERSAL_UNIDENTIFIED_ACCESS, false); + } + + public static void setShowUnidentifiedDeliveryIndicatorsEnabled(Context context, boolean enabled) { + setBooleanPreference(context, SHOW_UNIDENTIFIED_DELIVERY_INDICATORS, enabled); + } + + public static boolean isShowUnidentifiedDeliveryIndicatorsEnabled(Context context) { + return getBooleanPreference(context, SHOW_UNIDENTIFIED_DELIVERY_INDICATORS, false); + } + + public static void setIsUnidentifiedDeliveryEnabled(Context context, boolean enabled) { + setBooleanPreference(context, UNIDENTIFIED_DELIVERY_ENABLED, enabled); + } + + public static boolean isUnidentifiedDeliveryEnabled(Context context) { + return getBooleanPreference(context, UNIDENTIFIED_DELIVERY_ENABLED, true); + } + + public static long getSignedPreKeyRotationTime(Context context) { + return getLongPreference(context, SIGNED_PREKEY_ROTATION_TIME_PREF, 0L); + } + + public static void setSignedPreKeyRotationTime(Context context, long value) { + setLongPreference(context, SIGNED_PREKEY_ROTATION_TIME_PREF, value); + } + + public static long getDirectoryRefreshTime(Context context) { + return getLongPreference(context, DIRECTORY_FRESH_TIME_PREF, 0L); + } + + public static void setDirectoryRefreshTime(Context context, long value) { + setLongPreference(context, DIRECTORY_FRESH_TIME_PREF, value); + } + + public static long getUpdateApkRefreshTime(Context context) { + return getLongPreference(context, UPDATE_APK_REFRESH_TIME_PREF, 0L); + } + + public static void setUpdateApkRefreshTime(Context context, long value) { + setLongPreference(context, UPDATE_APK_REFRESH_TIME_PREF, value); + } + + public static void setUpdateApkDownloadId(Context context, long value) { + setLongPreference(context, UPDATE_APK_DOWNLOAD_ID, value); + } + + public static long getUpdateApkDownloadId(Context context) { + return getLongPreference(context, UPDATE_APK_DOWNLOAD_ID, -1); + } + + public static void setUpdateApkDigest(Context context, String value) { + setStringPreference(context, UPDATE_APK_DIGEST, value); + } + + public static String getUpdateApkDigest(Context context) { + return getStringPreference(context, UPDATE_APK_DIGEST, null); + } + + public static String getLocalNumber(Context context) { + return getStringPreference(context, LOCAL_NUMBER_PREF, null); + } + + public static void setLocalNumber(Context context, String localNumber) { + setStringPreference(context, LOCAL_NUMBER_PREF, localNumber); + } + + public static UUID getLocalUuid(Context context) { + return UuidUtil.parseOrNull(getStringPreference(context, LOCAL_UUID_PREF, null)); + } + + public static void setLocalUuid(Context context, UUID uuid) { + setStringPreference(context, LOCAL_UUID_PREF, uuid.toString()); + } + + public static String getPushServerPassword(Context context) { + return getStringPreference(context, GCM_PASSWORD_PREF, null); + } + + public static void setPushServerPassword(Context context, String password) { + setStringPreference(context, GCM_PASSWORD_PREF, password); + } + + public static boolean isEnterImeKeyEnabled(Context context) { + return getBooleanPreference(context, ENTER_PRESENT_PREF, false); + } + + public static boolean isEnterSendsEnabled(Context context) { + return getBooleanPreference(context, ENTER_SENDS_PREF, false); + } + + public static boolean isPasswordDisabled(Context context) { + return getBooleanPreference(context, DISABLE_PASSPHRASE_PREF, false); + } + + public static void setPasswordDisabled(Context context, boolean disabled) { + setBooleanPreference(context, DISABLE_PASSPHRASE_PREF, disabled); + } + + public static boolean getUseCustomMmsc(Context context) { + boolean legacy = TextSecurePreferences.isLegacyUseLocalApnsEnabled(context); + return getBooleanPreference(context, MMSC_CUSTOM_HOST_PREF, legacy); + } + + public static void setUseCustomMmsc(Context context, boolean value) { + setBooleanPreference(context, MMSC_CUSTOM_HOST_PREF, value); + } + + public static String getMmscUrl(Context context) { + return getStringPreference(context, MMSC_HOST_PREF, ""); + } + + public static void setMmscUrl(Context context, String mmsc) { + setStringPreference(context, MMSC_HOST_PREF, mmsc); + } + + public static boolean getUseCustomMmscProxy(Context context) { + boolean legacy = TextSecurePreferences.isLegacyUseLocalApnsEnabled(context); + return getBooleanPreference(context, MMSC_CUSTOM_PROXY_PREF, legacy); + } + + public static void setUseCustomMmscProxy(Context context, boolean value) { + setBooleanPreference(context, MMSC_CUSTOM_PROXY_PREF, value); + } + + public static String getMmscProxy(Context context) { + return getStringPreference(context, MMSC_PROXY_HOST_PREF, ""); + } + + public static void setMmscProxy(Context context, String value) { + setStringPreference(context, MMSC_PROXY_HOST_PREF, value); + } + + public static boolean getUseCustomMmscProxyPort(Context context) { + boolean legacy = TextSecurePreferences.isLegacyUseLocalApnsEnabled(context); + return getBooleanPreference(context, MMSC_CUSTOM_PROXY_PORT_PREF, legacy); + } + + public static void setUseCustomMmscProxyPort(Context context, boolean value) { + setBooleanPreference(context, MMSC_CUSTOM_PROXY_PORT_PREF, value); + } + + public static String getMmscProxyPort(Context context) { + return getStringPreference(context, MMSC_PROXY_PORT_PREF, ""); + } + + public static void setMmscProxyPort(Context context, String value) { + setStringPreference(context, MMSC_PROXY_PORT_PREF, value); + } + + public static boolean getUseCustomMmscUsername(Context context) { + boolean legacy = TextSecurePreferences.isLegacyUseLocalApnsEnabled(context); + return getBooleanPreference(context, MMSC_CUSTOM_USERNAME_PREF, legacy); + } + + public static void setUseCustomMmscUsername(Context context, boolean value) { + setBooleanPreference(context, MMSC_CUSTOM_USERNAME_PREF, value); + } + + public static String getMmscUsername(Context context) { + return getStringPreference(context, MMSC_USERNAME_PREF, ""); + } + + public static void setMmscUsername(Context context, String value) { + setStringPreference(context, MMSC_USERNAME_PREF, value); + } + + public static boolean getUseCustomMmscPassword(Context context) { + boolean legacy = TextSecurePreferences.isLegacyUseLocalApnsEnabled(context); + return getBooleanPreference(context, MMSC_CUSTOM_PASSWORD_PREF, legacy); + } + + public static void setUseCustomMmscPassword(Context context, boolean value) { + setBooleanPreference(context, MMSC_CUSTOM_PASSWORD_PREF, value); + } + + public static String getMmscPassword(Context context) { + return getStringPreference(context, MMSC_PASSWORD_PREF, ""); + } + + public static void setMmscPassword(Context context, String value) { + setStringPreference(context, MMSC_PASSWORD_PREF, value); + } + + public static String getMmsUserAgent(Context context, String defaultUserAgent) { + boolean useCustom = getBooleanPreference(context, MMS_CUSTOM_USER_AGENT, false); + + if (useCustom) return getStringPreference(context, MMS_USER_AGENT, defaultUserAgent); + else return defaultUserAgent; + } + + public static String getIdentityContactUri(Context context) { + return getStringPreference(context, IDENTITY_PREF, null); + } + + public static void setIdentityContactUri(Context context, String identityUri) { + setStringPreference(context, IDENTITY_PREF, identityUri); + } + + public static void setScreenSecurityEnabled(Context context, boolean value) { + setBooleanPreference(context, SCREEN_SECURITY_PREF, value); + } + + public static boolean isScreenSecurityEnabled(Context context) { + return getBooleanPreference(context, SCREEN_SECURITY_PREF, false); + } + + public static boolean isLegacyUseLocalApnsEnabled(Context context) { + return getBooleanPreference(context, ENABLE_MANUAL_MMS_PREF, false); + } + + public static int getLastVersionCode(Context context) { + return getIntegerPreference(context, LAST_VERSION_CODE_PREF, Util.getCanonicalVersionCode()); + } + + public static void setLastVersionCode(Context context, int versionCode) throws IOException { + if (!setIntegerPrefrenceBlocking(context, LAST_VERSION_CODE_PREF, versionCode)) { + throw new IOException("couldn't write version code to sharedpreferences"); + } + } + + public static int getLastExperienceVersionCode(Context context) { + return getIntegerPreference(context, LAST_EXPERIENCE_VERSION_PREF, 0); + } + + public static void setLastExperienceVersionCode(Context context, int versionCode) { + setIntegerPrefrence(context, LAST_EXPERIENCE_VERSION_PREF, versionCode); + } + + public static int getExperienceDismissedVersionCode(Context context) { + return getIntegerPreference(context, EXPERIENCE_DISMISSED_PREF, 0); + } + + public static void setExperienceDismissedVersionCode(Context context, int versionCode) { + setIntegerPrefrence(context, EXPERIENCE_DISMISSED_PREF, versionCode); + } + + public static String getTheme(Context context) { + return getStringPreference(context, THEME_PREF, DynamicTheme.systemThemeAvailable() ? DynamicTheme.SYSTEM : DynamicTheme.LIGHT); + } + + public static boolean isPushRegistered(Context context) { + return getBooleanPreference(context, REGISTERED_GCM_PREF, false); + } + + public static void setPushRegistered(Context context, boolean registered) { + Log.i(TAG, "Setting push registered: " + registered); + setBooleanPreference(context, REGISTERED_GCM_PREF, registered); + } + + public static boolean isShowInviteReminders(Context context) { + return getBooleanPreference(context, SHOW_INVITE_REMINDER_PREF, true); + } + + public static boolean isPassphraseTimeoutEnabled(Context context) { + return getBooleanPreference(context, PASSPHRASE_TIMEOUT_PREF, false); + } + + public static int getPassphraseTimeoutInterval(Context context) { + return getIntegerPreference(context, PASSPHRASE_TIMEOUT_INTERVAL_PREF, 5 * 60); + } + + public static void setPassphraseTimeoutInterval(Context context, int interval) { + setIntegerPrefrence(context, PASSPHRASE_TIMEOUT_INTERVAL_PREF, interval); + } + + public static String getLanguage(Context context) { + return getStringPreference(context, LANGUAGE_PREF, "zz"); + } + + public static void setLanguage(Context context, String language) { + setStringPreference(context, LANGUAGE_PREF, language); + } + + public static boolean isSmsDeliveryReportsEnabled(Context context) { + return getBooleanPreference(context, SMS_DELIVERY_REPORT_PREF, false); + } + + public static boolean hasSeenWelcomeScreen(Context context) { + return getBooleanPreference(context, SEEN_WELCOME_SCREEN_PREF, true); + } + + public static void setHasSeenWelcomeScreen(Context context, boolean value) { + setBooleanPreference(context, SEEN_WELCOME_SCREEN_PREF, value); + } + + public static boolean hasPromptedPushRegistration(Context context) { + return getBooleanPreference(context, PROMPTED_PUSH_REGISTRATION_PREF, false); + } + + public static void setPromptedPushRegistration(Context context, boolean value) { + setBooleanPreference(context, PROMPTED_PUSH_REGISTRATION_PREF, value); + } + + public static void setPromptedOptimizeDoze(Context context, boolean value) { + setBooleanPreference(context, PROMPTED_OPTIMIZE_DOZE_PREF, value); + } + + public static boolean hasPromptedOptimizeDoze(Context context) { + return getBooleanPreference(context, PROMPTED_OPTIMIZE_DOZE_PREF, false); + } + + public static boolean isInterceptAllMmsEnabled(Context context) { + return getBooleanPreference(context, ALL_MMS_PREF, true); + } + + public static boolean isInterceptAllSmsEnabled(Context context) { + return getBooleanPreference(context, ALL_SMS_PREF, true); + } + + public static boolean isNotificationsEnabled(Context context) { + return getBooleanPreference(context, NOTIFICATION_PREF, true); + } + + public static boolean isCallNotificationsEnabled(Context context) { + return getBooleanPreference(context, CALL_NOTIFICATIONS_PREF, true); + } + + public static @NonNull Uri getNotificationRingtone(Context context) { + String result = getStringPreference(context, RINGTONE_PREF, Settings.System.DEFAULT_NOTIFICATION_URI.toString()); + + if (result != null && result.startsWith("file:")) { + result = Settings.System.DEFAULT_NOTIFICATION_URI.toString(); + } + + return Uri.parse(result); + } + + public static @NonNull Uri getCallNotificationRingtone(Context context) { + String result = getStringPreference(context, CALL_RINGTONE_PREF, Settings.System.DEFAULT_RINGTONE_URI.toString()); + + if (result != null && result.startsWith("file:")) { + result = Settings.System.DEFAULT_RINGTONE_URI.toString(); + } + + return Uri.parse(result); + } + + public static void removeNotificationRingtone(Context context) { + removePreference(context, RINGTONE_PREF); + } + + public static void removeCallNotificationRingtone(Context context) { + removePreference(context, CALL_RINGTONE_PREF); + } + + public static void setNotificationRingtone(Context context, String ringtone) { + setStringPreference(context, RINGTONE_PREF, ringtone); + } + + public static void setCallNotificationRingtone(Context context, String ringtone) { + setStringPreference(context, CALL_RINGTONE_PREF, ringtone); + } + + public static void setNotificationVibrateEnabled(Context context, boolean enabled) { + setBooleanPreference(context, VIBRATE_PREF, enabled); + } + + public static boolean isNotificationVibrateEnabled(Context context) { + return getBooleanPreference(context, VIBRATE_PREF, true); + } + + public static boolean isCallNotificationVibrateEnabled(Context context) { + boolean defaultValue = true; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + defaultValue = (Settings.System.getInt(context.getContentResolver(), Settings.System.VIBRATE_WHEN_RINGING, 1) == 1); + } + + return getBooleanPreference(context, CALL_VIBRATE_PREF, defaultValue); + } + + public static String getNotificationLedColor(Context context) { + return getStringPreference(context, LED_COLOR_PREF, "blue"); + } + + public static String getNotificationLedPattern(Context context) { + return getStringPreference(context, LED_BLINK_PREF, "500,2000"); + } + + public static String getNotificationLedPatternCustom(Context context) { + return getStringPreference(context, LED_BLINK_PREF_CUSTOM, "500,2000"); + } + + public static void setNotificationLedPatternCustom(Context context, String pattern) { + setStringPreference(context, LED_BLINK_PREF_CUSTOM, pattern); + } + + public static boolean isSystemEmojiPreferred(Context context) { + return getBooleanPreference(context, SYSTEM_EMOJI_PREF, false); + } + + public static @NonNull Set getMobileMediaDownloadAllowed(Context context) { + return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_MOBILE_PREF, R.array.pref_media_download_mobile_data_default); + } + + public static @NonNull Set getWifiMediaDownloadAllowed(Context context) { + return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_WIFI_PREF, R.array.pref_media_download_wifi_default); + } + + public static @NonNull Set getRoamingMediaDownloadAllowed(Context context) { + return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_ROAMING_PREF, R.array.pref_media_download_roaming_default); + } + + private static @NonNull Set getMediaDownloadAllowed(Context context, String key, @ArrayRes int defaultValuesRes) { + return getStringSetPreference(context, + key, + new HashSet<>(Arrays.asList(context.getResources().getStringArray(defaultValuesRes)))); + } + + public static void setLastOutageCheckTime(Context context, long timestamp) { + setLongPreference(context, LAST_OUTAGE_CHECK_TIME, timestamp); + } + + public static long getLastOutageCheckTime(Context context) { + return getLongPreference(context, LAST_OUTAGE_CHECK_TIME, 0); + } + + public static void setServiceOutage(Context context, boolean isOutage) { + setBooleanPreference(context, SERVICE_OUTAGE, isOutage); + } + + public static boolean getServiceOutage(Context context) { + return getBooleanPreference(context, SERVICE_OUTAGE, false); + } + + public static long getLastFullContactSyncTime(Context context) { + return getLongPreference(context, LAST_FULL_CONTACT_SYNC_TIME, 0); + } + + public static void setLastFullContactSyncTime(Context context, long timestamp) { + setLongPreference(context, LAST_FULL_CONTACT_SYNC_TIME, timestamp); + } + + public static boolean needsFullContactSync(Context context) { + return getBooleanPreference(context, NEEDS_FULL_CONTACT_SYNC, false); + } + + public static void setNeedsFullContactSync(Context context, boolean needsSync) { + setBooleanPreference(context, NEEDS_FULL_CONTACT_SYNC, needsSync); + } + + public static void setLogEncryptedSecret(Context context, String base64Secret) { + setStringPreference(context, LOG_ENCRYPTED_SECRET, base64Secret); + } + + public static String getLogEncryptedSecret(Context context) { + return getStringPreference(context, LOG_ENCRYPTED_SECRET, null); + } + + public static void setLogUnencryptedSecret(Context context, String base64Secret) { + setStringPreference(context, LOG_UNENCRYPTED_SECRET, base64Secret); + } + + public static String getLogUnencryptedSecret(Context context) { + return getStringPreference(context, LOG_UNENCRYPTED_SECRET, null); + } + + public static int getNotificationChannelVersion(Context context) { + return getIntegerPreference(context, NOTIFICATION_CHANNEL_VERSION, 1); + } + + public static void setNotificationChannelVersion(Context context, int version) { + setIntegerPrefrence(context, NOTIFICATION_CHANNEL_VERSION, version); + } + + public static int getNotificationMessagesChannelVersion(Context context) { + return getIntegerPreference(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, 1); + } + + public static void setNotificationMessagesChannelVersion(Context context, int version) { + setIntegerPrefrence(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, version); + } + + public static boolean getNeedsMessagePull(Context context) { + return getBooleanPreference(context, NEEDS_MESSAGE_PULL, false); + } + + public static void setNeedsMessagePull(Context context, boolean needsMessagePull) { + setBooleanPreference(context, NEEDS_MESSAGE_PULL, needsMessagePull); + } + + public static boolean hasSeenStickerIntroTooltip(Context context) { + return getBooleanPreference(context, SEEN_STICKER_INTRO_TOOLTIP, false); + } + + public static void setHasSeenStickerIntroTooltip(Context context, boolean seenStickerTooltip) { + setBooleanPreference(context, SEEN_STICKER_INTRO_TOOLTIP, seenStickerTooltip); + } + + public static void setMediaKeyboardMode(Context context, MediaKeyboardMode mode) { + setStringPreference(context, MEDIA_KEYBOARD_MODE, mode.name()); + } + + public static MediaKeyboardMode getMediaKeyboardMode(Context context) { + String name = getStringPreference(context, MEDIA_KEYBOARD_MODE, MediaKeyboardMode.EMOJI.name()); + return MediaKeyboardMode.valueOf(name); + } + + public static void setHasSeenViewOnceTooltip(Context context, boolean value) { + setBooleanPreference(context, VIEW_ONCE_TOOLTIP_SEEN, value); + } + + public static boolean hasSeenViewOnceTooltip(Context context) { + return getBooleanPreference(context, VIEW_ONCE_TOOLTIP_SEEN, false); + } + + public static void setHasSeenCameraFirstTooltip(Context context, boolean value) { + setBooleanPreference(context, SEEN_CAMERA_FIRST_TOOLTIP, value); + } + + public static boolean hasSeenCameraFirstTooltip(Context context) { + return getBooleanPreference(context, SEEN_CAMERA_FIRST_TOOLTIP, false); + } + + public static void setJobManagerVersion(Context context, int version) { + setIntegerPrefrence(context, JOB_MANAGER_VERSION, version); + } + + public static int getJobManagerVersion(Context contex) { + return getIntegerPreference(contex, JOB_MANAGER_VERSION, 1); + } + + public static void setAppMigrationVersion(Context context, int version) { + setIntegerPrefrence(context, APP_MIGRATION_VERSION, version); + } + + public static int getAppMigrationVersion(Context context) { + return getIntegerPreference(context, APP_MIGRATION_VERSION, 1); + } + + public static void setFirstInstallVersion(Context context, int version) { + setIntegerPrefrence(context, FIRST_INSTALL_VERSION, version); + } + + public static int getFirstInstallVersion(Context context) { + return getIntegerPreference(context, FIRST_INSTALL_VERSION, -1); + } + + public static boolean hasSeenSwipeToReplyTooltip(Context context) { + return getBooleanPreference(context, HAS_SEEN_SWIPE_TO_REPLY, false); + } + + public static void setHasSeenSwipeToReplyTooltip(Context context, boolean value) { + setBooleanPreference(context, HAS_SEEN_SWIPE_TO_REPLY, value); + } + + public static boolean hasSeenVideoRecordingTooltip(Context context) { + return getBooleanPreference(context, HAS_SEEN_VIDEO_RECORDING_TOOLTIP, false); + } + + public static void setHasSeenVideoRecordingTooltip(Context context, boolean value) { + setBooleanPreference(context, HAS_SEEN_VIDEO_RECORDING_TOOLTIP, value); + } + + public static long getStorageManifestVersion(Context context) { + return getLongPreference(context, STORAGE_MANIFEST_VERSION, 0); + } + + public static void setStorageManifestVersion(Context context, long version) { + setLongPreference(context, STORAGE_MANIFEST_VERSION, version); + } + + public static boolean isArgon2Tested(Context context) { + return getBooleanPreference(context, ARGON2_TESTED, false); + } + + public static void setArgon2Tested(Context context, boolean tested) { + setBooleanPreference(context, ARGON2_TESTED, tested); + } + + public static void setBooleanPreference(Context context, String key, boolean value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); + } + + public static boolean getBooleanPreference(Context context, String key, boolean defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, defaultValue); + } + + public static void setStringPreference(Context context, String key, String value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, value).apply(); + } + + public static String getStringPreference(Context context, String key, String defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getString(key, defaultValue); + } + + private static int getIntegerPreference(Context context, String key, int defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(key, defaultValue); + } + + private static void setIntegerPrefrence(Context context, String key, int value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(key, value).apply(); + } + + private static boolean setIntegerPrefrenceBlocking(Context context, String key, int value) { + return PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(key, value).commit(); + } + + private static long getLongPreference(Context context, String key, long defaultValue) { + return PreferenceManager.getDefaultSharedPreferences(context).getLong(key, defaultValue); + } + + private static void setLongPreference(Context context, String key, long value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putLong(key, value).apply(); + } + + private static void removePreference(Context context, String key) { + PreferenceManager.getDefaultSharedPreferences(context).edit().remove(key).apply(); + } + + private static Set getStringSetPreference(Context context, String key, Set defaultValues) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.contains(key)) { + return prefs.getStringSet(key, Collections.emptySet()); + } else { + return defaultValues; + } + } + + // NEVER rename these -- they're persisted by name + public enum MediaKeyboardMode { + EMOJI, STICKER + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java new file mode 100644 index 00000000..6804861a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; +import android.view.LayoutInflater; + +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.view.ContextThemeWrapper; + +import org.thoughtcrime.securesms.R; + +public class ThemeUtil { + + public static boolean isDarkNotificationTheme(@NonNull Context context) { + return (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + public static boolean isDarkTheme(@NonNull Context context) { + return getAttribute(context, R.attr.theme_type, "light").equals("dark"); + } + + public static int getThemedResourceId(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.resourceId; + } + + return -1; + } + + public static boolean getThemedBoolean(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.data != 0; + } + + return false; + } + + public static @ColorInt int getThemedColor(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.data; + } + return Color.RED; + } + + public static @Nullable Drawable getThemedDrawable(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return AppCompatResources.getDrawable(context, typedValue.resourceId); + } + + return null; + } + + public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) { + Context contextThemeWrapper = new ContextThemeWrapper(context, theme); + return inflater.cloneInContext(contextThemeWrapper); + } + + public static float getThemedDimen(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.getDimension(context.getResources().getDisplayMetrics()); + } + + return 0; + } + + private static String getAttribute(Context context, int attribute, String defaultValue) { + TypedValue outValue = new TypedValue(); + + if (context.getTheme().resolveAttribute(attribute, outValue, true)) { + CharSequence charSequence = outValue.coerceToString(); + if (charSequence != null) { + return charSequence.toString(); + } + } + + return defaultValue; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ThrottledDebouncer.java b/app/src/main/java/org/thoughtcrime/securesms/util/ThrottledDebouncer.java new file mode 100644 index 00000000..e384f432 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ThrottledDebouncer.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +/** + * Mixes the behavior of {@link Throttler} and {@link Debouncer}. + * + * Like a throttler, it will limit the number of runnables to be executed to be at most once every + * specified interval, while allowing the first runnable to be run immediately. + * + * However, like a debouncer, instead of completely discarding runnables that are published in the + * throttling period, the most recent one will be saved and run at the end of the throttling period. + * + * Useful for publishing a set of identical or near-identical tasks that you want to be responsive + * and guaranteed, but limited in execution frequency. + */ +public class ThrottledDebouncer { + + private static final int WHAT = 24601; + + private final OverflowHandler handler; + private final long threshold; + + /** + * @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every + * {@code threshold} milliseconds. + */ + @MainThread + public ThrottledDebouncer(long threshold) { + this.handler = new OverflowHandler(); + this.threshold = threshold; + } + + @MainThread + public void publish(Runnable runnable) { + if (handler.hasMessages(WHAT)) { + handler.setRunnable(runnable); + } else { + runnable.run(); + handler.sendMessageDelayed(handler.obtainMessage(WHAT), threshold); + } + } + + @MainThread + public void clear() { + handler.removeCallbacksAndMessages(null); + } + + private static class OverflowHandler extends Handler { + + public OverflowHandler() { + super(Looper.getMainLooper()); + } + + private Runnable runnable; + + @Override + public void handleMessage(Message msg) { + if (msg.what == WHAT && runnable != null) { + runnable.run(); + runnable = null; + } + } + + public void setRunnable(@NonNull Runnable runnable) { + this.runnable = runnable; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Throttler.java b/app/src/main/java/org/thoughtcrime/securesms/util/Throttler.java new file mode 100644 index 00000000..66ef9d70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Throttler.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.util; + +import android.os.Handler; +import android.os.Looper; + +/** + * A class that will throttle the number of runnables executed to be at most once every specified + * interval. + * + * Useful for performing actions in response to rapid user input where you want to take action on + * the initial input but prevent follow-up spam. + * + * This is different from {@link Debouncer} in that it will run the first runnable immediately + * instead of waiting for input to die down. + * + * See http://rxmarbles.com/#throttle + */ +public class Throttler { + + private static final int WHAT = 8675309; + + private final Handler handler; + private final long threshold; + + /** + * @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every + * {@code threshold} milliseconds. + */ + public Throttler(long threshold) { + this.handler = new Handler(Looper.getMainLooper()); + this.threshold = threshold; + } + + public void publish(Runnable runnable) { + if (handler.hasMessages(WHAT)) { + return; + } + + runnable.run(); + handler.sendMessageDelayed(handler.obtainMessage(WHAT), threshold); + } + + public void clear() { + handler.removeCallbacksAndMessages(null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java b/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java new file mode 100644 index 00000000..86d9db9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; + +public class Triple { + + private final A a; + private final B b; + private final C c; + + public Triple(@Nullable A a, @Nullable B b, @Nullable C c) { + this.a = a; + this.b = b; + this.c = c; + } + + public @Nullable A first() { + return a; + } + + public @Nullable B second() { + return b; + } + + public @Nullable C third() { + return c; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Triple)) { + return false; + } + Triple t = (Triple) o; + return ObjectsCompat.equals(t.a, a) && ObjectsCompat.equals(t.b, b) && ObjectsCompat.equals(t.c, c); + } + + @Override + public int hashCode() { + return (a == null ? 0 : a.hashCode()) ^ (b == null ? 0 : b.hashCode()) ^ (c == null ? 0 : c.hashCode()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UriUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UriUtil.java new file mode 100644 index 00000000..051b3e42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UriUtil.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.IOException; + +public final class UriUtil { + + /** + * Ensures that an external URI is valid and doesn't contain any references to internal files or + * any other trickiness. + */ + public static boolean isValidExternalUri(@NonNull Context context, @NonNull Uri uri) { + if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + try { + File file = new File(uri.getPath()); + + return file.getCanonicalPath().equals(file.getPath()) && + !file.getCanonicalPath().startsWith("/data") && + !file.getCanonicalPath().contains(context.getPackageName()); + } catch (IOException e) { + return false; + } + } else { + return true; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UrlClickHandler.java b/app/src/main/java/org/thoughtcrime/securesms/util/UrlClickHandler.java new file mode 100644 index 00000000..b21a0936 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UrlClickHandler.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +public interface UrlClickHandler { + + /** + * @return true if you have handled it, false if you want to allow the standard Url handling. + */ + boolean handleOnClick(@NonNull String url); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java new file mode 100644 index 00000000..481836bf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; + +import java.io.IOException; +import java.util.UUID; +import java.util.regex.Pattern; + +public class UsernameUtil { + + private static final String TAG = Log.tag(UsernameUtil.class); + + public static final int MIN_LENGTH = 4; + public static final int MAX_LENGTH = 26; + + private static final Pattern FULL_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE); + private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$"); + + public static boolean isValidUsernameForSearch(@Nullable String value) { + return !TextUtils.isEmpty(value) && !DIGIT_START_PATTERN.matcher(value).matches(); + } + + public static Optional checkUsername(@Nullable String value) { + if (value == null) { + return Optional.of(InvalidReason.TOO_SHORT); + } else if (value.length() < MIN_LENGTH) { + return Optional.of(InvalidReason.TOO_SHORT); + } else if (value.length() > MAX_LENGTH) { + return Optional.of(InvalidReason.TOO_LONG); + } else if (DIGIT_START_PATTERN.matcher(value).matches()) { + return Optional.of(InvalidReason.STARTS_WITH_NUMBER); + } else if (!FULL_PATTERN.matcher(value).matches()) { + return Optional.of(InvalidReason.INVALID_CHARACTERS); + } else { + return Optional.absent(); + } + } + + @WorkerThread + public static @NonNull Optional fetchUuidForUsername(@NonNull Context context, @NonNull String username) { + Optional localId = DatabaseFactory.getRecipientDatabase(context).getByUsername(username); + + if (localId.isPresent()) { + Recipient recipient = Recipient.resolved(localId.get()); + + if (recipient.getUuid().isPresent()) { + Log.i(TAG, "Found username locally -- using associated UUID."); + return recipient.getUuid(); + } else { + Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it."); + DatabaseFactory.getRecipientDatabase(context).clearUsernameIfExists(username); + } + } + + try { + Log.d(TAG, "No local user with this username. Searching remotely."); + SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent()); + return Optional.fromNullable(profile.getUuid()); + } catch (IOException e) { + return Optional.absent(); + } + } + + public enum InvalidReason { + TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java new file mode 100644 index 00000000..eb3a82ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -0,0 +1,625 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Handler; +import android.os.Looper; +import android.provider.Telephony; +import android.telephony.TelephonyManager; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.StyleSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; + +import com.annimon.stream.Stream; +import com.google.android.mms.pdu_alt.CharacterSets; +import com.google.android.mms.pdu_alt.EncodedStringValue; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.signal.core.util.LinkedBlockingLifoQueue; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.OutgoingLegacyMmsConnection; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class Util { + private static final String TAG = Util.class.getSimpleName(); + + private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90); + + private static volatile Handler handler; + + public static List asList(T... elements) { + List result = new LinkedList<>(); + Collections.addAll(result, elements); + return result; + } + + public static String join(String[] list, String delimiter) { + return join(Arrays.asList(list), delimiter); + } + + public static String join(Collection list, String delimiter) { + StringBuilder result = new StringBuilder(); + int i = 0; + + for (String item : list) { + result.append(item); + + if (++i < list.size()) + result.append(delimiter); + } + + return result.toString(); + } + + public static String join(long[] list, String delimeter) { + List boxed = new ArrayList<>(list.length); + + for (int i = 0; i < list.length; i++) { + boxed.add(list[i]); + } + + return join(boxed, delimeter); + } + + @SafeVarargs + public static @NonNull List join(@NonNull List... lists) { + int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size()); + List joined = new ArrayList<>(totalSize); + + for (List list : lists) { + joined.addAll(list); + } + + return joined; + } + + public static String join(List list, String delimeter) { + StringBuilder sb = new StringBuilder(); + + for (int j = 0; j < list.size(); j++) { + if (j != 0) sb.append(delimeter); + sb.append(list.get(j)); + } + + return sb.toString(); + } + + public static String rightPad(String value, int length) { + if (value.length() >= length) { + return value; + } + + StringBuilder out = new StringBuilder(value); + while (out.length() < length) { + out.append(" "); + } + + return out.toString(); + } + + public static ExecutorService newSingleThreadedLifoExecutor() { + ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingLifoQueue()); + + executor.execute(() -> { +// Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + Thread.currentThread().setPriority(Thread.MIN_PRIORITY); + }); + + return executor; + } + + public static boolean isEmpty(EncodedStringValue[] value) { + return value == null || value.length == 0; + } + + public static boolean isEmpty(ComposeText value) { + return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); + } + + public static boolean isEmpty(Collection collection) { + return collection == null || collection.isEmpty(); + } + + public static boolean isEmpty(@Nullable String value) { + return value == null || value.length() == 0; + } + + public static boolean hasItems(@Nullable Collection collection) { + return collection != null && !collection.isEmpty(); + } + + public static V getOrDefault(@NonNull Map map, K key, V defaultValue) { + return map.containsKey(key) ? map.get(key) : defaultValue; + } + + public static String getFirstNonEmpty(String... values) { + for (String value : values) { + if (!Util.isEmpty(value)) { + return value; + } + } + return ""; + } + + public static @NonNull String emptyIfNull(@Nullable String value) { + return value != null ? value : ""; + } + + public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) { + return value != null ? value : ""; + } + + public static List> chunk(@NonNull List list, int chunkSize) { + List> chunks = new ArrayList<>(list.size() / chunkSize); + + for (int i = 0; i < list.size(); i += chunkSize) { + List chunk = list.subList(i, Math.min(list.size(), i + chunkSize)); + chunks.add(chunk); + } + + return chunks; + } + + public static CharSequence getBoldedString(String value) { + SpannableString spanned = new SpannableString(value); + spanned.setSpan(new StyleSpan(Typeface.BOLD), 0, + spanned.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spanned; + } + + public static @NonNull String toIsoString(byte[] bytes) { + try { + return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("ISO_8859_1 must be supported!"); + } + } + + public static byte[] toIsoBytes(String isoString) { + try { + return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("ISO_8859_1 must be supported!"); + } + } + + public static byte[] toUtf8Bytes(String utf8String) { + try { + return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("UTF_8 must be supported!"); + } + } + + public static void wait(Object lock, long timeout) { + try { + lock.wait(timeout); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + + @RequiresPermission(anyOf = { + android.Manifest.permission.READ_PHONE_STATE, + android.Manifest.permission.READ_SMS, + android.Manifest.permission.READ_PHONE_NUMBERS + }) + @SuppressLint("MissingPermission") + public static Optional getDeviceNumber(Context context) { + try { + final String localNumber = ((TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE)).getLine1Number(); + final Optional countryIso = getSimCountryIso(context); + + if (TextUtils.isEmpty(localNumber)) return Optional.absent(); + if (!countryIso.isPresent()) return Optional.absent(); + + return Optional.fromNullable(PhoneNumberUtil.getInstance().parse(localNumber, countryIso.get())); + } catch (NumberParseException e) { + Log.w(TAG, e); + return Optional.absent(); + } + } + + public static Optional getSimCountryIso(Context context) { + String simCountryIso = ((TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE)).getSimCountryIso(); + return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null); + } + + public static @NonNull T firstNonNull(@Nullable T optional, @NonNull T fallback) { + return optional != null ? optional : fallback; + } + + @SafeVarargs + public static @NonNull T firstNonNull(T ... ts) { + for (T t : ts) { + if (t != null) { + return t; + } + } + + throw new IllegalStateException("All choices were null."); + } + + public static List> partition(List list, int partitionSize) { + List> results = new LinkedList<>(); + + for (int index=0;index split(String source, String delimiter) { + List results = new LinkedList<>(); + + if (TextUtils.isEmpty(source)) { + return results; + } + + String[] elements = source.split(delimiter); + Collections.addAll(results, elements); + + return results; + } + + public static byte[][] split(byte[] input, int firstLength, int secondLength) { + byte[][] parts = new byte[2][]; + + parts[0] = new byte[firstLength]; + System.arraycopy(input, 0, parts[0], 0, firstLength); + + parts[1] = new byte[secondLength]; + System.arraycopy(input, firstLength, parts[1], 0, secondLength); + + return parts; + } + + public static byte[] combine(byte[]... elements) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + for (byte[] element : elements) { + baos.write(element); + } + + return baos.toByteArray(); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + public static byte[] trim(byte[] input, int length) { + byte[] result = new byte[length]; + System.arraycopy(input, 0, result, 0, result.length); + + return result; + } + + @SuppressLint("NewApi") + public static boolean isDefaultSmsProvider(Context context){ + return context.getPackageName().equals(Telephony.Sms.getDefaultSmsPackage(context)); + } + + /** + * The app version. + *

+ * This code should be used in all places that compare app versions rather than + * {@link #getManifestApkVersion(Context)} or {@link BuildConfig#VERSION_CODE}. + */ + public static int getCanonicalVersionCode() { + return BuildConfig.CANONICAL_VERSION_CODE; + } + + /** + * {@link BuildConfig#VERSION_CODE} may not be the actual version due to ABI split code adding a + * postfix after BuildConfig is generated. + *

+ * However, in most cases you want to use {@link BuildConfig#CANONICAL_VERSION_CODE} via + * {@link #getCanonicalVersionCode()} + */ + public static int getManifestApkVersion(Context context) { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode; + } catch (PackageManager.NameNotFoundException e) { + throw new AssertionError(e); + } + } + + public static String getSecret(int size) { + byte[] secret = getSecretBytes(size); + return Base64.encodeBytes(secret); + } + + public static byte[] getSecretBytes(int size) { + return getSecretBytes(new SecureRandom(), size); + } + + public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size) { + byte[] secret = new byte[size]; + secureRandom.nextBytes(secret); + return secret; + } + + /** + * @return The amount of time (in ms) until this build of Signal will be considered 'expired'. + * Takes into account both the build age as well as any remote deprecation values. + */ + public static long getTimeUntilBuildExpiry() { + if (SignalStore.misc().isClientDeprecated()) { + return 0; + } + + long buildAge = System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP; + long timeUntilBuildDeprecation = BUILD_LIFESPAN - buildAge; + long timeUntilRemoteDeprecation = RemoteDeprecation.getTimeUntilDeprecation(); + + if (timeUntilRemoteDeprecation != -1) { + long timeUntilDeprecation = Math.min(timeUntilBuildDeprecation, timeUntilRemoteDeprecation); + return Math.max(timeUntilDeprecation, 0); + } else { + return Math.max(timeUntilBuildDeprecation, 0); + } + } + + @TargetApi(VERSION_CODES.LOLLIPOP) + public static boolean isMmsCapable(Context context) { + return (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) || OutgoingLegacyMmsConnection.isConnectionPossible(context); + } + + public static boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } + + public static void assertMainThread() { + if (!isMainThread()) { + throw new AssertionError("Must run on main thread."); + } + } + + public static void assertNotMainThread() { + if (isMainThread()) { + throw new AssertionError("Cannot run on main thread."); + } + } + + public static void postToMain(final @NonNull Runnable runnable) { + getHandler().post(runnable); + } + + public static void runOnMain(final @NonNull Runnable runnable) { + if (isMainThread()) runnable.run(); + else getHandler().post(runnable); + } + + public static void runOnMainDelayed(final @NonNull Runnable runnable, long delayMillis) { + getHandler().postDelayed(runnable, delayMillis); + } + + public static void cancelRunnableOnMain(@NonNull Runnable runnable) { + getHandler().removeCallbacks(runnable); + } + + public static void runOnMainSync(final @NonNull Runnable runnable) { + if (isMainThread()) { + runnable.run(); + } else { + final CountDownLatch sync = new CountDownLatch(1); + runOnMain(() -> { + try { + runnable.run(); + } finally { + sync.countDown(); + } + }); + try { + sync.await(); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + } + + public static T getRandomElement(T[] elements) { + return elements[new SecureRandom().nextInt(elements.length)]; + } + + public static boolean equals(@Nullable Object a, @Nullable Object b) { + return a == b || (a != null && a.equals(b)); + } + + public static int hashCode(@Nullable Object... objects) { + return Arrays.hashCode(objects); + } + + public static @Nullable Uri uri(@Nullable String uri) { + if (uri == null) return null; + else return Uri.parse(uri); + } + + @TargetApi(VERSION_CODES.KITKAT) + public static boolean isLowMemory(Context context) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) || + activityManager.getLargeMemoryClass() <= 64; + } + + public static int clamp(int value, int min, int max) { + return Math.min(Math.max(value, min), max); + } + + public static long clamp(long value, long min, long max) { + return Math.min(Math.max(value, min), max); + } + + public static float clamp(float value, float min, float max) { + return Math.min(Math.max(value, min), max); + } + + public static @Nullable String readTextFromClipboard(@NonNull Context context) { + { + ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) { + return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString(); + } else { + return null; + } + } + } + + public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) { + { + ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClip(ClipData.newPlainText("Safety numbers", text)); + } + } + + public static int toIntExact(long value) { + if ((int)value != value) { + throw new ArithmeticException("integer overflow"); + } + return (int)value; + } + + public static boolean isStringEquals(String first, String second) { + if (first == null) return second == null; + return first.equals(second); + } + + public static boolean isEquals(@Nullable Long first, long second) { + return first != null && first == second; + } + + public static String getPrettyFileSize(long sizeBytes) { + return MemoryUnitFormat.formatBytes(sizeBytes); + } + + public static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + public static void copyToClipboard(@NonNull Context context, @NonNull String text) { + ServiceUtil.getClipboardManager(context).setPrimaryClip(ClipData.newPlainText("text", text)); + } + + private static Handler getHandler() { + if (handler == null) { + synchronized (Util.class) { + if (handler == null) { + handler = new Handler(Looper.getMainLooper()); + } + } + } + return handler; + } + + @SafeVarargs + public static List concatenatedList(Collection ... items) { + final List concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size())); + + for (Collection list : items) { + concat.addAll(list); + } + + return concat; + } + + public static boolean isLong(String value) { + try { + Long.parseLong(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + public static int parseInt(String integer, int defaultValue) { + try { + return Integer.parseInt(integer); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Appends the stack trace of the provided throwable onto the provided primary exception. This is + * useful for when exceptions are thrown inside of asynchronous systems (like runnables in an + * executor) where you'd otherwise lose important parts of the stack trace. This lets you save a + * throwable at the entry point, and then combine it with any caught exceptions later. + * + * @return The provided primary exception, for convenience. + */ + public static RuntimeException appendStackTrace(@NonNull RuntimeException primary, @NonNull Throwable secondary) { + StackTraceElement[] now = primary.getStackTrace(); + StackTraceElement[] then = secondary.getStackTrace(); + StackTraceElement[] combined = new StackTraceElement[now.length + then.length]; + + System.arraycopy(now, 0, combined, 0, now.length); + System.arraycopy(then, 0, combined, now.length, then.length); + + primary.setStackTrace(combined); + + return primary; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java b/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java new file mode 100644 index 00000000..0a3f61d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.text.style.ClickableSpan; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.IdentityKey; + +public class VerifySpan extends ClickableSpan { + + private final Context context; + private final RecipientId recipientId; + private final IdentityKey identityKey; + + public VerifySpan(@NonNull Context context, @NonNull IdentityKeyMismatch mismatch) { + this.context = context; + this.recipientId = mismatch.getRecipientId(context); + this.identityKey = mismatch.getIdentityKey(); + } + + public VerifySpan(@NonNull Context context, @NonNull RecipientId recipientId, @NonNull IdentityKey identityKey) { + this.context = context; + this.recipientId = recipientId; + this.identityKey = identityKey; + } + + @Override + public void onClick(@NonNull View widget) { + context.startActivity(VerifyIdentityActivity.newIntent(context, recipientId, identityKey, false)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java new file mode 100644 index 00000000..53509b51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.content.pm.PackageManager; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class VersionTracker { + + private static final String TAG = Log.tag(VersionTracker.class); + + public static int getLastSeenVersion(@NonNull Context context) { + return TextSecurePreferences.getLastVersionCode(context); + } + + public static void updateLastSeenVersion(@NonNull Context context) { + try { + int currentVersionCode = Util.getCanonicalVersionCode(); + int lastVersionCode = TextSecurePreferences.getLastVersionCode(context); + + if (currentVersionCode != lastVersionCode) { + Log.i(TAG, "Upgraded from " + lastVersionCode + " to " + currentVersionCode); + SignalStore.misc().clearClientDeprecated(); + TextSecurePreferences.setLastVersionCode(context, currentVersionCode); + ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); + } + } catch (IOException ioe) { + throw new AssertionError(ioe); + } + } + + public static long getDaysSinceFirstInstalled(Context context) { + try { + long installTimestamp = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0) + .firstInstallTime; + + return TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - installTimestamp); + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, e); + return 0; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VibrateUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/VibrateUtil.java new file mode 100644 index 00000000..569213de --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VibrateUtil.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; + +import androidx.annotation.NonNull; + +public final class VibrateUtil { + + private static final int TICK_LENGTH = 30; + + private VibrateUtil() { } + + public static void vibrateTick(@NonNull Context context) { + Vibrator vibrator = ServiceUtil.getVibrator(context); + + if (Build.VERSION.SDK_INT >= 26) { + VibrationEffect effect = VibrationEffect.createOneShot(TICK_LENGTH, 64); + vibrator.vibrate(effect); + } else { + vibrator.vibrate(TICK_LENGTH); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java new file mode 100644 index 00000000..ff2c7645 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -0,0 +1,357 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.ViewTreeObserver; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.core.view.ViewCompat; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import androidx.lifecycle.Lifecycle; + +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; +import org.thoughtcrime.securesms.util.views.Stub; + +public final class ViewUtil { + + private ViewUtil() { + } + + public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) { + input.requestFocus(); + + int numberLength = input.getText().length(); + input.setSelection(numberLength, numberLength); + + InputMethodManager imm = (InputMethodManager) input.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT); + + if (!imm.isAcceptingText()) { + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + } + + public static void focusAndShowKeyboard(@NonNull View view) { + view.requestFocus(); + if (view.hasWindowFocus()) { + showTheKeyboardNow(view); + } else { + view.getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() { + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (hasFocus) { + showTheKeyboardNow(view); + view.getViewTreeObserver().removeOnWindowFocusChangeListener(this); + } + } + }); + } + } + + private static void showTheKeyboardNow(@NonNull View view) { + if (view.isFocused()) { + view.post(() -> { + InputMethodManager inputMethodManager = ServiceUtil.getInputMethodManager(view.getContext()); + inputMethodManager.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); + }); + } + } + + @SuppressWarnings("unchecked") + public static T inflateStub(@NonNull View parent, @IdRes int stubId) { + return (T)((ViewStub)parent.findViewById(stubId)).inflate(); + } + + public static Stub findStubById(@NonNull Activity parent, @IdRes int resId) { + return new Stub<>(parent.findViewById(resId)); + } + + private static Animation getAlphaAnimation(float from, float to, int duration) { + final Animation anim = new AlphaAnimation(from, to); + anim.setInterpolator(new FastOutSlowInInterpolator()); + anim.setDuration(duration); + return anim; + } + + public static void fadeIn(final @NonNull View view, final int duration) { + animateIn(view, getAlphaAnimation(0f, 1f, duration)); + } + + public static ListenableFuture fadeOut(final @NonNull View view, final int duration) { + return fadeOut(view, duration, View.GONE); + } + + public static ListenableFuture fadeOut(@NonNull View view, int duration, int visibility) { + return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility); + } + + public static ListenableFuture animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) { + final SettableFuture future = new SettableFuture(); + if (view.getVisibility() == visibility) { + future.set(true); + } else { + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationRepeat(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + view.setVisibility(visibility); + future.set(true); + } + }); + view.startAnimation(animation); + } + return future; + } + + public static void animateIn(final @NonNull View view, final @NonNull Animation animation) { + if (view.getVisibility() == View.VISIBLE) return; + + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + view.setVisibility(View.VISIBLE); + view.startAnimation(animation); + } + + @SuppressWarnings("unchecked") + public static T inflate(@NonNull LayoutInflater inflater, + @NonNull ViewGroup parent, + @LayoutRes int layoutResId) + { + return (T)(inflater.inflate(layoutResId, parent, false)); + } + + @SuppressLint("RtlHardcoded") + public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) { + if (isRtl(context)) { + textView.setGravity(Gravity.RIGHT); + } else { + textView.setGravity(Gravity.LEFT); + } + } + + public static void mirrorIfRtl(View view, Context context) { + if (isRtl(context)) { + view.setScaleX(-1.0f); + } + } + + public static boolean isLtr(@NonNull View view) { + return isLtr(view.getContext()); + } + + public static boolean isLtr(@NonNull Context context) { + return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR; + } + + public static boolean isRtl(@NonNull View view) { + return isRtl(view.getContext()); + } + + public static boolean isRtl(@NonNull Context context) { + return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } + + public static float pxToDp(float px) { + return px / Resources.getSystem().getDisplayMetrics().density; + } + + public static int dpToPx(Context context, int dp) { + return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5); + } + + public static int dpToPx(int dp) { + return Math.round(dp * Resources.getSystem().getDisplayMetrics().density); + } + + public static int dpToSp(int dp) { + return (int) (dpToPx(dp) / Resources.getSystem().getDisplayMetrics().scaledDensity); + } + + public static int spToPx(float sp) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, Resources.getSystem().getDisplayMetrics()); + } + + public static void updateLayoutParams(@NonNull View view, int width, int height) { + view.getLayoutParams().width = width; + view.getLayoutParams().height = height; + view.requestLayout(); + } + + public static void updateLayoutParamsIfNonNull(@Nullable View view, int width, int height) { + if (view != null) { + updateLayoutParams(view, width, height); + } + } + + public static int getLeftMargin(@NonNull View view) { + if (isLtr(view)) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + + public static int getRightMargin(@NonNull View view) { + if (isLtr(view)) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + + public static void setLeftMargin(@NonNull View view, int margin) { + if (isLtr(view)) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin; + } else { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin; + } + view.forceLayout(); + view.requestLayout(); + } + + public static void setRightMargin(@NonNull View view, int margin) { + if (isLtr(view)) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin; + } else { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin; + } + view.forceLayout(); + view.requestLayout(); + } + + public static void setTopMargin(@NonNull View view, int margin) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin; + view.requestLayout(); + } + + public static void setBottomMargin(@NonNull View view, int margin) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).bottomMargin = margin; + view.requestLayout(); + } + + public static void setPaddingTop(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom()); + } + + public static void setPaddingBottom(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding); + } + + public static void setPadding(@NonNull View view, int padding) { + view.setPadding(padding, padding, padding, padding); + } + + public static void setPaddingStart(@NonNull View view, int padding) { + if (isLtr(view)) { + view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom()); + } else { + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom()); + } + } + + public static void setPaddingEnd(@NonNull View view, int padding) { + if (isLtr(view)) { + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom()); + } else { + view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom()); + } + } + + public static boolean isPointInsideView(@NonNull View view, float x, float y) { + int[] location = new int[2]; + + view.getLocationOnScreen(location); + + int viewX = location[0]; + int viewY = location[1]; + + return x > viewX && x < viewX + view.getWidth() && + y > viewY && y < viewY + view.getHeight(); + } + + public static int getStatusBarHeight(@NonNull View view) { + int result = 0; + int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resourceId > 0) { + result = view.getResources().getDimensionPixelSize(resourceId); + } + return result; + } + + public static void hideKeyboard(@NonNull Context context, @NonNull View view) { + InputMethodManager inputManager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE); + inputManager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + /** + * Enables or disables a view and all child views recursively. + */ + public static void setEnabledRecursive(@NonNull View view, boolean enabled) { + view.setEnabled(enabled); + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + setEnabledRecursive(viewGroup.getChildAt(i), enabled); + } + } + } + + public static @Nullable Lifecycle getActivityLifecycle(@NonNull View view) { + return getActivityLifecycle(view.getContext()); + } + + private static @Nullable Lifecycle getActivityLifecycle(@Nullable Context context) { + if (context instanceof ContextThemeWrapper) { + return getActivityLifecycle(((ContextThemeWrapper) context).getBaseContext()); + } + + if (context instanceof AppCompatActivity) { + return ((AppCompatActivity) context).getLifecycle(); + } + + return null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java new file mode 100644 index 00000000..a4e52737 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +public class WakeLockUtil { + + private static final String TAG = WakeLockUtil.class.getSimpleName(); + + /** + * Run a runnable with a wake lock. Ensures that the lock is safely acquired and released. + * + * @param tag will be prefixed with "signal:" if it does not already start with it. + */ + public static void runWithLock(@NonNull Context context, int lockType, long timeout, @NonNull String tag, @NonNull Runnable task) { + WakeLock wakeLock = null; + try { + wakeLock = acquire(context, lockType, timeout, tag); + task.run(); + } finally { + if (wakeLock != null) { + release(wakeLock, tag); + } + } + } + + /** + * @param tag will be prefixed with "signal:" if it does not already start with it. + */ + public static WakeLock acquire(@NonNull Context context, int lockType, long timeout, @NonNull String tag) { + tag = prefixTag(tag); + try { + PowerManager powerManager = ServiceUtil.getPowerManager(context); + WakeLock wakeLock = powerManager.newWakeLock(lockType, tag); + + wakeLock.acquire(timeout); + + return wakeLock; + } catch (Exception e) { + Log.w(TAG, "Failed to acquire wakelock with tag: " + tag, e); + return null; + } + } + + /** + * @param tag will be prefixed with "signal:" if it does not already start with it. + */ + public static void release(@Nullable WakeLock wakeLock, @NonNull String tag) { + tag = prefixTag(tag); + try { + if (wakeLock == null) { + Log.d(TAG, "Wakelock was null. Skipping. Tag: " + tag); + } else if (wakeLock.isHeld()) { + wakeLock.release(); + } else { + Log.d(TAG, "Wakelock wasn't held at time of release: " + tag); + } + } catch (Exception e) { + Log.w(TAG, "Failed to release wakelock with tag: " + tag, e); + } + } + + private static String prefixTag(@NonNull String tag) { + return tag.startsWith("signal:") ? tag : "signal:" + tag; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java new file mode 100644 index 00000000..2c737ebf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Activity; +import android.graphics.Rect; +import android.os.Build; +import android.view.View; +import android.view.Window; +import android.view.WindowInsetsController; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +public final class WindowUtil { + + private WindowUtil() { + } + + public static void setLightNavigationBarFromTheme(@NonNull Activity activity) { + if (Build.VERSION.SDK_INT < 27) return; + + final boolean isLightNavigationBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightNavigationBar); + + if (isLightNavigationBar) setLightNavigationBar(activity.getWindow()); + else clearLightNavigationBar(activity.getWindow()); + } + + public static void clearLightNavigationBar(@NonNull Window window) { + if (Build.VERSION.SDK_INT < 27) return; + + clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + } + + public static void setLightNavigationBar(@NonNull Window window) { + if (Build.VERSION.SDK_INT < 27) return; + + setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + } + + public static void setLightStatusBarFromTheme(@NonNull Activity activity) { + if (Build.VERSION.SDK_INT < 23) return; + + final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar); + + if (isLightStatusBar) setLightStatusBar(activity.getWindow()); + else clearLightStatusBar(activity.getWindow()); + } + + public static void clearLightStatusBar(@NonNull Window window) { + if (Build.VERSION.SDK_INT < 23) return; + + clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); + } + + public static void setLightStatusBar(@NonNull Window window) { + if (Build.VERSION.SDK_INT < 23) return; + + setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); + } + + public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) { + if (Build.VERSION.SDK_INT < 21) return; + + window.setStatusBarColor(color); + } + + /** + * A sort of roundabout way of determining if the status bar is present by seeing if there's a + * vertical window offset. + */ + public static boolean isStatusBarPresent(@NonNull Window window) { + Rect rectangle = new Rect(); + window.getDecorView().getWindowVisibleDisplayFrame(rectangle); + return rectangle.top > 0; + } + + private static void clearSystemUiFlags(@NonNull Window window, int flags) { + View view = window.getDecorView(); + int uiFlags = view.getSystemUiVisibility(); + + uiFlags &= ~flags; + view.setSystemUiVisibility(uiFlags); + } + + private static void setSystemUiFlags(@NonNull Window window, int flags) { + View view = window.getDecorView(); + int uiFlags = view.getSystemUiVisibility(); + + uiFlags |= flags; + view.setSystemUiVisibility(uiFlags); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/WorkerThread.java b/app/src/main/java/org/thoughtcrime/securesms/util/WorkerThread.java new file mode 100644 index 00000000..7d77f543 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/WorkerThread.java @@ -0,0 +1,48 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util; + +import java.util.List; + +public class WorkerThread extends Thread { + + private final List workQueue; + + public WorkerThread(List workQueue, String name) { + super(name); + this.workQueue = workQueue; + } + + private Runnable getWork() { + synchronized (workQueue) { + try { + while (workQueue.isEmpty()) + workQueue.wait(); + + return workQueue.remove(0); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + } + + @Override + public void run() { + for (;;) + getWork().run(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java new file mode 100644 index 00000000..ab58c9cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.util.adapter; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; + +public final class AlwaysChangedDiffUtil extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/FixedViewsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/FixedViewsAdapter.java new file mode 100644 index 00000000..3d9af706 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/FixedViewsAdapter.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.util.adapter; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Arrays; +import java.util.List; + +public final class FixedViewsAdapter extends RecyclerView.Adapter { + + private final List viewList; + + private boolean hidden; + + public FixedViewsAdapter(@NonNull View... viewList) { + this.viewList = Arrays.asList(viewList); + } + + @Override + public int getItemCount() { + return hidden ? 0 : viewList.size(); + } + + /** + * @return View type is the index. + */ + @Override + public int getItemViewType(int position) { + return position; + } + + /** + * @param viewType The index in the list of views. + */ + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + return new RecyclerView.ViewHolder(viewList.get(viewType)) { + }; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + } + + @Override + public long getItemId(int position) { + return position; + } + + public void hide() { + setHidden(true); + } + + public void show() { + setHidden(false); + } + + private void setHidden(boolean hidden) { + if (this.hidden != hidden) { + this.hidden = hidden; + + if (hidden) { + notifyItemRangeRemoved(0, viewList.size()); + } else { + notifyItemRangeInserted(0, viewList.size()); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java new file mode 100644 index 00000000..0471e77c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2017 Martijn van der Woude + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Original source: https://github.com/martijnvdwoude/recycler-view-merge-adapter + * + * This file has been modified by Signal. + */ + +package org.thoughtcrime.securesms.util.adapter; + +import android.util.LongSparseArray; +import android.util.SparseIntArray; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.LinkedList; +import java.util.List; + +public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter { + + private final List adapters = new LinkedList<>(); + + private long nextUnassignedItemId; + + /** + * Map of global view type to local adapter. + *

+ * Not the same as {@link #adapters}, it may have duplicates and may be in a different order. + */ + private final List viewTypes = new LinkedList<>(); + + /** Observes a single sub adapter and maps the positions on the events to global positions. */ + private static class AdapterDataObserver extends RecyclerView.AdapterDataObserver { + + private final RecyclerViewConcatenateAdapter mergeAdapter; + private final RecyclerView.Adapter adapter; + + AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter adapter) { + this.mergeAdapter = mergeAdapter; + this.adapter = adapter; + } + + @Override + public void onChanged() { + mergeAdapter.notifyDataSetChanged(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter); + + mergeAdapter.notifyItemRangeChanged(subAdapterOffset + positionStart, itemCount); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter); + + mergeAdapter.notifyItemRangeInserted(subAdapterOffset + positionStart, itemCount); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter); + + mergeAdapter.notifyItemRangeRemoved(subAdapterOffset + positionStart, itemCount); + } + } + + private static class ChildAdapter { + + final RecyclerView.Adapter adapter; + + /** Map of global view types to local view types */ + private final SparseIntArray globalViewTypesMap = new SparseIntArray(); + + /** Map of local view types to global view types */ + private final SparseIntArray localViewTypesMap = new SparseIntArray(); + + private final AdapterDataObserver adapterDataObserver; + + /** Map of local ids to global ids. */ + private final LongSparseArray localItemIdMap = new LongSparseArray<>(); + + ChildAdapter(@NonNull RecyclerView.Adapter adapter, @NonNull AdapterDataObserver adapterDataObserver) { + this.adapter = adapter; + this.adapterDataObserver = adapterDataObserver; + + this.adapter.registerAdapterDataObserver(this.adapterDataObserver); + } + + int getGlobalItemViewType(int localPosition, int defaultValue) { + int localViewType = adapter.getItemViewType(localPosition); + int globalViewType = localViewTypesMap.get(localViewType, defaultValue); + + if (globalViewType == defaultValue) { + globalViewTypesMap.append(globalViewType, localViewType); + localViewTypesMap.append(localViewType, globalViewType); + } + + return globalViewType; + } + + long getGlobalItemId(int localPosition, long defaultGlobalValue) { + final long localItemId = adapter.getItemId(localPosition); + + if (RecyclerView.NO_ID == localItemId) { + return RecyclerView.NO_ID; + } + + final Long globalItemId = localItemIdMap.get(localItemId); + + if (globalItemId == null) { + localItemIdMap.put(localItemId, defaultGlobalValue); + return defaultGlobalValue; + } + + return globalItemId; + } + + void unregister() { + adapter.unregisterAdapterDataObserver(adapterDataObserver); + } + + RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int globalViewType) { + int localViewType = globalViewTypesMap.get(globalViewType); + + return adapter.onCreateViewHolder(viewGroup, localViewType); + } + } + + public static class ChildAdapterPositionPair { + + final ChildAdapter childAdapter; + final int localPosition; + + ChildAdapterPositionPair(@NonNull ChildAdapter adapter, int position) { + childAdapter = adapter; + localPosition = position; + } + + RecyclerView.Adapter getAdapter() { + return childAdapter.adapter; + } + + public int getLocalPosition() { + return localPosition; + } + } + + /** + * @param adapter Append an adapter to the list of adapters. + */ + public void addAdapter(@NonNull RecyclerView.Adapter adapter) { + addAdapter(adapters.size(), adapter); + } + + /** + * @param index The index at which to add an adapter to the list of adapters. + * @param adapter The adapter to add. + */ + public void addAdapter(int index, @NonNull RecyclerView.Adapter adapter) { + AdapterDataObserver adapterDataObserver = new AdapterDataObserver(this, adapter); + adapters.add(index, new ChildAdapter(adapter, adapterDataObserver)); + notifyDataSetChanged(); + } + + /** + * Clear all adapters from the list of adapters. + */ + public void clearAdapters() { + for (ChildAdapter childAdapter : adapters) { + childAdapter.unregister(); + } + + adapters.clear(); + notifyDataSetChanged(); + } + + /** + * Return a childAdapterPositionPair object for a given global position. + * + * @param globalPosition The global position in the entire set of items. + * @return A childAdapterPositionPair object containing a reference to the adapter and the local + * position in that adapter that corresponds to the given global position. + */ + @NonNull + public ChildAdapterPositionPair getLocalPosition(final int globalPosition) { + int count = 0; + + for (ChildAdapter childAdapter : adapters) { + int newCount = count + childAdapter.adapter.getItemCount(); + + if (globalPosition < newCount) { + return new ChildAdapterPositionPair(childAdapter, globalPosition - count); + } + + count = newCount; + } + + throw new AssertionError("Position out of range"); + } + + @Override + @NonNull + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + ChildAdapter childAdapter = viewTypes.get(viewType); + if (childAdapter == null) { + throw new AssertionError("Unknown view type"); + } + + return childAdapter.onCreateViewHolder(viewGroup, viewType); + } + + /** + * Return the first global position in the entire set of items for a given adapter. + * + * @param adapter The adapter for which to the return the first global position. + * @return The first global position for the given adapter, or -1 if no such position could be found. + */ + private int getSubAdapterFirstGlobalPosition(@NonNull RecyclerView.Adapter adapter) { + int count = 0; + + for (ChildAdapter childAdapterWrapper : adapters) { + RecyclerView.Adapter childAdapter = childAdapterWrapper.adapter; + + if (childAdapter == adapter) { + return count; + } + + count += childAdapter.getItemCount(); + } + + throw new AssertionError("Adapter not found in list of adapters"); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + ChildAdapterPositionPair childAdapterPositionPair = getLocalPosition(position); + RecyclerView.Adapter adapter = childAdapterPositionPair.getAdapter(); + //noinspection unchecked + adapter.onBindViewHolder(viewHolder, childAdapterPositionPair.localPosition); + } + + @Override + public int getItemViewType(int position) { + int nextUnassignedViewType = viewTypes.size(); + ChildAdapterPositionPair localPosition = getLocalPosition(position); + + int viewType = localPosition.childAdapter.getGlobalItemViewType(localPosition.localPosition, nextUnassignedViewType); + + if (viewType == nextUnassignedViewType) { + viewTypes.add(viewType, localPosition.childAdapter); + } + + return viewType; + } + + @Override + public long getItemId(int position) { + ChildAdapterPositionPair localPosition = getLocalPosition(position); + + long itemId = localPosition.childAdapter.getGlobalItemId(localPosition.localPosition, nextUnassignedItemId); + + if (itemId == nextUnassignedItemId) { + nextUnassignedItemId++; + } + + return itemId; + } + + @Override + public int getItemCount() { + int count = 0; + + for (ChildAdapter adapter : adapters) { + count += adapter.adapter.getItemCount(); + } + + return count; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapterStickyHeader.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapterStickyHeader.java new file mode 100644 index 00000000..b97d4e40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapterStickyHeader.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util.adapter; + +import android.view.ViewGroup; + +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.components.RecyclerViewFastScroller; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +public final class RecyclerViewConcatenateAdapterStickyHeader extends RecyclerViewConcatenateAdapter + implements StickyHeaderDecoration.StickyHeaderAdapter, + RecyclerViewFastScroller.FastScrollAdapter +{ + + @Override + public long getHeaderId(int position) { + return getForPosition(position).transform(p -> p.first().getHeaderId(p.second())).or(-1L); + } + + @Override + public RecyclerView.ViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) { + return getForPosition(position).transform(p -> p.first().onCreateHeaderViewHolder(parent, p.second(), type)).orNull(); + } + + @Override + public void onBindHeaderViewHolder(RecyclerView.ViewHolder viewHolder, int position, int type) { + Optional> forPosition = getForPosition(position); + + if (forPosition.isPresent()) { + Pair stickyHeaderAdapterIntegerPair = forPosition.get(); + //noinspection unchecked + stickyHeaderAdapterIntegerPair.first().onBindHeaderViewHolder(viewHolder, stickyHeaderAdapterIntegerPair.second(), type); + } + } + + @Override + public CharSequence getBubbleText(int position) { + Optional> forPosition = getForPosition(position); + + return forPosition.transform(a -> { + if (a.first() instanceof RecyclerViewFastScroller.FastScrollAdapter) { + return ((RecyclerViewFastScroller.FastScrollAdapter) a.first()).getBubbleText(a.second()); + } else { + return ""; + } + }).or(""); + } + + private Optional> getForPosition(int position) { + ChildAdapterPositionPair localAdapterPosition = getLocalPosition(position); + RecyclerView.Adapter adapter = localAdapterPosition.getAdapter(); + + if (adapter instanceof StickyHeaderDecoration.StickyHeaderAdapter) { + StickyHeaderDecoration.StickyHeaderAdapter sticky = (StickyHeaderDecoration.StickyHeaderAdapter) adapter; + return Optional.of(new Pair<>(sticky, localAdapterPosition.localPosition)); + } + return Optional.absent(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SectionedRecyclerViewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SectionedRecyclerViewAdapter.java new file mode 100644 index 00000000..d58a4768 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SectionedRecyclerViewAdapter.java @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.util.adapter; + +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; + +import java.util.List; + +/** + * A {@link RecyclerView.Adapter} subclass that makes it easier to have sectioned content, where + * you have header rows and content rows. + * + * @param The type you'll use to generate stable IDs. + * @param The subclass of {@link Section} you're using. + */ +public abstract class SectionedRecyclerViewAdapter> extends RecyclerView.Adapter { + + private static final int TYPE_HEADER = 1; + private static final int TYPE_CONTENT = 2; + private static final int TYPE_EMPTY = 3; + + private final StableIdGenerator stableIdGenerator; + + public SectionedRecyclerViewAdapter() { + this.stableIdGenerator = new StableIdGenerator<>(); + setHasStableIds(true); + } + + protected @NonNull abstract List getSections(); + protected @NonNull abstract RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent); + protected @NonNull abstract RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent); + protected @Nullable abstract RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup); + protected abstract void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull SectionImpl section, int localPosition); + + @Override + public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + switch (viewType) { + case TYPE_HEADER: + return createHeaderViewHolder(viewGroup); + case TYPE_CONTENT: + return createContentViewHolder(viewGroup); + case TYPE_EMPTY: + RecyclerView.ViewHolder holder = createEmptyViewHolder(viewGroup); + if (holder == null) { + throw new IllegalStateException("Expected an empty view holder, but got none!"); + } + return holder; + default: + throw new AssertionError("Unexpected viewType! " + viewType); + } + } + + @Override + public long getItemId(int globalPosition) { + for (SectionImpl section: getSections()) { + if (section.handles(globalPosition)) { + return section.getItemId(stableIdGenerator, globalPosition); + } + } + throw new NoSectionException(); + } + + @Override + public int getItemViewType(int globalPosition) { + for (SectionImpl section : getSections()) { + if (section.handles(globalPosition)) { + return section.getViewType(globalPosition); + } + } + throw new NoSectionException(); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) { + for (SectionImpl section : getSections()) { + if (section.handles(globalPosition)) { + bindViewHolder(holder, section, section.getLocalPosition(globalPosition)); + return; + } + } + throw new NoSectionException(); + } + + @Override + public int getItemCount() { + return Stream.of(getSections()).reduce(0, (sum, section) -> sum + section.size()); + } + + /** + * Represents a section of content in the adapter. Has a header and content. + * @param The type you'll use to generate stable IDs. + */ + public static abstract class Section { + + private final int offset; + + public Section(int offset) { + this.offset = offset; + } + + public abstract boolean hasEmptyState(); + public abstract int getContentSize(); + public abstract long getItemId(@NonNull StableIdGenerator idGenerator, int globalPosition); + + protected final int getLocalPosition(int globalPosition) { + return globalPosition - offset; + } + + final int getViewType(int globalPosition) { + int localPosition = getLocalPosition(globalPosition); + + if (localPosition == 0) { + return TYPE_HEADER; + } else if (getContentSize() == 0) { + return TYPE_EMPTY; + } else { + return TYPE_CONTENT; + } + } + + final boolean handles(int globalPosition) { + int localPosition = getLocalPosition(globalPosition); + return localPosition >= 0 && localPosition < size(); + } + + public boolean isContent(int globalPosition) { + return handles(globalPosition) && getViewType(globalPosition) == TYPE_CONTENT; + } + + public final int size() { + if (getContentSize() == 0 && hasEmptyState()) { + return 2; + } else if (getContentSize() == 0) { + return 0; + } else { + return getContentSize() + 1; + } + } + } + + private static class NoSectionException extends IllegalStateException {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/StableIdGenerator.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/StableIdGenerator.java new file mode 100644 index 00000000..f3d52277 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/StableIdGenerator.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.util.adapter; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashMap; +import java.util.Map; + +/** + * Useful for generate ID's to be used with + * {@link RecyclerView.Adapter#getItemId(int)} when you otherwise don't + * have a good way to generate an ID. + */ +public class StableIdGenerator { + + private final Map keys = new HashMap<>(); + + private long index = 1; + + @MainThread + public long getId(@NonNull E item) { + if (keys.containsKey(item)) { + return keys.get(item); + } + + long key = index++; + keys.put(item, key); + + return key; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/cjkv/CJKVUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/cjkv/CJKVUtil.java new file mode 100644 index 00000000..96baf94f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/cjkv/CJKVUtil.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.util.cjkv; + +import androidx.annotation.Nullable; + +public final class CJKVUtil { + + private CJKVUtil() { + } + + public static boolean isCJKV(@Nullable String value) { + if (value == null || value.length() == 0) { + return true; + } + + for (int offset = 0; offset < value.length(); ) { + int codepoint = Character.codePointAt(value, offset); + + if (!isCodepointCJKV(codepoint)) { + return false; + } + + offset += Character.charCount(codepoint); + } + + return true; + } + + private static boolean isCodepointCJKV(int codepoint) { + if (codepoint == (int)' ') return true; + + Character.UnicodeBlock block = Character.UnicodeBlock.of(codepoint); + + return Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS.equals(block) || + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A.equals(block) || + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B.equals(block) || + Character.UnicodeBlock.CJK_COMPATIBILITY.equals(block) || + Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS.equals(block) || + Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS.equals(block) || + Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT.equals(block) || + Character.UnicodeBlock.CJK_RADICALS_SUPPLEMENT.equals(block) || + Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION.equals(block) || + Character.UnicodeBlock.ENCLOSED_CJK_LETTERS_AND_MONTHS.equals(block) || + Character.UnicodeBlock.KANGXI_RADICALS.equals(block) || + Character.UnicodeBlock.IDEOGRAPHIC_DESCRIPTION_CHARACTERS.equals(block) || + Character.UnicodeBlock.HIRAGANA.equals(block) || + Character.UnicodeBlock.KATAKANA.equals(block) || + Character.UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS.equals(block) || + Character.UnicodeBlock.HANGUL_JAMO.equals(block) || + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO.equals(block) || + Character.UnicodeBlock.HANGUL_SYLLABLES.equals(block) || + Character.isIdeographic(codepoint); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java new file mode 100644 index 00000000..1bd4e812 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/AssertedSuccessListener.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener; + +import java.util.concurrent.ExecutionException; + +public abstract class AssertedSuccessListener implements Listener { + @Override + public void onFailure(ExecutionException e) { + throw new AssertionError(e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/FilteredExecutor.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/FilteredExecutor.java new file mode 100644 index 00000000..146d066f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/FilteredExecutor.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executor; + +/** + * Allows you to specify a filter upon which a job will be executed on the provided executor. If + * it doesn't match the filter, it will be run on the calling thread. + */ +public final class FilteredExecutor implements Executor { + + private final Executor backgroundExecutor; + private final Filter filter; + + public FilteredExecutor(@NonNull Executor backgroundExecutor, @NonNull Filter filter) { + this.backgroundExecutor = backgroundExecutor; + this.filter = filter; + } + + @Override + public void execute(@NonNull Runnable runnable) { + if (filter.shouldRunOnExecutor()) { + backgroundExecutor.execute(runnable); + } else { + runnable.run(); + } + } + + public interface Filter { + boolean shouldRunOnExecutor(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java new file mode 100644 index 00000000..b943a26f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public interface ListenableFuture extends Future { + void addListener(Listener listener); + + public interface Listener { + public void onSuccess(T result); + public void onFailure(ExecutionException e); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialExecutor.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialExecutor.java new file mode 100644 index 00000000..5f62a1c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialExecutor.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import androidx.annotation.NonNull; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.Executor; + +/** + * From https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Executor.html + */ +public final class SerialExecutor implements Executor { + private final Queue tasks = new ArrayDeque<>(); + private final Executor executor; + private Runnable active; + + public SerialExecutor(@NonNull Executor executor) { + this.executor = executor; + } + + public synchronized void execute(final Runnable r) { + tasks.offer(() -> { + try { + r.run(); + } finally { + scheduleNext(); + } + }); + if (active == null) { + scheduleNext(); + } + } + + private synchronized void scheduleNext() { + if ((active = tasks.poll()) != null) { + executor.execute(active); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialMonoLifoExecutor.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialMonoLifoExecutor.java new file mode 100644 index 00000000..66d0f171 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SerialMonoLifoExecutor.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executor; + +/** + * Wraps another executor to make a new executor that only keeps around two tasks: + * - The actively running task + * - A single enqueued task + * + * If multiple tasks are enqueued while one is running, only the latest task is kept. The rest are + * dropped. + * + * This is useful when you want to enqueue a bunch of tasks at unknown intervals, but only the most + * recent one is relevant. For instance, running a query in response to changing user input. + * + * Based on SerialExecutor + * https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Executor.html + */ +public final class SerialMonoLifoExecutor implements Executor { + private final Executor executor; + + private Runnable next; + private Runnable active; + + public SerialMonoLifoExecutor(@NonNull Executor executor) { + this.executor = executor; + } + + @Override + public synchronized void execute(@NonNull Runnable command) { + enqueue(command); + } + + /** + * @return True if a pending task was replaced by this one, otherwise false. + */ + public synchronized boolean enqueue(@NonNull Runnable command) { + boolean performedReplace = next != null; + + next = () -> { + try { + command.run(); + } finally { + scheduleNext(); + } + }; + + if (active == null) { + scheduleNext(); + } + + return performedReplace; + } + + private synchronized void scheduleNext() { + active = next; + next = null; + if (active != null) { + executor.execute(active); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java new file mode 100644 index 00000000..81aaadbe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class SettableFuture implements ListenableFuture { + + private final List> listeners = new LinkedList<>(); + + private boolean completed; + private boolean canceled; + private volatile T result; + private volatile Throwable exception; + + public SettableFuture() { } + + public SettableFuture(T value) { + this.result = value; + this.completed = true; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (!completed && !canceled) { + canceled = true; + return true; + } + + return false; + } + + @Override + public synchronized boolean isCancelled() { + return canceled; + } + + @Override + public synchronized boolean isDone() { + return completed; + } + + public boolean set(T result) { + synchronized (this) { + if (completed || canceled) return false; + + this.result = result; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public boolean setException(Throwable throwable) { + synchronized (this) { + if (completed || canceled) return false; + + this.exception = throwable; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public void deferTo(ListenableFuture other) { + other.addListener(new Listener() { + @Override + public void onSuccess(T result) { + SettableFuture.this.set(result); + } + + @Override + public void onFailure(ExecutionException e) { + SettableFuture.this.setException(e.getCause()); + } + }); + } + + @Override + public synchronized T get() throws InterruptedException, ExecutionException { + while (!completed) wait(); + + if (exception != null) throw new ExecutionException(exception); + else return result; + } + + @Override + public synchronized T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException + { + long startTime = System.currentTimeMillis(); + + while (!completed && System.currentTimeMillis() - startTime > unit.toMillis(timeout)) { + wait(unit.toMillis(timeout)); + } + + if (!completed) throw new TimeoutException(); + else return get(); + } + + @Override + public void addListener(Listener listener) { + synchronized (this) { + listeners.add(listener); + + if (!completed) return; + } + + notifyListener(listener); + } + + private void notifyAllListeners() { + List> localListeners; + + synchronized (this) { + localListeners = new LinkedList<>(listeners); + } + + for (Listener listener : localListeners) { + notifyListener(listener); + } + } + + private void notifyListener(Listener listener) { + if (exception != null) listener.onFailure(new ExecutionException(exception)); + else listener.onSuccess(result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java new file mode 100644 index 00000000..43d12ecd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SimpleTask.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.util.concurrent; + +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Lifecycle; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.Executor; + +public class SimpleTask { + + /** + * Runs a task in the background and passes the result of the computation to a task that is run + * on the main thread. Will only invoke the {@code foregroundTask} if the provided {@link Lifecycle} + * is in a valid (i.e. visible) state at that time. In this way, it is very similar to + * {@link AsyncTask}, but is safe in that you can guarantee your task won't be called when your + * view is in an invalid state. + */ + public static void run(@NonNull Lifecycle lifecycle, @NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) { + if (!isValid(lifecycle)) { + return; + } + + SignalExecutors.BOUNDED.execute(() -> { + final E result = backgroundTask.run(); + + if (isValid(lifecycle)) { + Util.runOnMain(() -> { + if (isValid(lifecycle)) { + foregroundTask.run(result); + } + }); + } + }); + } + + /** + * Runs a task in the background and passes the result of the computation to a task that is run on + * the main thread. Essentially {@link AsyncTask}, but lambda-compatible. + */ + public static void run(@NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) { + run(SignalExecutors.BOUNDED, backgroundTask, foregroundTask); + } + + /** + * Runs a task on the specified {@link Executor} and passes the result of the computation to a + * task that is run on the main thread. Essentially {@link AsyncTask}, but lambda-compatible. + */ + public static void run(@NonNull Executor executor, @NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) { + executor.execute(() -> { + final E result = backgroundTask.run(); + Util.runOnMain(() -> foregroundTask.run(result)); + }); + } + + private static boolean isValid(@NonNull Lifecycle lifecycle) { + return lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED); + } + + public interface BackgroundTask { + E run(); + } + + public interface ForegroundTask { + void run(E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionInfoCompat.java b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionInfoCompat.java new file mode 100644 index 00000000..4a283766 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionInfoCompat.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util.dualsim; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SubscriptionInfoCompat { + + private final int subscriptionId; + private final int mcc; + private final int mnc; + private final @Nullable CharSequence displayName; + + public SubscriptionInfoCompat(int subscriptionId, @Nullable CharSequence displayName, int mcc, int mnc) { + this.subscriptionId = subscriptionId; + this.displayName = displayName; + this.mcc = mcc; + this.mnc = mnc; + } + + public @NonNull CharSequence getDisplayName() { + return displayName != null ? displayName : ""; + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public int getMnc() { + return mnc; + } + + public int getMcc() { + return mcc; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionManagerCompat.java new file mode 100644 index 00000000..9df9e1bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/SubscriptionManagerCompat.java @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.util.dualsim; + +import android.content.Context; +import android.os.Build; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.whispersystems.libsignal.util.guava.Function; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class SubscriptionManagerCompat { + + private static final String TAG = Log.tag(SubscriptionManagerCompat.class); + + private final Context context; + + public SubscriptionManagerCompat(Context context) { + this.context = context.getApplicationContext(); + } + + public Optional getPreferredSubscriptionId() { + if (Build.VERSION.SDK_INT < 24) { + return Optional.absent(); + } + + return Optional.of(SubscriptionManager.getDefaultSmsSubscriptionId()); + } + + public Optional getActiveSubscriptionInfo(int subscriptionId) { + if (Build.VERSION.SDK_INT < 22) { + return Optional.absent(); + } + + return Optional.fromNullable(getActiveSubscriptionInfoMap(false).get(subscriptionId)); + } + + public @NonNull Collection getActiveAndReadySubscriptionInfos() { + if (Build.VERSION.SDK_INT < 22) { + return Collections.emptyList(); + } + + return getActiveSubscriptionInfoMap(true).values(); + } + + @RequiresApi(api = 22) + private @NonNull Map getActiveSubscriptionInfoMap(boolean excludeUnreadySubscriptions) { + List subscriptionInfos = getActiveSubscriptionInfoList(); + + if (subscriptionInfos.isEmpty()) { + return Collections.emptyMap(); + } + + Map descriptions = getDescriptionsFor(subscriptionInfos); + Map map = new LinkedHashMap<>(); + + for (SubscriptionInfo subscriptionInfo : subscriptionInfos) { + if (!excludeUnreadySubscriptions || isReady(subscriptionInfo)) { + map.put(subscriptionInfo.getSubscriptionId(), + new SubscriptionInfoCompat(subscriptionInfo.getSubscriptionId(), + descriptions.get(subscriptionInfo), + subscriptionInfo.getMcc(), + subscriptionInfo.getMnc())); + } + } + + return map; + } + + public boolean isMultiSim() { + if (Build.VERSION.SDK_INT < 22) { + return false; + } + + return getActiveSubscriptionInfoList().size() >= 2; + } + + @RequiresApi(api = 22) + private @NonNull List getActiveSubscriptionInfoList() { + SubscriptionManager subscriptionManager = ServiceUtil.getSubscriptionManager(context); + + if (subscriptionManager == null) { + Log.w(TAG, "Missing SubscriptionManager."); + return Collections.emptyList(); + } + + List list = subscriptionManager.getActiveSubscriptionInfoList(); + + return list != null? list : Collections.emptyList(); + } + + @RequiresApi(api = 22) + private Map getDescriptionsFor(@NonNull Collection subscriptions) { + Map descriptions; + + descriptions = createDescriptionMap(subscriptions, SubscriptionInfo::getDisplayName); + if (hasNoDuplicates(descriptions.values())) return descriptions; + + return createDescriptionMap(subscriptions, this::describeSimIndex); + } + + @RequiresApi(api = 22) + private String describeSimIndex(SubscriptionInfo info) { + return context.getString(R.string.conversation_activity__sim_n, info.getSimSlotIndex() + 1); + } + + private static Map createDescriptionMap(@NonNull Collection subscriptions, + @NonNull Function createDescription) + { + Map descriptions = new HashMap<>(); + for (SubscriptionInfo subscriptionInfo: subscriptions) { + descriptions.put(subscriptionInfo, createDescription.apply(subscriptionInfo)); + } + return descriptions; + } + + private static boolean hasNoDuplicates(Collection collection) { + final Set set = new HashSet<>(); + + for (T t : collection) { + if (!set.add(t)) { + return false; + } + } + return true; + } + + private boolean isReady(@NonNull SubscriptionInfo subscriptionInfo) { + if (Build.VERSION.SDK_INT < 24) return true; + + TelephonyManager telephonyManager = ServiceUtil.getTelephonyManager(context); + + TelephonyManager specificTelephonyManager = telephonyManager.createForSubscriptionId(subscriptionInfo.getSubscriptionId()); + + return specificTelephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java new file mode 100644 index 00000000..72746f89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.content.Context; +import android.content.res.Configuration; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Locale; + +/** + * Updates a context with an alternative language. + */ +public final class DynamicLanguageContextWrapper { + private DynamicLanguageContextWrapper() {} + + public static void prepareOverrideConfiguration(@NonNull Context context, @NonNull Configuration base) { + Locale newLocale = getUsersSelectedLocale(context); + + Locale.setDefault(newLocale); + base.setLocale(newLocale); + } + + public static @NonNull Locale getUsersSelectedLocale(@NonNull Context context) { + String language = TextSecurePreferences.getLanguage(context); + return LocaleParser.findBestMatchingLocaleForLanguage(language); + } + + public static void updateContext(@NonNull Context base) { + Configuration config = base.getResources().getConfiguration(); + + prepareOverrideConfiguration(base, config); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java new file mode 100644 index 00000000..0c74c2ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +public final class LanguageString { + + private LanguageString() { + } + + /** + * @param languageString String in format language_REGION, e.g. en_US + * @return Locale, or null if cannot parse + */ + @Nullable + public static Locale parseLocale(@Nullable String languageString) { + if (languageString == null || languageString.isEmpty()) { + return null; + } + + final Locale locale = createLocale(languageString); + + if (!isValid(locale)) { + return null; + } else { + return locale; + } + } + + private static Locale createLocale(@NonNull String languageString) { + final String language[] = languageString.split("_"); + if (language.length == 2) { + return new Locale(language[0], language[1]); + } else { + return new Locale(language[0]); + } + } + + private static boolean isValid(@NonNull Locale locale) { + try { + return locale.getISO3Language() != null && locale.getISO3Country() != null; + } catch (Exception ex) { + return false; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java new file mode 100644 index 00000000..caad1035 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.content.res.Configuration; +import android.content.res.Resources; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.os.ConfigurationCompat; + +import org.thoughtcrime.securesms.BuildConfig; + +import java.util.Arrays; +import java.util.Locale; + +final class LocaleParser { + + private LocaleParser() { + } + + /** + * Given a language, gets the best choice from the apps list of supported languages and the + * Systems set of languages. + */ + static @NonNull Locale findBestMatchingLocaleForLanguage(@Nullable String language) { + final Locale locale = LanguageString.parseLocale(language); + if (appSupportsTheExactLocale(locale)) { + return locale; + } else { + return findBestSystemLocale(); + } + } + + private static boolean appSupportsTheExactLocale(@Nullable Locale locale) { + if (locale == null) { + return false; + } + return Arrays.asList(BuildConfig.LANGUAGES).contains(locale.toString()); + } + + /** + * Get the first preferred language the app supports. + */ + private static @NonNull Locale findBestSystemLocale() { + final Configuration config = Resources.getSystem().getConfiguration(); + + final Locale firstMatch = ConfigurationCompat.getLocales(config) + .getFirstMatch(BuildConfig.LANGUAGES); + + if (firstMatch != null) { + return firstMatch; + } + + return Locale.ENGLISH; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataPair.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataPair.java new file mode 100644 index 00000000..2d28291d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataPair.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; + +import org.whispersystems.libsignal.util.Pair; + +public final class LiveDataPair extends MediatorLiveData> { + private A a; + private B b; + + public LiveDataPair(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB) + { + this(liveDataA, liveDataB, null, null); + } + + public LiveDataPair(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB, + @Nullable A initialA, + @Nullable B initialB) + { + a = initialA; + b = initialB; + setValue(new Pair<>(a, b)); + + if (liveDataA == liveDataB) { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + } + setValue(new Pair<>(a, b)); + }); + + } else { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + } + setValue(new Pair<>(a, b)); + }); + + addSource(liveDataB, (b) -> { + if (b != null) { + this.b = b; + } + setValue(new Pair<>(a, b)); + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java new file mode 100644 index 00000000..31b0676d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; + +import org.thoughtcrime.securesms.util.Triple; + +public final class LiveDataTriple extends MediatorLiveData> { + private A a; + private B b; + private C c; + + public LiveDataTriple(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB, + @NonNull LiveData liveDataC) + { + this(liveDataA, liveDataB, liveDataC, null, null, null); + } + + public LiveDataTriple(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB, + @NonNull LiveData liveDataC, + @Nullable A initialA, + @Nullable B initialB, + @Nullable C initialC) + { + a = initialA; + b = initialB; + c = initialC; + setValue(new Triple<>(a, b, c)); + + if (liveDataA == liveDataB && liveDataA == liveDataC) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataA == liveDataB) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataC, c -> { + if (c != null) { + this.c = c; + } + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataA == liveDataC) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + } + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataB == liveDataC) { + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) b; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + } + setValue(new Triple<>(a, b, c)); + }); + + } else { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + } + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + } + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataC, c -> { + if (c != null) { + this.c = c; + } + setValue(new Triple<>(a, b, c)); + }); + + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java new file mode 100644 index 00000000..d19453ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -0,0 +1,263 @@ +package org.thoughtcrime.securesms.util.livedata; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; + +import com.annimon.stream.function.Predicate; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; +import org.whispersystems.libsignal.util.guava.Function; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; + +public final class LiveDataUtil { + + private LiveDataUtil() { + } + + public static @NonNull LiveData filterNotNull(@NonNull LiveData source) { + //noinspection Convert2MethodRef + return filter(source, a -> a != null); + } + + /** + * Filters output of a given live data based off a predicate. + */ + public static @NonNull LiveData filter(@NonNull LiveData source, @NonNull Predicate predicate) { + MediatorLiveData mediator = new MediatorLiveData<>(); + + mediator.addSource(source, newValue -> { + if (predicate.test(newValue)) { + mediator.setValue(newValue); + } + }); + + return mediator; + } + + /** + * Runs the {@param backgroundFunction} on {@link SignalExecutors#BOUNDED}. + *

+ * The background function order is run serially, albeit possibly across multiple threads. + *

+ * The background function may not run for all {@param source} updates. Later updates taking priority. + */ + public static LiveData mapAsync(@NonNull LiveData source, @NonNull Function backgroundFunction) { + return mapAsync(SignalExecutors.BOUNDED, source, backgroundFunction); + } + + /** + * Runs the {@param backgroundFunction} on the supplied {@param executor}. + *

+ * Regardless of the executor supplied, the background function is run serially. + *

+ * The background function may not run for all {@param source} updates. Later updates taking priority. + */ + public static LiveData mapAsync(@NonNull Executor executor, @NonNull LiveData source, @NonNull Function backgroundFunction) { + MediatorLiveData outputLiveData = new MediatorLiveData<>(); + Executor liveDataExecutor = new SerialMonoLifoExecutor(executor); + + outputLiveData.addSource(source, currentValue -> { + liveDataExecutor.execute(() -> { + outputLiveData.postValue(backgroundFunction.apply(currentValue)); + }); + }); + + return outputLiveData; + } + + /** + * Once there is non-null data on both input {@link LiveData}, the {@link Combine} function is run + * and produces a live data of the combined data. + *

+ * As each live data changes, the combine function is re-run, and a new value is emitted always + * with the latest, non-null values. + */ + public static LiveData combineLatest(@NonNull LiveData a, + @NonNull LiveData b, + @NonNull Combine combine) { + return new CombineLiveData<>(a, b, combine); + } + + /** + * Merges the supplied live data streams. + */ + public static LiveData merge(@NonNull List> liveDataList) { + Set> set = new LinkedHashSet<>(liveDataList.size()); + + set.addAll(liveDataList); + + if (set.size() == 1) { + return liveDataList.get(0); + } + + MediatorLiveData mergedLiveData = new MediatorLiveData<>(); + + for (LiveData liveDataSource : set) { + mergedLiveData.addSource(liveDataSource, mergedLiveData::setValue); + } + + return mergedLiveData; + } + + /** + * @return Live data with just the initial value. + */ + public static LiveData just(@NonNull T item) { + return new MutableLiveData<>(item); + } + + /** + * Emits {@param whileWaiting} until {@param main} starts emitting. + */ + public static @NonNull LiveData until(@NonNull LiveData main, + @NonNull LiveData whileWaiting) + { + MediatorLiveData mediatorLiveData = new MediatorLiveData<>(); + + mediatorLiveData.addSource(whileWaiting, mediatorLiveData::setValue); + + mediatorLiveData.addSource(main, value -> { + mediatorLiveData.removeSource(whileWaiting); + mediatorLiveData.setValue(value); + }); + + return mediatorLiveData; + } + + /** + * Skip the first {@param skip} emissions before emitting everything else. + */ + public static @NonNull LiveData skip(@NonNull LiveData source, int skip) { + return new MediatorLiveData() { + int skipsRemaining = skip; + + { + addSource(source, value -> { + if (skipsRemaining <= 0) { + setValue(value); + } else { + skipsRemaining--; + } + }); + } + }; + } + + /** + * After {@param delay} ms after observation, emits a single Object, {@param value}. + */ + public static LiveData delay(long delay, T value) { + return new MutableLiveData() { + boolean emittedValue; + + @Override + protected void onActive() { + if (emittedValue) return; + new Handler(Looper.getMainLooper()).postDelayed(() -> setValue(value), delay); + emittedValue = true; + } + }; + } + + public static LiveData distinctUntilChanged(@NonNull LiveData source, @NonNull EqualityChecker checker) { + final MediatorLiveData outputLiveData = new MediatorLiveData<>(); + outputLiveData.addSource(source, new Observer() { + + boolean firstChange = true; + + @Override + public void onChanged(T nextValue) { + T currentValue = outputLiveData.getValue(); + + if (currentValue == null && nextValue == null) { + return; + } + + if (firstChange || + currentValue == null || + nextValue == null || + !checker.contentsMatch(currentValue, nextValue)) + { + firstChange = false; + outputLiveData.setValue(nextValue); + } + } + }); + + return outputLiveData; + } + + /** + * Observes a source until the predicate is met. The final value matching the predicate is emitted. + */ + public static @NonNull LiveData until(@NonNull LiveData source, @NonNull Predicate predicate) { + MediatorLiveData mediator = new MediatorLiveData<>(); + + mediator.addSource(source, newValue -> { + mediator.setValue(newValue); + if (predicate.test(newValue)) { + mediator.removeSource(source); + } + }); + + return mediator; + } + + public interface Combine { + @NonNull R apply(@NonNull A a, @NonNull B b); + } + + public interface EqualityChecker { + boolean contentsMatch(@NonNull T current, @NonNull T next); + } + + private static final class CombineLiveData extends MediatorLiveData { + private A a; + private B b; + + CombineLiveData(LiveData liveDataA, LiveData liveDataB, Combine combine) { + if (liveDataA == liveDataB) { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + setValue(combine.apply(a, b)); + } + }); + + } else { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + if (b != null) { + setValue(combine.apply(a, b)); + } + } + }); + + addSource(liveDataB, (b) -> { + if (b != null) { + this.b = b; + if (a != null) { + setValue(combine.apply(a, b)); + } + } + }); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java b/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java new file mode 100644 index 00000000..995f4165 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.util.paging; + +import androidx.annotation.NonNull; + +public class Invalidator { + private Runnable callback; + + public synchronized void invalidate() { + if (callback != null) { + callback.run(); + } + } + + public synchronized void observe(@NonNull Runnable callback) { + this.callback = callback; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java b/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java new file mode 100644 index 00000000..334a5437 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.util.paging; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; + +import java.util.List; + +public class SizeFixResult { + + private static final String TAG = Log.tag(SizeFixResult.class); + + final List items; + final int total; + + private SizeFixResult(@NonNull List items, int total) { + this.items = items; + this.total = total; + } + + public List getItems() { + return items; + } + + public int getTotal() { + return total; + } + + public static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List records, + int startPosition, + int pageSize, + int total) + { + if (records.size() + startPosition == total || (records.size() != 0 && records.size() % pageSize == 0)) { + return new SizeFixResult<>(records, total); + } + + if (records.size() < pageSize) { + Log.w(TAG, "Hit a miscalculation where we don't have the full dataset, but it's smaller than a page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); + return new SizeFixResult<>(records, records.size() + startPosition); + } + + Log.w(TAG, "Hit a miscalculation where our data size isn't a multiple of the page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); + int overflow = records.size() % pageSize; + + return new SizeFixResult<>(records.subList(0, records.size() - overflow), total); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java b/app/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java new file mode 100644 index 00000000..880de5e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/spans/CenterAlignedRelativeSizeSpan.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util.spans; + + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class CenterAlignedRelativeSizeSpan extends MetricAffectingSpan { + + private final float relativeSize; + + public CenterAlignedRelativeSizeSpan(float relativeSize) { + this.relativeSize = relativeSize; + } + + @Override + public void updateMeasureState(@NonNull TextPaint p) { + updateDrawState(p); + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.setTextSize(tp.getTextSize() * relativeSize); + tp.baselineShift += (int) (tp.ascent() * relativeSize) / 4; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java new file mode 100644 index 00000000..e862d5d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.util.task; + +import android.app.ProgressDialog; +import android.content.Context; +import android.os.AsyncTask; + +import java.lang.ref.WeakReference; + +public abstract class ProgressDialogAsyncTask extends AsyncTask { + + private final WeakReference contextReference; + private ProgressDialog progress; + private final String title; + private final String message; + + public ProgressDialogAsyncTask(Context context, String title, String message) { + super(); + this.contextReference = new WeakReference<>(context); + this.title = title; + this.message = message; + } + + public ProgressDialogAsyncTask(Context context, int title, int message) { + this(context, context.getString(title), context.getString(message)); + } + + @Override + protected void onPreExecute() { + final Context context = contextReference.get(); + if (context != null) progress = ProgressDialog.show(context, title, message, true); + } + + @Override + protected void onPostExecute(Result result) { + if (progress != null) progress.dismiss(); + } + + protected Context getContext() { + return contextReference.get(); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java new file mode 100644 index 00000000..f6bcfcb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.util.task; + +import android.app.ProgressDialog; +import android.graphics.Color; +import android.os.AsyncTask; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; + +import com.google.android.material.snackbar.Snackbar; + +import org.signal.core.util.logging.Log; + +public abstract class SnackbarAsyncTask + extends AsyncTask + implements View.OnClickListener +{ + private static final String TAG = Log.tag(SnackbarAsyncTask.class); + + private final Lifecycle lifecycle; + private final View view; + private final String snackbarText; + private final String snackbarActionText; + private final int snackbarActionColor; + private final int snackbarDuration; + private final boolean showProgress; + + private @Nullable Params reversibleParameter; + private @Nullable ProgressDialog progressDialog; + + public SnackbarAsyncTask(@NonNull Lifecycle lifecycle, + @NonNull View view, + String snackbarText, + String snackbarActionText, + int snackbarActionColor, + int snackbarDuration, + boolean showProgress) + { + this.lifecycle = lifecycle; + this.view = view; + this.snackbarText = snackbarText; + this.snackbarActionText = snackbarActionText; + this.snackbarActionColor = snackbarActionColor; + this.snackbarDuration = snackbarDuration; + this.showProgress = showProgress; + } + + @Override + protected void onPreExecute() { + if (this.showProgress) this.progressDialog = ProgressDialog.show(view.getContext(), "", "", true); + else this.progressDialog = null; + } + + @SafeVarargs + @Override + protected final Void doInBackground(Params... params) { + this.reversibleParameter = params != null && params.length > 0 ?params[0] : null; + executeAction(reversibleParameter); + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (this.showProgress && this.progressDialog != null) { + this.progressDialog.dismiss(); + this.progressDialog = null; + } + + if (!lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED)) { + Log.w(TAG, "Not in at least created state. Refusing to show snack bar."); + return; + } + + Snackbar.make(view, snackbarText, snackbarDuration) + .setAction(snackbarActionText, this) + .setActionTextColor(snackbarActionColor) + .setTextColor(Color.WHITE) + .show(); + } + + @Override + public void onClick(View v) { + new AsyncTask() { + @Override + protected void onPreExecute() { + if (showProgress) progressDialog = ProgressDialog.show(view.getContext(), "", "", true); + else progressDialog = null; + } + + @Override + protected Void doInBackground(Void... params) { + reverseAction(reversibleParameter); + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (showProgress && progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + protected abstract void executeAction(@Nullable Params parameter); + protected abstract void reverseAction(@Nullable Params parameter); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/text/AfterTextChanged.java b/app/src/main/java/org/thoughtcrime/securesms/util/text/AfterTextChanged.java new file mode 100644 index 00000000..39f361e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/text/AfterTextChanged.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.util.text; + +import android.text.Editable; +import android.text.TextWatcher; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +public final class AfterTextChanged implements TextWatcher { + + private final Consumer afterTextChangedConsumer; + + public AfterTextChanged(@NonNull Consumer afterTextChangedConsumer) { + this.afterTextChangedConsumer = afterTextChangedConsumer; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + afterTextChangedConsumer.accept(s); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java new file mode 100644 index 00000000..e07ee76c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.util.viewholders; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingModel; + +import java.util.Objects; + +public abstract class RecipientMappingModel> implements MappingModel { + + public abstract @NonNull Recipient getRecipient(); + + public @NonNull String getName(@NonNull Context context) { + return getRecipient().getDisplayName(context); + } + + @Override + public boolean areItemsTheSame(@NonNull T newItem) { + return getRecipient().getId().equals(newItem.getRecipient().getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull T newItem) { + Context context = ApplicationDependencies.getApplication(); + return getName(context).equals(newItem.getName(context)) && Objects.equals(getRecipient().getContactPhoto(), newItem.getRecipient().getContactPhoto()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java new file mode 100644 index 00000000..de667bad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util.viewholders; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class RecipientViewHolder> extends MappingViewHolder { + + protected final @Nullable AvatarImageView avatar; + protected final @Nullable TextView name; + protected final @Nullable EventListener eventListener; + + public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener eventListener) { + super(itemView); + this.eventListener = eventListener; + + avatar = findViewById(R.id.recipient_view_avatar); + name = findViewById(R.id.recipient_view_name); + } + + @Override + public void bind(@NonNull T model) { + if (avatar != null) { + avatar.setRecipient(model.getRecipient()); + } + + if (name != null) { + name.setText(model.getName(context)); + } + + if (eventListener != null) { + itemView.setOnClickListener(v -> eventListener.onModelClick(model)); + } else { + itemView.setOnClickListener(null); + } + } + + public static @NonNull > MappingAdapter.Factory createFactory(@LayoutRes int layout, @Nullable EventListener listener) { + return new MappingAdapter.LayoutFactory<>(view -> new RecipientViewHolder<>(view, listener), layout); + } + + public interface EventListener> { + default void onModelClick(@NonNull T model) { + onClick(model.getRecipient()); + } + + void onClick(@NonNull Recipient recipient); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java new file mode 100644 index 00000000..ad0cd617 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; + +/** + * AdaptiveActionsToolbar behaves like a normal {@link Toolbar} except in that it ignores the + * showAsAlways attributes of menu items added via menu inflation, opting for an adaptive algorithm + * instead. This algorithm will display as many icons as it can up to a specific percentage of the + * screen. + * + * Each ActionView icon is expected to occupy 48dp of space, including padding. Items are stacked one + * after the next with no margins. + * + * This view can be customized via attributes: + * + * aat_max_shown -- controls the max number of items to display. + * aat_percent_for_actions -- controls the max percent of screen width the buttons can occupy. + */ +public class AdaptiveActionsToolbar extends Toolbar { + + private static final int NAVIGATION_DP = 56; + private static final int ACTION_VIEW_WIDTH_DP = 48; + private static final int OVERFLOW_VIEW_WIDTH_DP = 36; + + private int maxShown; + + public AdaptiveActionsToolbar(@NonNull Context context) { + this(context, null); + } + + public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + } + + public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.AdaptiveActionsToolbar); + + maxShown = array.getInteger(R.styleable.AdaptiveActionsToolbar_aat_max_shown, 100); + + array.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + adjustMenuActions(getMenu(), maxShown, getMeasuredWidth()); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + public static void adjustMenuActions(@NonNull Menu menu, int maxToShow, int toolbarWidthPx) { + int menuSize = 0; + + for (int i = 0; i < menu.size(); i++) { + if (menu.getItem(i).isVisible()) { + menuSize++; + } + } + + int widthAllowed = toolbarWidthPx - ViewUtil.dpToPx(NAVIGATION_DP); + int nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); + + if (nItemsToShow < menuSize) { + widthAllowed -= ViewUtil.dpToPx(OVERFLOW_VIEW_WIDTH_DP); + } + + nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); + + for (int i = 0; i < menu.size(); i++) { + MenuItem item = menu.getItem(i); + if (item.isVisible() && nItemsToShow > 0) { + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + nItemsToShow--; + } else { + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/DarkOverflowToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/DarkOverflowToolbar.java new file mode 100644 index 00000000..7282dc5e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/DarkOverflowToolbar.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; + +/** + * It seems to be impossible to tint the overflow icon in an ActionMode independently from the + * default toolbar overflow icon. So we default the overflow icon to white, then we can use this + * subclass to make it the correct themed color for most use cases. + */ +public class DarkOverflowToolbar extends Toolbar { + public DarkOverflowToolbar(Context context) { + super(context); + init(); + } + + public DarkOverflowToolbar(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DarkOverflowToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + if (getOverflowIcon() != null) { + getOverflowIcon().setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_icon_tint_primary), PorterDuff.Mode.SRC_ATOP); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java new file mode 100644 index 00000000..17d67b18 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatTextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public class LearnMoreTextView extends AppCompatTextView { + + private OnClickListener linkListener; + private Spannable link; + private boolean visible; + private CharSequence baseText; + + public LearnMoreTextView(Context context) { + super(context); + init(); + } + + public LearnMoreTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + setMovementMethod(LinkMovementMethod.getInstance()); + setLinkTextInternal(R.string.LearnMoreTextView_learn_more); + visible = true; + } + + @Override + public void setText(CharSequence text, BufferType type) { + baseText = text; + setTextInternal(baseText, type); + } + + @Override + public void setTextColor(int color) { + super.setTextColor(color); + } + + public void setOnLinkClickListener(@Nullable OnClickListener listener) { + this.linkListener = listener; + } + + public void setLearnMoreVisible(boolean visible) { + this.visible = visible; + setTextInternal(baseText, visible ? BufferType.SPANNABLE : BufferType.NORMAL); + } + + public void setLearnMoreVisible(boolean visible, @StringRes int linkText) { + setLinkTextInternal(linkText); + this.visible = visible; + setTextInternal(baseText, visible ? BufferType.SPANNABLE : BufferType.NORMAL); + } + + private void setLinkTextInternal(@StringRes int linkText) { + ClickableSpan clickable = new ClickableSpan() { + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.setUnderlineText(false); + ds.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.colorAccent)); + } + + @Override + public void onClick(@NonNull View widget) { + if (linkListener != null) { + linkListener.onClick(widget); + } + } + }; + + link = new SpannableString(getContext().getString(linkText)); + link.setSpan(clickable, 0, link.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + private void setTextInternal(CharSequence text, BufferType type) { + if (visible) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append(text).append(' ').append(link); + + super.setText(builder, BufferType.SPANNABLE); + } else { + super.setText(text, type); + } + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java new file mode 100644 index 00000000..5a99bc3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.Util; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Helper class to show a fullscreen blocking indeterminate progress dialog. + */ +public final class SimpleProgressDialog { + + private static final String TAG = Log.tag(SimpleProgressDialog.class); + + private SimpleProgressDialog() {} + + @MainThread + public static @NonNull AlertDialog show(@NonNull Context context) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setView(R.layout.progress_dialog) + .setCancelable(false) + .create(); + dialog.show(); + dialog.getWindow().setLayout(context.getResources().getDimensionPixelSize(R.dimen.progress_dialog_size), + context.getResources().getDimensionPixelSize(R.dimen.progress_dialog_size)); + + return dialog; + } + + @AnyThread + public static @NonNull DismissibleDialog showDelayed(@NonNull Context context) { + return showDelayed(context, 300, 1000); + } + + /** + * Shows the dialog after {@param delayMs} ms. + *

+ * To dismiss, call {@link DismissibleDialog#dismiss()} on the result. If dismiss is called before + * the delay has elapsed, the dialog will not show at all. + *

+ * Dismiss can be called on any thread. + * + * @param minimumShowTimeMs If the dialog does display, then it will be visible for at least this duration. + * This is to prevent flicker. + */ + @AnyThread + public static @NonNull DismissibleDialog showDelayed(@NonNull Context context, + int delayMs, + int minimumShowTimeMs) + { + AtomicReference dialogAtomicReference = new AtomicReference<>(); + AtomicLong shownAt = new AtomicLong(); + + Runnable showRunnable = () -> { + Log.i(TAG, "Taking some time. Showing a progress dialog."); + shownAt.set(System.currentTimeMillis()); + dialogAtomicReference.set(show(context)); + }; + + Util.runOnMainDelayed(showRunnable, delayMs); + + return () -> { + Util.cancelRunnableOnMain(showRunnable); + Util.runOnMain(() -> { + AlertDialog alertDialog = dialogAtomicReference.getAndSet(null); + if (alertDialog != null) { + long beenShowingForMs = System.currentTimeMillis() - shownAt.get(); + long remainingTimeMs = minimumShowTimeMs - beenShowingForMs; + + if (remainingTimeMs > 0) { + Util.runOnMainDelayed(alertDialog::dismiss, remainingTimeMs); + } else { + alertDialog.dismiss(); + } + } + }); + }; + } + + public interface DismissibleDialog { + @AnyThread + void dismiss(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/SlideUpWithSnackbarBehavior.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/SlideUpWithSnackbarBehavior.java new file mode 100644 index 00000000..2b1011d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/SlideUpWithSnackbarBehavior.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import com.google.android.material.snackbar.Snackbar; + +public class SlideUpWithSnackbarBehavior extends CoordinatorLayout.Behavior { + + public SlideUpWithSnackbarBehavior(@NonNull Context context, @Nullable AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, + @NonNull View child, + @NonNull View dependency) + { + float translationY = Math.min(0, dependency.getTranslationY() - dependency.getHeight()); + child.setTranslationY(translationY); + + return true; + } + + @Override + public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, + @NonNull View child, + @NonNull View dependency) + { + child.setTranslationY(0); + } + + @Override + public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, + @NonNull View child, + @NonNull View dependency) + { + return dependency instanceof Snackbar.SnackbarLayout; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java new file mode 100644 index 00000000..7d964844 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/Stub.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.util.views; + + +import android.view.ViewStub; + +import androidx.annotation.NonNull; + +public class Stub { + + private ViewStub viewStub; + private T view; + + public Stub(@NonNull ViewStub viewStub) { + this.viewStub = viewStub; + } + + public T get() { + if (view == null) { + view = (T)viewStub.inflate(); + viewStub = null; + } + + return view; + } + + public boolean resolved() { + return view != null; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java new file mode 100644 index 00000000..de42ebc8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TouchInterceptingFrameLayout extends FrameLayout { + + private OnInterceptTouchEventListener listener; + + public TouchInterceptingFrameLayout(@NonNull Context context) { + super(context); + } + + public TouchInterceptingFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TouchInterceptingFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (listener != null) { + return listener.onInterceptTouchEvent(ev); + } else { + return super.onInterceptTouchEvent(ev); + } + } + + public void setOnInterceptTouchEventListener(@Nullable OnInterceptTouchEventListener listener) { + this.listener = listener; + } + + public interface OnInterceptTouchEventListener { + boolean onInterceptTouchEvent(MotionEvent ev); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/ByteArrayMediaDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/ByteArrayMediaDataSource.java new file mode 100644 index 00000000..8eed8802 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/ByteArrayMediaDataSource.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.video; + +import android.media.MediaDataSource; + +import androidx.annotation.RequiresApi; + +import java.io.IOException; + +@RequiresApi(23) +public class ByteArrayMediaDataSource extends MediaDataSource { + + private byte[] data; + + public ByteArrayMediaDataSource(byte[] data) { + this.data = data; + } + + @Override + public int readAt(long position, byte[] buffer, int offset, int size) throws IOException { + if (data == null) throw new IOException("ByteArrayMediaDataSource is closed"); + + long bytesAvailable = getSize() - position; + int read = Math.min(size, (int) bytesAvailable); + if (read <= 0) return -1; + + if (buffer != null) { + System.arraycopy(data, (int) position, buffer, offset, read); + } + + return read; + } + + @Override + public long getSize() throws IOException { + if (data == null) throw new IOException("ByteArrayMediaDataSource is closed"); + return data.length; + } + + @Override + public void close() throws IOException { + data = null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/ClassicEncryptedMediaDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/ClassicEncryptedMediaDataSource.java new file mode 100644 index 00000000..f4a01997 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/ClassicEncryptedMediaDataSource.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.video; + +import android.media.MediaDataSource; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; +import org.thoughtcrime.securesms.util.Util; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +@RequiresApi(23) +final class ClassicEncryptedMediaDataSource extends MediaDataSource { + + private final AttachmentSecret attachmentSecret; + private final File mediaFile; + private final long length; + + ClassicEncryptedMediaDataSource(@NonNull AttachmentSecret attachmentSecret, @NonNull File mediaFile, long length) { + this.attachmentSecret = attachmentSecret; + this.mediaFile = mediaFile; + this.length = length; + } + + @Override + public int readAt(long position, byte[] bytes, int offset, int length) throws IOException { + try (InputStream inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, mediaFile)) { + byte[] buffer = new byte[4096]; + long headerRemaining = position; + + while (headerRemaining > 0) { + int read = inputStream.read(buffer, 0, Util.toIntExact(Math.min((long)buffer.length, headerRemaining))); + + if (read == -1) return -1; + + headerRemaining -= read; + } + + return inputStream.read(bytes, offset, length); + } + } + + @Override + public long getSize() { + return length; + } + + @Override + public void close() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/EncryptedMediaDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/EncryptedMediaDataSource.java new file mode 100644 index 00000000..6001bcac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/EncryptedMediaDataSource.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.video; + +import android.media.MediaDataSource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.crypto.AttachmentSecret; + +import java.io.File; + +@RequiresApi(23) +public final class EncryptedMediaDataSource { + + public static MediaDataSource createFor(@NonNull AttachmentSecret attachmentSecret, @NonNull File mediaFile, @Nullable byte[] random, long length) { + if (random == null) { + return new ClassicEncryptedMediaDataSource(attachmentSecret, mediaFile, length); + } else { + return new ModernEncryptedMediaDataSource(attachmentSecret, mediaFile, random, length); + } + } + + public static MediaDataSource createForDiskBlob(@NonNull AttachmentSecret attachmentSecret, @NonNull File mediaFile) { + return new ModernEncryptedMediaDataSource(attachmentSecret, mediaFile, null, mediaFile.length() - 32); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java new file mode 100644 index 00000000..4d0931f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.video; + +import android.content.Context; +import android.media.MediaDataSource; +import android.media.MediaMetadataRetriever; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.google.android.exoplayer2.util.MimeTypes; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.mms.MediaStream; +import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.video.videoconverter.EncodingException; +import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; + +import java.io.Closeable; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.IOException; +import java.text.NumberFormat; +import java.util.Locale; + +@RequiresApi(26) +public final class InMemoryTranscoder implements Closeable { + + private static final String TAG = Log.tag(InMemoryTranscoder.class); + + private final Context context; + private final MediaDataSource dataSource; + private final long upperSizeLimit; + private final long inSize; + private final long duration; + private final int inputBitRate; + private final VideoBitRateCalculator.Quality targetQuality; + private final long memoryFileEstimate; + private final boolean transcodeRequired; + private final long fileSizeEstimate; + private final @Nullable TranscoderOptions options; + + private @Nullable MemoryFileDescriptor memoryFile; + + /** + * @param upperSizeLimit A upper size to transcode to. The actual output size can be up to 10% smaller. + */ + public InMemoryTranscoder(@NonNull Context context, @NonNull MediaDataSource dataSource, @Nullable TranscoderOptions options, long upperSizeLimit) throws IOException, VideoSourceException { + this.context = context; + this.dataSource = dataSource; + this.options = options; + + final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); + try { + mediaMetadataRetriever.setDataSource(dataSource); + } catch (RuntimeException e) { + Log.w(TAG, "Unable to read datasource", e); + throw new VideoSourceException("Unable to read datasource", e); + } + + this.inSize = dataSource.getSize(); + this.duration = getDuration(mediaMetadataRetriever); + this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration); + this.targetQuality = new VideoBitRateCalculator(upperSizeLimit).getTargetQuality(duration, inputBitRate); + this.upperSizeLimit = upperSizeLimit; + + this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null; + if (!transcodeRequired) { + Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options."); + } + + this.fileSizeEstimate = targetQuality.getFileSizeEstimate(); + this.memoryFileEstimate = (long) (fileSizeEstimate * 1.1); + } + + public @NonNull MediaStream transcode(@NonNull Progress progress, + @Nullable TranscoderCancelationSignal cancelationSignal) + throws IOException, EncodingException, VideoSizeException + { + if (memoryFile != null) throw new AssertionError("Not expecting to reuse transcoder"); + + float durationSec = duration / 1000f; + + NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); + + Log.i(TAG, String.format(Locale.US, + "Transcoding:\n" + + "Target bitrate : %s + %s = %s\n" + + "Target format : %dp\n" + + "Video duration : %.1fs\n" + + "Size limit : %s kB\n" + + "Estimate : %s kB\n" + + "Input size : %s kB\n" + + "Input bitrate : %s bps", + numberFormat.format(targetQuality.getTargetVideoBitRate()), + numberFormat.format(targetQuality.getTargetAudioBitRate()), + numberFormat.format(targetQuality.getTargetTotalBitRate()), + targetQuality.getOutputResolution(), + durationSec, + numberFormat.format(upperSizeLimit / 1024), + numberFormat.format(fileSizeEstimate / 1024), + numberFormat.format(inSize / 1024), + numberFormat.format(inputBitRate))); + + if (fileSizeEstimate > upperSizeLimit) { + throw new VideoSizeException("Size constraints could not be met!"); + } + + memoryFile = MemoryFileDescriptor.newMemoryFileDescriptor(context, + "TRANSCODE", + memoryFileEstimate); + final long startTime = System.currentTimeMillis(); + + final FileDescriptor memoryFileFileDescriptor = memoryFile.getFileDescriptor(); + + final MediaConverter converter = new MediaConverter(); + + converter.setInput(new MediaInput.MediaDataSourceMediaInput(dataSource)); + converter.setOutput(memoryFileFileDescriptor); + converter.setVideoResolution(targetQuality.getOutputResolution()); + converter.setVideoBitrate(targetQuality.getTargetVideoBitRate()); + converter.setAudioBitrate(targetQuality.getTargetAudioBitRate()); + + if (options != null) { + if (options.endTimeUs > 0) { + long timeFrom = options.startTimeUs / 1000; + long timeTo = options.endTimeUs / 1000; + converter.setTimeRange(timeFrom, timeTo); + Log.i(TAG, String.format(Locale.US, "Trimming:\nTotal duration: %d\nKeeping: %d..%d\nFinal duration:(%d)", duration, timeFrom, timeTo, timeTo - timeFrom)); + } + } + + converter.setListener(percent -> { + progress.onProgress(percent); + return cancelationSignal != null && cancelationSignal.isCanceled(); + }); + + converter.convert(); + + // output details of the transcoding + long outSize = memoryFile.size(); + float encodeDurationSec = (System.currentTimeMillis() - startTime) / 1000f; + + Log.i(TAG, String.format(Locale.US, + "Transcoding complete:\n" + + "Transcode time : %.1fs (%.1fx)\n" + + "Output size : %s kB\n" + + " of Original : %.1f%%\n" + + " of Estimate : %.1f%%\n" + + " of Memory : %.1f%%\n" + + "Output bitrate : %s bps", + encodeDurationSec, + durationSec / encodeDurationSec, + numberFormat.format(outSize / 1024), + (outSize * 100d) / inSize, + (outSize * 100d) / fileSizeEstimate, + (outSize * 100d) / memoryFileEstimate, + numberFormat.format(VideoBitRateCalculator.bitRate(outSize, duration)))); + + if (outSize > upperSizeLimit) { + throw new VideoSizeException("Size constraints could not be met!"); + } + + memoryFile.seek(0); + + return new MediaStream(new FileInputStream(memoryFileFileDescriptor), MimeTypes.VIDEO_MP4, 0, 0); + } + + public boolean isTranscodeRequired() { + return transcodeRequired; + } + + @Override + public void close() throws IOException { + if (memoryFile != null) { + memoryFile.close(); + } + } + + private static long getDuration(MediaMetadataRetriever mediaMetadataRetriever) throws VideoSourceException { + String durationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + if (durationString == null) { + throw new VideoSourceException("Cannot determine duration of video, null meta data"); + } + try { + long duration = Long.parseLong(durationString); + if (duration <= 0) { + throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString); + } + return duration; + } catch (NumberFormatException e) { + throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString, e); + } + } + + private static boolean containsLocation(MediaMetadataRetriever mediaMetadataRetriever) { + String locationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION); + return locationString != null; + } + + public interface Progress { + void onProgress(int percent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java new file mode 100644 index 00000000..867e36d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.video; + +import android.media.MediaDataSource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * Create via {@link EncryptedMediaDataSource}. + *

+ * A {@link MediaDataSource} that points to an encrypted file. + *

+ * It is "modern" compared to the {@link ClassicEncryptedMediaDataSource}. And "modern" refers to + * the presence of a random part of the key supplied in the constructor. + */ +@RequiresApi(23) +final class ModernEncryptedMediaDataSource extends MediaDataSource { + + private final AttachmentSecret attachmentSecret; + private final File mediaFile; + private final byte[] random; + private final long length; + + ModernEncryptedMediaDataSource(@NonNull AttachmentSecret attachmentSecret, @NonNull File mediaFile, @Nullable byte[] random, long length) { + this.attachmentSecret = attachmentSecret; + this.mediaFile = mediaFile; + this.random = random; + this.length = length; + } + + @Override + public int readAt(long position, byte[] bytes, int offset, int length) throws IOException { + if (position >= this.length) { + return -1; + } + + try (InputStream inputStream = createInputStream(position)) { + int totalRead = 0; + + while (length > 0) { + int read = inputStream.read(bytes, offset, length); + + if (read == -1) { + if (totalRead == 0) { + return -1; + } else { + return totalRead; + } + } + + length -= read; + offset += read; + totalRead += read; + } + + return totalRead; + } + } + + @Override + public long getSize() { + return length; + } + + @Override + public void close() { + } + + private InputStream createInputStream(long position) throws IOException { + if (random == null) { + return ModernDecryptingPartInputStream.createFor(attachmentSecret, mediaFile, position); + } else { + return ModernDecryptingPartInputStream.createFor(attachmentSecret, random, mediaFile, position); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java new file mode 100644 index 00000000..d56a41b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java @@ -0,0 +1,214 @@ +package org.thoughtcrime.securesms.video; + +import android.media.MediaDataSource; +import android.media.MediaMetadataRetriever; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.video.videoconverter.EncodingException; +import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.text.NumberFormat; +import java.util.Locale; + +@RequiresApi(26) +public final class StreamingTranscoder { + + private static final String TAG = Log.tag(StreamingTranscoder.class); + + private final MediaDataSource dataSource; + private final long upperSizeLimit; + private final long inSize; + private final long duration; + private final int inputBitRate; + private final VideoBitRateCalculator.Quality targetQuality; + private final long memoryFileEstimate; + private final boolean transcodeRequired; + private final long fileSizeEstimate; + private final @Nullable TranscoderOptions options; + + /** + * @param upperSizeLimit A upper size to transcode to. The actual output size can be up to 10% smaller. + */ + public StreamingTranscoder(@NonNull MediaDataSource dataSource, + @Nullable TranscoderOptions options, + long upperSizeLimit) + throws IOException, VideoSourceException + { + this.dataSource = dataSource; + this.options = options; + + final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); + try { + mediaMetadataRetriever.setDataSource(dataSource); + } catch (RuntimeException e) { + Log.w(TAG, "Unable to read datasource", e); + throw new VideoSourceException("Unable to read datasource", e); + } + + this.inSize = dataSource.getSize(); + this.duration = getDuration(mediaMetadataRetriever); + this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration); + this.targetQuality = new VideoBitRateCalculator(upperSizeLimit).getTargetQuality(duration, inputBitRate); + this.upperSizeLimit = upperSizeLimit; + + this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null; + if (!transcodeRequired) { + Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options."); + } + + this.fileSizeEstimate = targetQuality.getFileSizeEstimate(); + this.memoryFileEstimate = (long) (fileSizeEstimate * 1.1); + } + + public void transcode(@NonNull Progress progress, + @NonNull OutputStream stream, + @Nullable TranscoderCancelationSignal cancelationSignal) + throws IOException, EncodingException + { + float durationSec = duration / 1000f; + + NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); + + Log.i(TAG, String.format(Locale.US, + "Transcoding:\n" + + "Target bitrate : %s + %s = %s\n" + + "Target format : %dp\n" + + "Video duration : %.1fs\n" + + "Size limit : %s kB\n" + + "Estimate : %s kB\n" + + "Input size : %s kB\n" + + "Input bitrate : %s bps", + numberFormat.format(targetQuality.getTargetVideoBitRate()), + numberFormat.format(targetQuality.getTargetAudioBitRate()), + numberFormat.format(targetQuality.getTargetTotalBitRate()), + targetQuality.getOutputResolution(), + durationSec, + numberFormat.format(upperSizeLimit / 1024), + numberFormat.format(fileSizeEstimate / 1024), + numberFormat.format(inSize / 1024), + numberFormat.format(inputBitRate))); + + if (fileSizeEstimate > upperSizeLimit) { + throw new VideoSizeException("Size constraints could not be met!"); + } + + final long startTime = System.currentTimeMillis(); + + final MediaConverter converter = new MediaConverter(); + final LimitedSizeOutputStream limitedSizeOutputStream = new LimitedSizeOutputStream(stream, upperSizeLimit); + + converter.setInput(new MediaInput.MediaDataSourceMediaInput(dataSource)); + converter.setOutput(limitedSizeOutputStream); + converter.setVideoResolution(targetQuality.getOutputResolution()); + converter.setVideoBitrate(targetQuality.getTargetVideoBitRate()); + converter.setAudioBitrate(targetQuality.getTargetAudioBitRate()); + + if (options != null) { + if (options.endTimeUs > 0) { + long timeFrom = options.startTimeUs / 1000; + long timeTo = options.endTimeUs / 1000; + converter.setTimeRange(timeFrom, timeTo); + Log.i(TAG, String.format(Locale.US, "Trimming:\nTotal duration: %d\nKeeping: %d..%d\nFinal duration:(%d)", duration, timeFrom, timeTo, timeTo - timeFrom)); + } + } + + converter.setListener(percent -> { + progress.onProgress(percent); + return cancelationSignal != null && cancelationSignal.isCanceled(); + }); + + converter.convert(); + + long outSize = limitedSizeOutputStream.written; + float encodeDurationSec = (System.currentTimeMillis() - startTime) / 1000f; + + Log.i(TAG, String.format(Locale.US, + "Transcoding complete:\n" + + "Transcode time : %.1fs (%.1fx)\n" + + "Output size : %s kB\n" + + " of Original : %.1f%%\n" + + " of Estimate : %.1f%%\n" + + " of Memory : %.1f%%\n" + + "Output bitrate : %s bps", + encodeDurationSec, + durationSec / encodeDurationSec, + numberFormat.format(outSize / 1024), + (outSize * 100d) / inSize, + (outSize * 100d) / fileSizeEstimate, + (outSize * 100d) / memoryFileEstimate, + numberFormat.format(VideoBitRateCalculator.bitRate(outSize, duration)))); + + if (outSize > upperSizeLimit) { + throw new VideoSizeException("Size constraints could not be met!"); + } + + stream.flush(); + } + + public boolean isTranscodeRequired() { + return transcodeRequired; + } + + private static long getDuration(MediaMetadataRetriever mediaMetadataRetriever) throws VideoSourceException { + String durationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + if (durationString == null) { + throw new VideoSourceException("Cannot determine duration of video, null meta data"); + } + try { + long duration = Long.parseLong(durationString); + if (duration <= 0) { + throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString); + } + return duration; + } catch (NumberFormatException e) { + throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString, e); + } + } + + private static boolean containsLocation(MediaMetadataRetriever mediaMetadataRetriever) { + String locationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION); + return locationString != null; + } + + public interface Progress { + void onProgress(int percent); + } + + private static class LimitedSizeOutputStream extends FilterOutputStream { + + private final long sizeLimit; + private long written; + + LimitedSizeOutputStream(@NonNull OutputStream inner, long sizeLimit) { + super(inner); + this.sizeLimit = sizeLimit; + } + + @Override public void write(int b) throws IOException { + incWritten(1); + out.write(b); + } + + @Override public void write(byte[] b, int off, int len) throws IOException { + incWritten(len); + out.write(b, off, len); + } + + private void incWritten(int len) throws IOException { + long newWritten = written + len; + if (newWritten > sizeLimit) { + Log.w(TAG, String.format(Locale.US, "File size limit hit. Wrote %d, tried to write %d more. Limit is %d", written, len, sizeLimit)); + throw new VideoSizeException("File size limit hit"); + } + written = newWritten; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java new file mode 100644 index 00000000..107005d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.video; + +public interface TranscoderCancelationSignal { + boolean isCanceled(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java new file mode 100644 index 00000000..8378759f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.video; + +public final class TranscoderOptions { + final long startTimeUs; + final long endTimeUs; + + public TranscoderOptions(long startTimeUs, long endTimeUs) { + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java new file mode 100644 index 00000000..47fe76fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.video; + +/** + * Calculates a target quality output for a video to fit within a specified size. + */ +public final class VideoBitRateCalculator { + + private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoUtil.VIDEO_BIT_RATE; + private static final int LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000; + private static final int MINIMUM_TARGET_VIDEO_BITRATE = 500_000; + private static final int AUDIO_BITRATE = VideoUtil.AUDIO_BIT_RATE; + private static final int OUTPUT_FORMAT = VideoUtil.VIDEO_SHORT_WIDTH; + private static final int LOW_RES_OUTPUT_FORMAT = 480; + + private final long upperFileSizeLimitWithMargin; + + public VideoBitRateCalculator(long upperFileSizeLimit) { + upperFileSizeLimitWithMargin = (long) (upperFileSizeLimit / 1.1); + } + + /** + * Gets the output quality of a video of the given {@param duration}. + */ + public Quality getTargetQuality(long duration, int inputTotalBitRate) { + int maxVideoBitRate = Math.min(MAXIMUM_TARGET_VIDEO_BITRATE, inputTotalBitRate - AUDIO_BITRATE); + int minVideoBitRate = Math.min(MINIMUM_TARGET_VIDEO_BITRATE, maxVideoBitRate); + + int targetVideoBitRate = Math.max(minVideoBitRate, Math.min(getTargetVideoBitRate(upperFileSizeLimitWithMargin, duration), maxVideoBitRate)); + int bitRateRange = maxVideoBitRate - minVideoBitRate; + double quality = bitRateRange == 0 ? 1 : (targetVideoBitRate - minVideoBitRate) / (double) bitRateRange; + + return new Quality(targetVideoBitRate, AUDIO_BITRATE, quality, duration); + } + + private int getTargetVideoBitRate(long sizeGuideBytes, long duration) { + double durationSeconds = duration / 1000d; + + sizeGuideBytes -= durationSeconds * AUDIO_BITRATE / 8; + + double targetAttachmentSizeBits = sizeGuideBytes * 8L; + + return (int) (targetAttachmentSizeBits / durationSeconds); + } + + public static int bitRate(long bytes, long durationMs) { + return (int) (bytes * 8 / (durationMs / 1000f)); + } + + public static class Quality { + private final int targetVideoBitRate; + private final int targetAudioBitRate; + private final double quality; + private final long duration; + + private Quality(int targetVideoBitRate, int targetAudioBitRate, double quality, long duration) { + this.targetVideoBitRate = targetVideoBitRate; + this.targetAudioBitRate = targetAudioBitRate; + this.quality = Math.max(0, Math.min(quality, 1)); + this.duration = duration; + } + + /** + * [0..1] + *

+ * 0 = {@link #MINIMUM_TARGET_VIDEO_BITRATE} + * 1 = {@link #MAXIMUM_TARGET_VIDEO_BITRATE} + */ + public double getQuality() { + return quality; + } + + public int getTargetVideoBitRate() { + return targetVideoBitRate; + } + + public int getTargetAudioBitRate() { + return targetAudioBitRate; + } + + public int getTargetTotalBitRate() { + return targetVideoBitRate + targetAudioBitRate; + } + + public boolean useLowRes() { + return targetVideoBitRate < LOW_RES_TARGET_VIDEO_BITRATE; + } + + public int getOutputResolution() { + return useLowRes() ? LOW_RES_OUTPUT_FORMAT + : OUTPUT_FORMAT; + } + + public long getFileSizeEstimate() { + return getTargetTotalBitRate() * duration / 8000; + } + + @Override + public String toString() { + return "Quality{" + + "targetVideoBitRate=" + targetVideoBitRate + + ", targetAudioBitRate=" + targetAudioBitRate + + ", quality=" + quality + + ", duration=" + duration + + ", filesize=" + getFileSizeEstimate() + + '}'; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java new file mode 100644 index 00000000..54bd96ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2017 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.video; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.ClippingMediaSource; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; + +import java.util.concurrent.TimeUnit; + +public class VideoPlayer extends FrameLayout { + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(VideoPlayer.class); + + private final PlayerView exoView; + private final PlayerControlView exoControls; + + private SimpleExoPlayer exoPlayer; + private Window window; + private PlayerStateCallback playerStateCallback; + private PlayerCallback playerCallback; + private boolean clipped; + private long clippedStartUs; + + public VideoPlayer(Context context) { + this(context, null); + } + + public VideoPlayer(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(context, R.layout.video_player, this); + + this.exoView = findViewById(R.id.video_view); + this.exoControls = new PlayerControlView(getContext()); + this.exoControls.setShowTimeoutMs(-1); + } + + private CreateMediaSource createMediaSource; + + public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay) { + Context context = getContext(); + DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(context); + TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); + TrackSelector trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + LoadControl loadControl = new DefaultLoadControl(); + + exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector, loadControl); + exoPlayer.addListener(new ExoPlayerListener(window, playerStateCallback)); + exoPlayer.addListener(new Player.DefaultEventListener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playerCallback != null) { + switch (playbackState) { + case Player.STATE_READY: + if (playWhenReady) playerCallback.onPlaying(); + break; + case Player.STATE_ENDED: + playerCallback.onStopped(); + break; + } + } + } + }); + exoView.setPlayer(exoPlayer); + exoControls.setPlayer(exoPlayer); + + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); + + createMediaSource = () -> new ExtractorMediaSource.Factory(attachmentDataSourceFactory) + .setExtractorsFactory(extractorsFactory) + .createMediaSource(videoSource.getUri()); + + exoPlayer.prepare(createMediaSource.create()); + exoPlayer.setPlayWhenReady(autoplay); + } + + public void pause() { + this.exoPlayer.setPlayWhenReady(false); + } + + public void hideControls() { + if (this.exoView != null) { + this.exoView.hideController(); + } + } + + public @Nullable View getControlView() { + if (this.exoControls != null) { + return this.exoControls; + } + return null; + } + + public void cleanup() { + if (this.exoPlayer != null) { + this.exoPlayer.release(); + } + } + + public void loopForever() { + if (this.exoPlayer != null) { + exoPlayer.setRepeatMode(Player.REPEAT_MODE_ONE); + } + } + + public long getDuration() { + if (this.exoPlayer != null) { + return this.exoPlayer.getDuration(); + } + return 0L; + } + + public long getPlaybackPosition() { + if (this.exoPlayer != null) { + return this.exoPlayer.getCurrentPosition(); + } + return 0L; + } + + public long getPlaybackPositionUs() { + if (this.exoPlayer != null) { + return TimeUnit.MILLISECONDS.toMicros(this.exoPlayer.getCurrentPosition()) + clippedStartUs; + } + return 0L; + } + + public void setPlaybackPosition(long positionMs) { + if (this.exoPlayer != null) { + this.exoPlayer.seekTo(positionMs); + } + } + + public void clip(long fromUs, long toUs, boolean playWhenReady) { + if (this.exoPlayer != null && createMediaSource != null) { + MediaSource clippedMediaSource = new ClippingMediaSource(createMediaSource.create(), fromUs, toUs); + exoPlayer.prepare(clippedMediaSource); + exoPlayer.setPlayWhenReady(playWhenReady); + clipped = true; + clippedStartUs = fromUs; + } + } + + public void removeClip(boolean playWhenReady) { + if (exoPlayer != null && createMediaSource != null) { + if (clipped) { + exoPlayer.prepare(createMediaSource.create()); + clipped = false; + clippedStartUs = 0; + } + exoPlayer.setPlayWhenReady(playWhenReady); + } + } + + public void setWindow(@Nullable Window window) { + this.window = window; + } + + public void setPlayerStateCallbacks(@Nullable PlayerStateCallback playerStateCallback) { + this.playerStateCallback = playerStateCallback; + } + + public void setPlayerCallback(PlayerCallback playerCallback) { + this.playerCallback = playerCallback; + } + + /** + * Resumes a paused video, or restarts if at end of video. + */ + public void play() { + if (exoPlayer != null) { + exoPlayer.setPlayWhenReady(true); + if (exoPlayer.getCurrentPosition() >= exoPlayer.getDuration()) { + exoPlayer.seekTo(0); + } + } + } + + private static class ExoPlayerListener extends Player.DefaultEventListener { + private final Window window; + private final PlayerStateCallback playerStateCallback; + + ExoPlayerListener(Window window, PlayerStateCallback playerStateCallback) { + this.window = window; + this.playerStateCallback = playerStateCallback; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + switch(playbackState) { + case Player.STATE_IDLE: + case Player.STATE_BUFFERING: + case Player.STATE_ENDED: + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + break; + case Player.STATE_READY: + if (playWhenReady) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + notifyPlayerReady(); + break; + default: + break; + } + } + + private void notifyPlayerReady() { + if (playerStateCallback != null) playerStateCallback.onPlayerReady(); + } + } + + public interface PlayerStateCallback { + void onPlayerReady(); + } + + public interface PlayerCallback { + + void onPlaying(); + + void onStopped(); + } + + private interface CreateMediaSource { + MediaSource create(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java new file mode 100644 index 00000000..fd13bdf5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.video; + +import java.io.IOException; + +public final class VideoSizeException extends IOException { + + VideoSizeException(String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java new file mode 100644 index 00000000..88d702f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.video; + +public final class VideoSourceException extends Exception { + + VideoSourceException(String message) { + super(message); + } + + VideoSourceException(String message, Exception inner) { + super(message, inner); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java new file mode 100644 index 00000000..18a4e4ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.video; + +import android.content.Context; +import android.content.res.Resources; +import android.media.MediaFormat; +import android.util.DisplayMetrics; +import android.util.Size; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.concurrent.TimeUnit; + +public final class VideoUtil { + + public static final int AUDIO_BIT_RATE = 192_000; + public static final int VIDEO_FRAME_RATE = 30; + public static final int VIDEO_BIT_RATE = 2_000_000; + + static final int VIDEO_SHORT_WIDTH = 720; + + private static final int VIDEO_LONG_WIDTH = 1280; + private static final int VIDEO_MAX_RECORD_LENGTH_S = 60; + private static final int VIDEO_MAX_UPLOAD_LENGTH_S = (int) TimeUnit.MINUTES.toSeconds(10); + + private static final int TOTAL_BYTES_PER_SECOND = (VIDEO_BIT_RATE / 8) + (AUDIO_BIT_RATE / 8); + + @RequiresApi(21) + public static final String VIDEO_MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC; + public static final String AUDIO_MIME_TYPE = "audio/mp4a-latm"; + + public static final String RECORDED_VIDEO_CONTENT_TYPE = MediaUtil.VIDEO_MP4; + + private VideoUtil() { } + + @RequiresApi(21) + public static Size getVideoRecordingSize() { + return isPortrait(screenSize()) + ? new Size(VIDEO_SHORT_WIDTH, VIDEO_LONG_WIDTH) + : new Size(VIDEO_LONG_WIDTH, VIDEO_SHORT_WIDTH); + } + + public static int getMaxVideoRecordDurationInSeconds(@NonNull Context context, @NonNull MediaConstraints mediaConstraints) { + int allowedSize = mediaConstraints.getCompressedVideoMaxSize(context); + int duration = (int) Math.floor((float) allowedSize / TOTAL_BYTES_PER_SECOND); + + return Math.min(duration, VIDEO_MAX_RECORD_LENGTH_S); + } + + public static int getMaxVideoUploadDurationInSeconds() { + return VIDEO_MAX_UPLOAD_LENGTH_S; + } + + @RequiresApi(21) + private static Size screenSize() { + DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics(); + return new Size(metrics.widthPixels, metrics.heightPixels); + } + + @RequiresApi(21) + private static boolean isPortrait(Size size) { + return size.getWidth() < size.getHeight(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java new file mode 100644 index 00000000..2a55e586 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSource.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.video.exo; + + +import android.net.Uri; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.providers.BlobProvider; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class AttachmentDataSource implements DataSource { + + private final DefaultDataSource defaultDataSource; + private final PartDataSource partDataSource; + private final BlobDataSource blobDataSource; + + private DataSource dataSource; + + public AttachmentDataSource(DefaultDataSource defaultDataSource, + PartDataSource partDataSource, + BlobDataSource blobDataSource) + { + this.defaultDataSource = defaultDataSource; + this.partDataSource = partDataSource; + this.blobDataSource = blobDataSource; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + if (BlobProvider.isAuthority(dataSpec.uri)) dataSource = blobDataSource; + else if (PartAuthority.isLocalUri(dataSpec.uri)) dataSource = partDataSource; + else dataSource = defaultDataSource; + + return dataSource.open(dataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + return dataSource.read(buffer, offset, readLength); + } + + @Override + public Uri getUri() { + return dataSource.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + dataSource.close(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java new file mode 100644 index 00000000..2e3e5e2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/AttachmentDataSourceFactory.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.video.exo; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; + +public class AttachmentDataSourceFactory implements DataSource.Factory { + + private final Context context; + + private final DefaultDataSourceFactory defaultDataSourceFactory; + private final TransferListener listener; + + public AttachmentDataSourceFactory(@NonNull Context context, + @NonNull DefaultDataSourceFactory defaultDataSourceFactory, + @Nullable TransferListener listener) + { + this.context = context; + this.defaultDataSourceFactory = defaultDataSourceFactory; + this.listener = listener; + } + + @Override + public AttachmentDataSource createDataSource() { + return new AttachmentDataSource(defaultDataSourceFactory.createDataSource(), + new PartDataSource(context, listener), + new BlobDataSource(context, listener)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java new file mode 100644 index 00000000..8bc090cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.video.exo; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.thoughtcrime.securesms.providers.BlobProvider; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class BlobDataSource implements DataSource { + + private final @NonNull Context context; + private final @Nullable TransferListener listener; + + private Uri uri; + private InputStream inputStream; + + BlobDataSource(@NonNull Context context, @Nullable TransferListener listener) { + this.context = context.getApplicationContext(); + this.listener = listener; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + this.uri = dataSpec.uri; + this.inputStream = BlobProvider.getInstance().getStream(context, uri, dataSpec.position); + + if (listener != null) { + listener.onTransferStart(this, dataSpec, false); + } + + long size = unwrapLong(BlobProvider.getFileSize(uri)); + if (size - dataSpec.position <= 0) throw new EOFException("No more data"); + + return size - dataSpec.position; + } + + private long unwrapLong(@Nullable Long boxed) { + return boxed == null ? 0L : boxed; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + int read = inputStream.read(buffer, offset, readLength); + + if (read > 0 && listener != null) { + listener.onBytesTransferred(this, null, false, read); + } + + return read; + } + + @Override + public Uri getUri() { + return uri; + } + + @Override + public Map> getResponseHeaders() { + return Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + inputStream.close(); + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java new file mode 100644 index 00000000..ca208f4b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.video.exo; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mms.PartUriParser; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class PartDataSource implements DataSource { + + private final @NonNull Context context; + private final @Nullable TransferListener listener; + + private Uri uri; + private InputStream inputSteam; + + PartDataSource(@NonNull Context context, @Nullable TransferListener listener) { + this.context = context.getApplicationContext(); + this.listener = listener; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + this.uri = dataSpec.uri; + + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + PartUriParser partUri = new PartUriParser(uri); + Attachment attachment = attachmentDatabase.getAttachment(partUri.getPartId()); + + if (attachment == null) throw new IOException("Attachment not found"); + + this.inputSteam = attachmentDatabase.getAttachmentStream(partUri.getPartId(), dataSpec.position); + + if (listener != null) { + listener.onTransferStart(this, dataSpec, false); + } + + if (attachment.getSize() - dataSpec.position <= 0) throw new EOFException("No more data"); + + return attachment.getSize() - dataSpec.position; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + int read = inputSteam.read(buffer, offset, readLength); + + if (read > 0 && listener != null) { + listener.onBytesTransferred(this, null, false, read); + } + + return read; + } + + @Override + public Uri getUri() { + return uri; + } + + @Override + public Map> getResponseHeaders() { + return Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + inputSteam.close(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java new file mode 100644 index 00000000..98a04022 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java @@ -0,0 +1,421 @@ +package org.thoughtcrime.securesms.video.videoconverter; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaExtractor; +import android.media.MediaFormat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.video.VideoUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Locale; + +final class AudioTrackConverter { + + private static final String TAG = "media-converter"; + private static final boolean VERBOSE = false; // lots of logging + + private static final String OUTPUT_AUDIO_MIME_TYPE = VideoUtil.AUDIO_MIME_TYPE; // Advanced Audio Coding + private static final int OUTPUT_AUDIO_AAC_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC; //MediaCodecInfo.CodecProfileLevel.AACObjectHE; + + private static final int TIMEOUT_USEC = 10000; + + private final long mTimeFrom; + private final long mTimeTo; + private final int mAudioBitrate; + + final long mInputDuration; + + private final MediaExtractor mAudioExtractor; + private final MediaCodec mAudioDecoder; + private final MediaCodec mAudioEncoder; + + private final ByteBuffer[] mAudioDecoderInputBuffers; + private ByteBuffer[] mAudioDecoderOutputBuffers; + private final ByteBuffer[] mAudioEncoderInputBuffers; + private ByteBuffer[] mAudioEncoderOutputBuffers; + private final MediaCodec.BufferInfo mAudioDecoderOutputBufferInfo; + private final MediaCodec.BufferInfo mAudioEncoderOutputBufferInfo; + + MediaFormat mEncoderOutputAudioFormat; + + boolean mAudioExtractorDone; + private boolean mAudioDecoderDone; + boolean mAudioEncoderDone; + + private int mOutputAudioTrack = -1; + + private int mPendingAudioDecoderOutputBufferIndex = -1; + long mMuxingAudioPresentationTime; + + private int mAudioExtractedFrameCount; + private int mAudioDecodedFrameCount; + private int mAudioEncodedFrameCount; + + private Muxer mMuxer; + + static @Nullable + AudioTrackConverter create( + final @NonNull MediaInput input, + final long timeFrom, + final long timeTo, + final int audioBitrate) throws IOException { + + final MediaExtractor audioExtractor = input.createExtractor(); + final int audioInputTrack = getAndSelectAudioTrackIndex(audioExtractor); + if (audioInputTrack == -1) { + audioExtractor.release(); + return null; + } + return new AudioTrackConverter(audioExtractor, audioInputTrack, timeFrom, timeTo, audioBitrate); + } + + private AudioTrackConverter( + final @NonNull MediaExtractor audioExtractor, + final int audioInputTrack, + long timeFrom, + long timeTo, + int audioBitrate) throws IOException { + + mTimeFrom = timeFrom; + mTimeTo = timeTo; + mAudioExtractor = audioExtractor; + mAudioBitrate = audioBitrate; + + final MediaCodecInfo audioCodecInfo = MediaConverter.selectCodec(OUTPUT_AUDIO_MIME_TYPE); + if (audioCodecInfo == null) { + // Don't fail CTS if they don't have an AAC codec (not here, anyway). + Log.e(TAG, "Unable to find an appropriate codec for " + OUTPUT_AUDIO_MIME_TYPE); + throw new FileNotFoundException(); + } + if (VERBOSE) Log.d(TAG, "audio found codec: " + audioCodecInfo.getName()); + + final MediaFormat inputAudioFormat = mAudioExtractor.getTrackFormat(audioInputTrack); + mInputDuration = inputAudioFormat.containsKey(MediaFormat.KEY_DURATION) ? inputAudioFormat.getLong(MediaFormat.KEY_DURATION) : 0; + + final MediaFormat outputAudioFormat = + MediaFormat.createAudioFormat( + OUTPUT_AUDIO_MIME_TYPE, + inputAudioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE), + inputAudioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + outputAudioFormat.setInteger(MediaFormat.KEY_BIT_RATE, audioBitrate); + outputAudioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, OUTPUT_AUDIO_AAC_PROFILE); + outputAudioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1024); + + // Create a MediaCodec for the desired codec, then configure it as an encoder with + // our desired properties. Request a Surface to use for input. + mAudioEncoder = createAudioEncoder(audioCodecInfo, outputAudioFormat); + // Create a MediaCodec for the decoder, based on the extractor's format. + mAudioDecoder = createAudioDecoder(inputAudioFormat); + + mAudioDecoderInputBuffers = mAudioDecoder.getInputBuffers(); + mAudioDecoderOutputBuffers = mAudioDecoder.getOutputBuffers(); + mAudioEncoderInputBuffers = mAudioEncoder.getInputBuffers(); + mAudioEncoderOutputBuffers = mAudioEncoder.getOutputBuffers(); + mAudioDecoderOutputBufferInfo = new MediaCodec.BufferInfo(); + mAudioEncoderOutputBufferInfo = new MediaCodec.BufferInfo(); + + if (mTimeFrom > 0) { + mAudioExtractor.seekTo(mTimeFrom * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + Log.i(TAG, "Seek audio:" + mTimeFrom + " " + mAudioExtractor.getSampleTime()); + } + } + + void setMuxer(final @NonNull Muxer muxer) throws IOException { + mMuxer = muxer; + if (mEncoderOutputAudioFormat != null) { + Log.d(TAG, "muxer: adding audio track."); + if (!mEncoderOutputAudioFormat.containsKey(MediaFormat.KEY_BIT_RATE)) { + mEncoderOutputAudioFormat.setInteger(MediaFormat.KEY_BIT_RATE, mAudioBitrate); + } + if (!mEncoderOutputAudioFormat.containsKey(MediaFormat.KEY_AAC_PROFILE)) { + mEncoderOutputAudioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, OUTPUT_AUDIO_AAC_PROFILE); + } + mOutputAudioTrack = muxer.addTrack(mEncoderOutputAudioFormat); + } + } + + void step() throws IOException { + // Extract audio from file and feed to decoder. + // Do not extract audio if we have determined the output format but we are not yet + // ready to mux the frames. + while (!mAudioExtractorDone && (mEncoderOutputAudioFormat == null || mMuxer != null)) { + int decoderInputBufferIndex = mAudioDecoder.dequeueInputBuffer(TIMEOUT_USEC); + if (decoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no audio decoder input buffer"); + break; + } + if (VERBOSE) { + Log.d(TAG, "audio decoder: returned input buffer: " + decoderInputBufferIndex); + } + final ByteBuffer decoderInputBuffer = mAudioDecoderInputBuffers[decoderInputBufferIndex]; + final int size = mAudioExtractor.readSampleData(decoderInputBuffer, 0); + final long presentationTime = mAudioExtractor.getSampleTime(); + if (VERBOSE) { + Log.d(TAG, "audio extractor: returned buffer of size " + size); + Log.d(TAG, "audio extractor: returned buffer for time " + presentationTime); + } + mAudioExtractorDone = size < 0 || (mTimeTo > 0 && presentationTime > mTimeTo * 1000); + if (mAudioExtractorDone) { + if (VERBOSE) Log.d(TAG, "audio extractor: EOS"); + mAudioDecoder.queueInputBuffer( + decoderInputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } else { + mAudioDecoder.queueInputBuffer( + decoderInputBufferIndex, + 0, + size, + presentationTime, + mAudioExtractor.getSampleFlags()); + } + mAudioExtractor.advance(); + mAudioExtractedFrameCount++; + // We extracted a frame, let's try something else next. + break; + } + + // Poll output frames from the audio decoder. + // Do not poll if we already have a pending buffer to feed to the encoder. + while (!mAudioDecoderDone && mPendingAudioDecoderOutputBufferIndex == -1 + && (mEncoderOutputAudioFormat == null || mMuxer != null)) { + final int decoderOutputBufferIndex = + mAudioDecoder.dequeueOutputBuffer( + mAudioDecoderOutputBufferInfo, TIMEOUT_USEC); + if (decoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no audio decoder output buffer"); + break; + } + if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + if (VERBOSE) Log.d(TAG, "audio decoder: output buffers changed"); + mAudioDecoderOutputBuffers = mAudioDecoder.getOutputBuffers(); + break; + } + if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (VERBOSE) { + MediaFormat decoderOutputAudioFormat = mAudioDecoder.getOutputFormat(); + Log.d(TAG, "audio decoder: output format changed: " + decoderOutputAudioFormat); + } + break; + } + if (VERBOSE) { + Log.d(TAG, "audio decoder: returned output buffer: " + decoderOutputBufferIndex); + Log.d(TAG, "audio decoder: returned buffer of size " + mAudioDecoderOutputBufferInfo.size); + } + if ((mAudioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (VERBOSE) Log.d(TAG, "audio decoder: codec config buffer"); + mAudioDecoder.releaseOutputBuffer(decoderOutputBufferIndex, false); + break; + } + if (mAudioDecoderOutputBufferInfo.presentationTimeUs < mTimeFrom * 1000 && + (mAudioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) == 0) { + if (VERBOSE) + Log.d(TAG, "audio decoder: frame prior to " + mAudioDecoderOutputBufferInfo.presentationTimeUs); + mAudioDecoder.releaseOutputBuffer(decoderOutputBufferIndex, false); + break; + } + if (VERBOSE) { + Log.d(TAG, "audio decoder: returned buffer for time " + mAudioDecoderOutputBufferInfo.presentationTimeUs); + Log.d(TAG, "audio decoder: output buffer is now pending: " + mPendingAudioDecoderOutputBufferIndex); + } + mPendingAudioDecoderOutputBufferIndex = decoderOutputBufferIndex; + mAudioDecodedFrameCount++; + // We extracted a pending frame, let's try something else next. + break; + } + + // Feed the pending decoded audio buffer to the audio encoder. + while (mPendingAudioDecoderOutputBufferIndex != -1) { + if (VERBOSE) { + Log.d(TAG, "audio decoder: attempting to process pending buffer: " + mPendingAudioDecoderOutputBufferIndex); + } + final int encoderInputBufferIndex = mAudioEncoder.dequeueInputBuffer(TIMEOUT_USEC); + if (encoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no audio encoder input buffer"); + break; + } + if (VERBOSE) { + Log.d(TAG, "audio encoder: returned input buffer: " + encoderInputBufferIndex); + } + final ByteBuffer encoderInputBuffer = mAudioEncoderInputBuffers[encoderInputBufferIndex]; + final int size = mAudioDecoderOutputBufferInfo.size; + final long presentationTime = mAudioDecoderOutputBufferInfo.presentationTimeUs; + if (VERBOSE) { + Log.d(TAG, "audio decoder: processing pending buffer: " + mPendingAudioDecoderOutputBufferIndex); + } + if (VERBOSE) { + Log.d(TAG, "audio decoder: pending buffer of size " + size); + Log.d(TAG, "audio decoder: pending buffer for time " + presentationTime); + } + if (size >= 0) { + final ByteBuffer decoderOutputBuffer = mAudioDecoderOutputBuffers[mPendingAudioDecoderOutputBufferIndex].duplicate(); + decoderOutputBuffer.position(mAudioDecoderOutputBufferInfo.offset); + decoderOutputBuffer.limit(mAudioDecoderOutputBufferInfo.offset + size); + encoderInputBuffer.position(0); + encoderInputBuffer.put(decoderOutputBuffer); + + mAudioEncoder.queueInputBuffer( + encoderInputBufferIndex, + 0, + size, + presentationTime, + mAudioDecoderOutputBufferInfo.flags); + } + mAudioDecoder.releaseOutputBuffer(mPendingAudioDecoderOutputBufferIndex, false); + mPendingAudioDecoderOutputBufferIndex = -1; + if ((mAudioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + if (VERBOSE) Log.d(TAG, "audio decoder: EOS"); + mAudioDecoderDone = true; + } + // We enqueued a pending frame, let's try something else next. + break; + } + + // Poll frames from the audio encoder and send them to the muxer. + while (!mAudioEncoderDone && (mEncoderOutputAudioFormat == null || mMuxer != null)) { + final int encoderOutputBufferIndex = mAudioEncoder.dequeueOutputBuffer(mAudioEncoderOutputBufferInfo, TIMEOUT_USEC); + if (encoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no audio encoder output buffer"); + break; + } + if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + if (VERBOSE) Log.d(TAG, "audio encoder: output buffers changed"); + mAudioEncoderOutputBuffers = mAudioEncoder.getOutputBuffers(); + break; + } + if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (VERBOSE) Log.d(TAG, "audio encoder: output format changed"); + Preconditions.checkState("audio encoder changed its output format again?", mOutputAudioTrack < 0); + + mEncoderOutputAudioFormat = mAudioEncoder.getOutputFormat(); + break; + } + Preconditions.checkState("should have added track before processing output", mMuxer != null); + if (VERBOSE) { + Log.d(TAG, "audio encoder: returned output buffer: " + encoderOutputBufferIndex); + Log.d(TAG, "audio encoder: returned buffer of size " + mAudioEncoderOutputBufferInfo.size); + } + final ByteBuffer encoderOutputBuffer = mAudioEncoderOutputBuffers[encoderOutputBufferIndex]; + if ((mAudioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (VERBOSE) Log.d(TAG, "audio encoder: codec config buffer"); + // Simply ignore codec config buffers. + mAudioEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false); + break; + } + if (VERBOSE) { + Log.d(TAG, "audio encoder: returned buffer for time " + mAudioEncoderOutputBufferInfo.presentationTimeUs); + } + if (mAudioEncoderOutputBufferInfo.size != 0) { + mMuxer.writeSampleData(mOutputAudioTrack, encoderOutputBuffer, mAudioEncoderOutputBufferInfo); + mMuxingAudioPresentationTime = Math.max(mMuxingAudioPresentationTime, mAudioEncoderOutputBufferInfo.presentationTimeUs); + } + if ((mAudioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + if (VERBOSE) Log.d(TAG, "audio encoder: EOS"); + mAudioEncoderDone = true; + } + mAudioEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false); + mAudioEncodedFrameCount++; + // We enqueued an encoded frame, let's try something else next. + break; + } + } + + void release() throws Exception { + Exception exception = null; + try { + if (mAudioExtractor != null) { + mAudioExtractor.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mAudioExtractor", e); + exception = e; + } + try { + if (mAudioDecoder != null) { + mAudioDecoder.stop(); + mAudioDecoder.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mAudioDecoder", e); + if (exception == null) { + exception = e; + } + } + try { + if (mAudioEncoder != null) { + mAudioEncoder.stop(); + mAudioEncoder.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mAudioEncoder", e); + if (exception == null) { + exception = e; + } + } + if (exception != null) { + throw exception; + } + } + + String dumpState() { + return String.format(Locale.US, + "A{" + + "extracted:%d(done:%b) " + + "decoded:%d(done:%b) " + + "encoded:%d(done:%b) " + + "pending:%d " + + "muxing:%b(track:%d} )", + mAudioExtractedFrameCount, mAudioExtractorDone, + mAudioDecodedFrameCount, mAudioDecoderDone, + mAudioEncodedFrameCount, mAudioEncoderDone, + mPendingAudioDecoderOutputBufferIndex, + mMuxer != null, mOutputAudioTrack); + } + + void verifyEndState() { + Preconditions.checkState("no frame should be pending", -1 == mPendingAudioDecoderOutputBufferIndex); + } + + private static @NonNull + MediaCodec createAudioDecoder(final @NonNull MediaFormat inputFormat) throws IOException { + final MediaCodec decoder = MediaCodec.createDecoderByType(MediaConverter.getMimeTypeFor(inputFormat)); + decoder.configure(inputFormat, null, null, 0); + decoder.start(); + return decoder; + } + + private static @NonNull + MediaCodec createAudioEncoder(final @NonNull MediaCodecInfo codecInfo, final @NonNull MediaFormat format) throws IOException { + final MediaCodec encoder = MediaCodec.createByCodecName(codecInfo.getName()); + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + encoder.start(); + return encoder; + } + + private static int getAndSelectAudioTrackIndex(MediaExtractor extractor) { + for (int index = 0; index < extractor.getTrackCount(); ++index) { + if (VERBOSE) { + Log.d(TAG, "format for track " + index + " is " + MediaConverter.getMimeTypeFor(extractor.getTrackFormat(index))); + } + if (isAudioFormat(extractor.getTrackFormat(index))) { + extractor.selectTrack(index); + return index; + } + } + return -1; + } + + private static boolean isAudioFormat(final @NonNull MediaFormat format) { + return MediaConverter.getMimeTypeFor(format).startsWith("audio/"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java new file mode 100644 index 00000000..1c022dca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This file has been modified by Signal. + */ + +package org.thoughtcrime.securesms.video.videoconverter; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.StringDef; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.video.videoconverter.muxer.StreamingMuxer; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@SuppressWarnings("WeakerAccess") +public final class MediaConverter { + private static final String TAG = "media-converter"; + private static final boolean VERBOSE = false; // lots of logging + + // Describes when the annotation will be discarded + @Retention(RetentionPolicy.SOURCE) + @StringDef({VIDEO_CODEC_H264, VIDEO_CODEC_H265}) + public @interface VideoCodec {} + public static final String VIDEO_CODEC_H264 = "video/avc"; + public static final String VIDEO_CODEC_H265 = "video/hevc"; + + private MediaInput mInput; + private Output mOutput; + + private long mTimeFrom; + private long mTimeTo; + private int mVideoResolution; + private int mVideoBitrate = 2000000; // 2Mbps + private @VideoCodec String mVideoCodec = VIDEO_CODEC_H264; + private int mAudioBitrate = 128000; // 128Kbps + + private Listener mListener; + private boolean mCancelled; + + public interface Listener { + boolean onProgress(int percent); + } + + public MediaConverter() { + } + + public void setInput(final @NonNull MediaInput videoInput) { + mInput = videoInput; + } + + @SuppressWarnings("unused") + public void setOutput(final @NonNull File file) { + mOutput = new FileOutput(file); + } + + @SuppressWarnings("unused") + @RequiresApi(26) + public void setOutput(final @NonNull FileDescriptor fileDescriptor) { + mOutput = new FileDescriptorOutput(fileDescriptor); + } + + public void setOutput(final @NonNull OutputStream stream) { + mOutput = new StreamOutput(stream); + } + + @SuppressWarnings("unused") + public void setTimeRange(long timeFrom, long timeTo) { + mTimeFrom = timeFrom; + mTimeTo = timeTo; + + if (timeTo > 0 && timeFrom >= timeTo) { + throw new IllegalArgumentException("timeFrom:" + timeFrom + " timeTo:" + timeTo); + } + } + + @SuppressWarnings("unused") + public void setVideoResolution(int videoResolution) { + mVideoResolution = videoResolution; + } + + @SuppressWarnings("unused") + public void setVideoCodec(final @VideoCodec String videoCodec) throws FileNotFoundException { + if (selectCodec(videoCodec) == null) { + throw new FileNotFoundException(); + } + mVideoCodec = videoCodec; + } + + @SuppressWarnings("unused") + public void setVideoBitrate(final int videoBitrate) { + mVideoBitrate = videoBitrate; + } + + @SuppressWarnings("unused") + public void setAudioBitrate(final int audioBitrate) { + mAudioBitrate = audioBitrate; + } + + @SuppressWarnings("unused") + public void setListener(final Listener listener) { + mListener = listener; + } + + @WorkerThread + @RequiresApi(23) + public void convert() throws EncodingException, IOException { + // Exception that may be thrown during release. + Exception exception = null; + Muxer muxer = null; + VideoTrackConverter videoTrackConverter = null; + AudioTrackConverter audioTrackConverter = null; + + try { + videoTrackConverter = VideoTrackConverter.create(mInput, mTimeFrom, mTimeTo, mVideoResolution, mVideoBitrate, mVideoCodec); + audioTrackConverter = AudioTrackConverter.create(mInput, mTimeFrom, mTimeTo, mAudioBitrate); + + if (videoTrackConverter == null && audioTrackConverter == null) { + throw new EncodingException("No video and audio tracks"); + } + + muxer = mOutput.createMuxer(); + + doExtractDecodeEditEncodeMux( + videoTrackConverter, + audioTrackConverter, + muxer); + + } catch (EncodingException | IOException e) { + Log.e(TAG, "error converting", e); + exception = e; + throw e; + } catch (Exception e) { + Log.e(TAG, "error converting", e); + exception = e; + } finally { + if (VERBOSE) Log.d(TAG, "releasing extractor, decoder, encoder, and muxer"); + // Try to release everything we acquired, even if one of the releases fails, in which + // case we save the first exception we got and re-throw at the end (unless something + // other exception has already been thrown). This guarantees the first exception thrown + // is reported as the cause of the error, everything is (attempted) to be released, and + // all other exceptions appear in the logs. + try { + if (videoTrackConverter != null) { + videoTrackConverter.release(); + } + } catch (Exception e) { + if (exception == null) { + exception = e; + } + } + try { + if (audioTrackConverter != null) { + audioTrackConverter.release(); + } + } catch (Exception e) { + if (exception == null) { + exception = e; + } + } + try { + if (muxer != null) { + muxer.stop(); + muxer.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing muxer", e); + if (exception == null) { + exception = e; + } + } + } + if (exception != null) { + throw new EncodingException("Transcode failed", exception); + } + } + + /** + * Does the actual work for extracting, decoding, encoding and muxing. + */ + private void doExtractDecodeEditEncodeMux( + final @Nullable VideoTrackConverter videoTrackConverter, + final @Nullable AudioTrackConverter audioTrackConverter, + final @NonNull Muxer muxer) throws IOException, TranscodingException { + + boolean muxing = false; + int percentProcessed = 0; + long inputDuration = Math.max( + videoTrackConverter == null ? 0 : videoTrackConverter.mInputDuration, + audioTrackConverter == null ? 0 : audioTrackConverter.mInputDuration); + + while (!mCancelled && + ((videoTrackConverter != null && !videoTrackConverter.mVideoEncoderDone) || + (audioTrackConverter != null &&!audioTrackConverter.mAudioEncoderDone))) { + + if (VERBOSE) { + Log.d(TAG, "loop: " + + (videoTrackConverter == null ? "" : videoTrackConverter.dumpState()) + + (audioTrackConverter == null ? "" : audioTrackConverter.dumpState()) + + " muxing:" + muxing); + } + + if (videoTrackConverter != null && (audioTrackConverter == null || audioTrackConverter.mAudioExtractorDone || videoTrackConverter.mMuxingVideoPresentationTime <= audioTrackConverter.mMuxingAudioPresentationTime)) { + videoTrackConverter.step(); + } + + if (audioTrackConverter != null && (videoTrackConverter == null || videoTrackConverter.mVideoExtractorDone || videoTrackConverter.mMuxingVideoPresentationTime >= audioTrackConverter.mMuxingAudioPresentationTime)) { + audioTrackConverter.step(); + } + + if (inputDuration != 0 && mListener != null) { + final long timeFromUs = mTimeFrom <= 0 ? 0 : mTimeFrom * 1000; + final long timeToUs = mTimeTo <= 0 ? inputDuration : mTimeTo * 1000; + final int curPercentProcessed = (int) (100 * + (Math.max( + videoTrackConverter == null ? 0 : videoTrackConverter.mMuxingVideoPresentationTime, + audioTrackConverter == null ? 0 : audioTrackConverter.mMuxingAudioPresentationTime) + - timeFromUs) / (timeToUs - timeFromUs)); + + if (curPercentProcessed != percentProcessed) { + percentProcessed = curPercentProcessed; + mCancelled = mCancelled || mListener.onProgress(percentProcessed); + } + } + + if (!muxing + && (videoTrackConverter == null || videoTrackConverter.mEncoderOutputVideoFormat != null) + && (audioTrackConverter == null || audioTrackConverter.mEncoderOutputAudioFormat != null)) { + if (videoTrackConverter != null) { + videoTrackConverter.setMuxer(muxer); + } + if (audioTrackConverter != null) { + audioTrackConverter.setMuxer(muxer); + } + Log.d(TAG, "muxer: starting"); + muxer.start(); + muxing = true; + } + } + + // Basic sanity checks. + if (videoTrackConverter != null) { + videoTrackConverter.verifyEndState(); + } + if (audioTrackConverter != null) { + audioTrackConverter.verifyEndState(); + } + + // TODO: Check the generated output file. + } + + static String getMimeTypeFor(MediaFormat format) { + return format.getString(MediaFormat.KEY_MIME); + } + + /** + * Returns the first codec capable of encoding the specified MIME type, or null if no match was + * found. + */ + static MediaCodecInfo selectCodec(final String mimeType) { + final int numCodecs = MediaCodecList.getCodecCount(); + for (int i = 0; i < numCodecs; i++) { + final MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i); + + if (!codecInfo.isEncoder()) { + continue; + } + + final String[] types = codecInfo.getSupportedTypes(); + for (String type : types) { + if (type.equalsIgnoreCase(mimeType)) { + return codecInfo; + } + } + } + return null; + } + + interface Output { + @NonNull + Muxer createMuxer() throws IOException; + } + + private static class FileOutput implements Output { + + final File file; + + FileOutput(final @NonNull File file) { + this.file = file; + } + + @Override + public @NonNull + Muxer createMuxer() throws IOException { + return new AndroidMuxer(file); + } + } + + @RequiresApi(26) + private static class FileDescriptorOutput implements Output { + + final FileDescriptor fileDescriptor; + + FileDescriptorOutput(final @NonNull FileDescriptor fileDescriptor) { + this.fileDescriptor = fileDescriptor; + } + + @Override + public @NonNull + Muxer createMuxer() throws IOException { + return new AndroidMuxer(fileDescriptor); + } + } + + private static class StreamOutput implements Output { + + final OutputStream outputStream; + + StreamOutput(final @NonNull OutputStream outputStream) { + this.outputStream = outputStream; + } + + @Override + public @NonNull Muxer createMuxer() { + return new StreamingMuxer(outputStream); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java new file mode 100644 index 00000000..7347b149 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java @@ -0,0 +1,190 @@ +package org.thoughtcrime.securesms.video.videoconverter; + +import android.graphics.Bitmap; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.opengl.GLES20; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@RequiresApi(api = 23) +final class VideoThumbnailsExtractor { + + private static final String TAG = Log.tag(VideoThumbnailsExtractor.class); + + interface Callback { + void durationKnown(long duration); + + boolean publishProgress(int index, Bitmap thumbnail); + + void failed(); + } + + static void extractThumbnails(final @NonNull MediaInput input, + final int thumbnailCount, + final int thumbnailResolution, + final @NonNull Callback callback) + { + MediaExtractor extractor = null; + MediaCodec decoder = null; + OutputSurface outputSurface = null; + try { + extractor = input.createExtractor(); + MediaFormat mediaFormat = null; + for (int index = 0; index < extractor.getTrackCount(); ++index) { + if (extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME).startsWith("video/")) { + extractor.selectTrack(index); + mediaFormat = extractor.getTrackFormat(index); + break; + } + } + if (mediaFormat != null) { + final String mime = mediaFormat.getString(MediaFormat.KEY_MIME); + final int rotation = mediaFormat.containsKey(MediaFormat.KEY_ROTATION) ? mediaFormat.getInteger(MediaFormat.KEY_ROTATION) : 0; + final int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH); + final int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); + final int outputWidth; + final int outputHeight; + + if (width < height) { + outputWidth = thumbnailResolution; + outputHeight = height * outputWidth / width; + } else { + outputHeight = thumbnailResolution; + outputWidth = width * outputHeight / height; + } + + final int outputWidthRotated; + final int outputHeightRotated; + + if ((rotation % 180 == 90)) { + //noinspection SuspiciousNameCombination + outputWidthRotated = outputHeight; + //noinspection SuspiciousNameCombination + outputHeightRotated = outputWidth; + } else { + outputWidthRotated = outputWidth; + outputHeightRotated = outputHeight; + } + + Log.i(TAG, "video: " + width + "x" + height + " " + rotation); + Log.i(TAG, "output: " + outputWidthRotated + "x" + outputHeightRotated); + + outputSurface = new OutputSurface(outputWidthRotated, outputHeightRotated, true); + + decoder = MediaCodec.createDecoderByType(mime); + decoder.configure(mediaFormat, outputSurface.getSurface(), null, 0); + decoder.start(); + + long duration = 0; + + if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) { + duration = mediaFormat.getLong(MediaFormat.KEY_DURATION); + } else { + Log.w(TAG, "Video is missing duration!"); + } + + callback.durationKnown(duration); + + doExtract(extractor, decoder, outputSurface, outputWidthRotated, outputHeightRotated, duration, thumbnailCount, callback); + } + } catch (IOException | TranscodingException e) { + Log.w(TAG, e); + callback.failed(); + } finally { + if (outputSurface != null) { + outputSurface.release(); + } + if (decoder != null) { + decoder.stop(); + decoder.release(); + } + if (extractor != null) { + extractor.release(); + } + } + } + + private static void doExtract(final @NonNull MediaExtractor extractor, + final @NonNull MediaCodec decoder, + final @NonNull OutputSurface outputSurface, + final int outputWidth, int outputHeight, long duration, int thumbnailCount, + final @NonNull Callback callback) + throws TranscodingException + { + + final int TIMEOUT_USEC = 10000; + final ByteBuffer[] decoderInputBuffers = decoder.getInputBuffers(); + final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + + int samplesExtracted = 0; + int thumbnailsCreated = 0; + + Log.i(TAG, "doExtract started"); + final ByteBuffer pixelBuf = ByteBuffer.allocateDirect(outputWidth * outputHeight * 4); + pixelBuf.order(ByteOrder.LITTLE_ENDIAN); + + boolean outputDone = false; + boolean inputDone = false; + while (!outputDone) { + if (!inputDone) { + int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC); + if (inputBufIndex >= 0) { + final ByteBuffer inputBuf = decoderInputBuffers[inputBufIndex]; + final int sampleSize = extractor.readSampleData(inputBuf, 0); + if (sampleSize < 0 || samplesExtracted >= thumbnailCount) { + decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + Log.i(TAG, "input done"); + } else { + final long presentationTimeUs = extractor.getSampleTime(); + decoder.queueInputBuffer(inputBufIndex, 0, sampleSize, presentationTimeUs, 0 /*flags*/); + samplesExtracted++; + extractor.seekTo(duration * samplesExtracted / thumbnailCount, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + Log.i(TAG, "seek to " + duration * samplesExtracted / thumbnailCount + ", actual " + extractor.getSampleTime()); + } + } + } + + int outputBufIndex = decoder.dequeueOutputBuffer(info, TIMEOUT_USEC); + if (outputBufIndex >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + outputDone = true; + } + + final boolean shouldRender = (info.size != 0) /*&& (info.presentationTimeUs >= duration * decodeCount / thumbnailCount)*/; + + decoder.releaseOutputBuffer(outputBufIndex, shouldRender); + if (shouldRender) { + outputSurface.awaitNewImage(); + outputSurface.drawImage(); + + if (thumbnailsCreated < thumbnailCount) { + pixelBuf.rewind(); + GLES20.glReadPixels(0, 0, outputWidth, outputHeight, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixelBuf); + + final Bitmap bitmap = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888); + pixelBuf.rewind(); + bitmap.copyPixelsFromBuffer(pixelBuf); + + if (!callback.publishProgress(thumbnailsCreated, bitmap)) { + break; + } + Log.i(TAG, "publishProgress for frame " + thumbnailsCreated + " at " + info.presentationTimeUs + " (target " + duration * thumbnailsCreated / thumbnailCount + ")"); + } + thumbnailsCreated++; + } + } + } + Log.i(TAG, "doExtract finished"); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java new file mode 100644 index 00000000..6462cfa3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java @@ -0,0 +1,564 @@ +package org.thoughtcrime.securesms.video.videoconverter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.annotation.RequiresApi; +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MemoryUnitFormat; + +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +@RequiresApi(api = 23) +public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView { + + private static final String TAG = Log.tag(VideoThumbnailsRangeSelectorView.class); + + private static final long MINIMUM_SELECTABLE_RANGE = TimeUnit.MILLISECONDS.toMicros(500); + private static final int ANIMATION_DURATION_MS = 100; + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintGrey = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint thumbTimeTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint thumbTimeBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Rect tempDrawRect = new Rect(); + private final RectF timePillRect = new RectF(); + private Drawable chevronLeft; + private Drawable chevronRight; + + @Px private int left; + @Px private int right; + @Px private int cursor; + private Long minValue; + private Long maxValue; + private Long externalMinValue; + private Long externalMaxValue; + private float xDown; + private long downCursor; + private long downMin; + private long downMax; + private Thumb dragThumb; + private Thumb lastDragThumb; + private OnRangeChangeListener onRangeChangeListener; + @Px private int thumbSizePixels; + @Px private int thumbTouchRadius; + @Px private int cursorPixels; + @ColorInt private int cursorColor; + @ColorInt private int thumbColor; + @ColorInt private int thumbColorEdited; + private long actualPosition; + private long dragPosition; + @Px private int thumbHintTextSize; + @ColorInt private int thumbHintTextColor; + @ColorInt private int thumbHintBackgroundColor; + private long dragStartTimeMs; + private long dragEndTimeMs; + private long maximumSelectableRangeMicros; + private Quality outputQuality; + private long qualityAvailableTimeMs; + + public VideoThumbnailsRangeSelectorView(final Context context) { + super(context); + init(null); + } + + public VideoThumbnailsRangeSelectorView(final Context context, final @Nullable AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public VideoThumbnailsRangeSelectorView(final Context context, final @Nullable AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(final @Nullable AttributeSet attrs) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.VideoThumbnailsRangeSelectorView, 0, 0); + try { + thumbSizePixels = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbWidth, 1); + cursorPixels = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_cursorWidth, 1); + thumbColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbColor, 0xffff0000); + thumbColorEdited = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbColorEdited, thumbColor); + cursorColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_cursorColor, thumbColor); + thumbTouchRadius = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbTouchRadius, 50); + thumbHintTextSize = typedArray.getDimensionPixelSize(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextSize, 0); + thumbHintTextColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintTextColor, 0xffff0000); + thumbHintBackgroundColor = typedArray.getColor(R.styleable.VideoThumbnailsRangeSelectorView_thumbHintBackgroundColor, 0xff00ff00); + } finally { + typedArray.recycle(); + } + } + + chevronLeft = VectorDrawableCompat.create(getResources(), R.drawable.ic_chevron_left_black_8dp, null); + chevronRight = VectorDrawableCompat.create(getResources(), R.drawable.ic_chevron_right_black_8dp, null); + + paintGrey.setColor(0x7f000000); + paintGrey.setStyle(Paint.Style.FILL_AND_STROKE); + paintGrey.setStrokeWidth(1); + + paint.setStrokeWidth(2); + + thumbTimeTextPaint.setTextSize(thumbHintTextSize); + thumbTimeTextPaint.setColor(thumbHintTextColor); + + thumbTimeBackgroundPaint.setStyle(Paint.Style.FILL_AND_STROKE); + thumbTimeBackgroundPaint.setColor(thumbHintBackgroundColor); + } + + @Override + protected void afterDurationChange(long duration) { + super.afterDurationChange(duration); + + if (maxValue != null && duration < maxValue) { + maxValue = duration; + } + + if (minValue != null && duration < minValue) { + minValue = duration; + } + + if (duration > 0) { + if (externalMinValue != null) { + setMinMax(externalMinValue, getMaxValue(), Thumb.MIN); + externalMinValue = null; + } + + if (externalMaxValue != null) { + setMinMax(getMinValue(), externalMaxValue, Thumb.MAX); + externalMaxValue = null; + } + } + + if (setMinValue(getMinValue())) { + Log.d(TAG, "Clamped video duration to " + getMaxValue()); + if (onRangeChangeListener != null) { + onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), Thumb.MAX); + } + } + + if (onRangeChangeListener != null) { + onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), Thumb.MIN); + setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); + } + + invalidate(); + } + + public void setOnRangeChangeListener(OnRangeChangeListener onRangeChangeListener) { + this.onRangeChangeListener = onRangeChangeListener; + } + + public void setActualPosition(long position) { + if (this.actualPosition != position) { + this.actualPosition = position; + invalidate(); + } + } + + private void setDragPosition(long position) { + if (this.dragPosition != position) { + this.dragPosition = Math.max(getMinValue(), Math.min(getMaxValue(), position)); + invalidate(); + } + } + + @Override + protected void onDraw(final Canvas canvas) { + if (thumbHintTextSize > 0) { + thumbTimeTextPaint.getTextBounds("0", 0, "0".length(), tempDrawRect); + canvas.translate(0, tempDrawRect.height()); + } + + super.onDraw(canvas); + + canvas.translate(getPaddingLeft(), getPaddingTop()); + int drawableWidth = getDrawableWidth(); + int drawableHeight = getDrawableHeight(); + + long duration = getDuration(); + + long min = getMinValue(); + long max = getMaxValue(); + + boolean edited = min != 0 || max != duration; + + long drawPosAt = dragThumb == Thumb.POSITION ? dragPosition : actualPosition; + + left = duration != 0 ? (int) ((min * drawableWidth) / duration) : 0; + right = duration != 0 ? (int) ((max * drawableWidth) / duration) : drawableWidth; + cursor = duration != 0 ? (int) ((drawPosAt * drawableWidth) / duration) : drawableWidth; + + // draw greyed out areas + tempDrawRect.set(0, 0, left - 1, drawableHeight); + canvas.drawRect(tempDrawRect, paintGrey); + tempDrawRect.set(right + 1, 0, drawableWidth, drawableHeight); + canvas.drawRect(tempDrawRect, paintGrey); + + // draw area rectangle + paint.setStyle(Paint.Style.STROKE); + tempDrawRect.set(left, 0, right, drawableHeight); + paint.setColor(edited ? thumbColorEdited : thumbColor); + canvas.drawRect(tempDrawRect, paint); + + // draw thumb rectangles + paint.setStyle(Paint.Style.FILL_AND_STROKE); + tempDrawRect.set(left, 0, left + thumbSizePixels, drawableHeight); + canvas.drawRect(tempDrawRect, paint); + tempDrawRect.set(right - thumbSizePixels, 0, right, drawableHeight); + canvas.drawRect(tempDrawRect, paint); + + int arrowSize = Math.min(drawableHeight, thumbSizePixels * 2); + chevronLeft .setBounds(0, 0, arrowSize, arrowSize); + chevronRight.setBounds(0, 0, arrowSize, arrowSize); + + float dy = (drawableHeight - arrowSize) / 2f; + float arrowPaddingX = (thumbSizePixels - arrowSize) / 2f; + + // draw left thumb chevron + canvas.save(); + canvas.translate(left + arrowPaddingX, dy); + chevronLeft.draw(canvas); + canvas.restore(); + + // draw right thumb chevron + canvas.save(); + canvas.translate(right - thumbSizePixels + arrowPaddingX, dy); + chevronRight.draw(canvas); + canvas.restore(); + + // draw time hint pill + if (thumbHintTextSize > 0) { + if (dragStartTimeMs > 0 && (dragThumb == Thumb.MIN || dragThumb == Thumb.MAX)) { + drawTimeHint(canvas, drawableWidth, drawableHeight, dragThumb, false); + } + if (dragEndTimeMs > 0 && (lastDragThumb == Thumb.MIN || lastDragThumb == Thumb.MAX)) { + drawTimeHint(canvas, drawableWidth, drawableHeight, lastDragThumb, true); + } + + drawDurationAndSizeHint(canvas, drawableWidth); + } + + // draw current position marker + if (left <= cursor && cursor <= right && dragThumb != Thumb.MIN && dragThumb != Thumb.MAX) { + canvas.translate(cursorPixels / 2, 0); + tempDrawRect.set(cursor, 0, cursor + cursorPixels, drawableHeight); + paint.setColor(cursorColor); + canvas.drawRect(tempDrawRect, paint); + } + } + + private void drawTimeHint(Canvas canvas, int drawableWidth, int drawableHeight, Thumb dragThumb, boolean fadeOut) { + canvas.save(); + long microsecondValue = dragThumb == Thumb.MIN ? getMinValue() : getMaxValue(); + long seconds = TimeUnit.MICROSECONDS.toSeconds(microsecondValue); + String timeString = String.format(Locale.getDefault(), "%d:%02d", seconds / 60, seconds % 60); + float topBottomPadding = thumbHintTextSize * 0.5f; + float leftRightPadding = thumbHintTextSize * 0.75f; + + thumbTimeTextPaint.getTextBounds(timeString, 0, timeString.length(), tempDrawRect); + + timePillRect.set(tempDrawRect.left - leftRightPadding, tempDrawRect.top - topBottomPadding, tempDrawRect.right + leftRightPadding, tempDrawRect.bottom + topBottomPadding); + + float halfPillWidth = timePillRect.width() / 2f; + float halfPillHeight = timePillRect.height() / 2f; + + long animationTime = fadeOut ? ANIMATION_DURATION_MS - Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - dragEndTimeMs) + : Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - dragStartTimeMs); + float animationPosition = animationTime / (float) ANIMATION_DURATION_MS; + float scaleIn = 0.2f * animationPosition + 0.8f; + int alpha = (int) (255 * animationPosition); + + if (dragThumb == Thumb.MAX) { + canvas.translate(Math.min(right, drawableWidth - halfPillWidth), 0); + } else { + canvas.translate(Math.max(left, halfPillWidth), 0); + } + canvas.translate(0, drawableHeight + halfPillHeight); + canvas.scale(scaleIn, scaleIn); + thumbTimeBackgroundPaint.setAlpha(alpha); + thumbTimeTextPaint.setAlpha(alpha); + canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight); + canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint); + canvas.drawText(timeString, 0, 0, thumbTimeTextPaint); + canvas.restore(); + + if (fadeOut && animationTime > 0 || !fadeOut && animationTime < ANIMATION_DURATION_MS) { + invalidate(); + } else { + if (fadeOut) { + lastDragThumb = null; + } + } + } + + private void drawDurationAndSizeHint(Canvas canvas, int drawableWidth) { + if (outputQuality == null) return; + + canvas.save(); + long microsecondValue = getMaxValue() - getMinValue(); + long seconds = TimeUnit.MICROSECONDS.toSeconds(microsecondValue); + String durationAndSize = String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, MemoryUnitFormat.formatBytes(outputQuality.fileSize, MemoryUnitFormat.MEGA_BYTES, true)); + float topBottomPadding = thumbHintTextSize * 0.5f; + float leftRightPadding = thumbHintTextSize * 0.75f; + + thumbTimeTextPaint.getTextBounds(durationAndSize, 0, durationAndSize.length(), tempDrawRect); + + timePillRect.set(tempDrawRect.left - leftRightPadding, tempDrawRect.top - topBottomPadding, tempDrawRect.right + leftRightPadding, tempDrawRect.bottom + topBottomPadding); + + float halfPillWidth = timePillRect.width() / 2f; + float halfPillHeight = timePillRect.height() / 2f; + + long animationTime = Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - qualityAvailableTimeMs); + float animationPosition = animationTime / (float) ANIMATION_DURATION_MS; + float scaleIn = 0.2f * animationPosition + 0.8f; + int alpha = (int) (255 * animationPosition); + + canvas.translate(Math.max(halfPillWidth, Math.min((right + left) / 2f, drawableWidth - halfPillWidth)), - 2 * halfPillHeight); + canvas.scale(scaleIn, scaleIn); + thumbTimeBackgroundPaint.setAlpha(alpha); + thumbTimeTextPaint.setAlpha(alpha); + canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight); + canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint); + canvas.drawText(durationAndSize, 0, 0, thumbTimeTextPaint); + canvas.restore(); + + if (animationTime < ANIMATION_DURATION_MS) { + invalidate(); + } + } + + public long getMinValue() { + return minValue == null ? 0 : minValue; + } + + public long getMaxValue() { + return maxValue == null ? getDuration() : maxValue; + } + + public long getClipDuration() { + return getMaxValue() - getMinValue(); + } + + private boolean setMinValue(long minValue) { + if (this.minValue == null || this.minValue != minValue) { + return setMinMax(minValue, getMaxValue(), Thumb.MIN); + } else{ + return false; + } + } + + public boolean setMaxValue(long maxValue) { + if (this.maxValue == null || this.maxValue != maxValue) { + return setMinMax(getMinValue(), maxValue, Thumb.MAX); + } else{ + return false; + } + } + + private boolean setMinMax(long newMin, long newMax, Thumb thumb) { + final long currentMin = getMinValue(); + final long currentMax = getMaxValue(); + final long duration = getDuration(); + + final long minDiff = Math.max(MINIMUM_SELECTABLE_RANGE, pixelToDuration(thumbSizePixels * 2.5f)); + final long maxDiff = maximumSelectableRangeMicros <= MINIMUM_SELECTABLE_RANGE ? 0 : Math.max(maximumSelectableRangeMicros, pixelToDuration(thumbSizePixels * 2.5f)); + + if (thumb == Thumb.MIN) { + newMin = clamp(newMin, 0, currentMax - minDiff); + if (maxDiff > 0) { + newMax = clamp(newMax, newMin + minDiff, Math.min(newMin + maxDiff, duration)); + } + } else { + newMax = clamp(newMax, currentMin + minDiff, duration); + if (maxDiff > 0) { + newMin = clamp(newMin, Math.max(0, newMax - maxDiff), newMax - minDiff); + } + } + + if (newMin != currentMin || newMax != currentMax) { + this.minValue = newMin; + this.maxValue = newMax; + invalidate(); + return true; + } + return false; + } + + private static long clamp(long value, long min, long max) { + return Math.min(Math.max(min, value), max); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + int actionMasked = event.getActionMasked(); + if (actionMasked == MotionEvent.ACTION_DOWN) { + xDown = event.getX(); + downCursor = actualPosition; + downMin = getMinValue(); + downMax = getMaxValue(); + dragThumb = closestThumb(event.getX()); + dragStartTimeMs = System.currentTimeMillis(); + invalidate(); + return dragThumb != null; + } + + if (actionMasked == MotionEvent.ACTION_MOVE) { + boolean changed = false; + long delta = pixelToDuration(event.getX() - xDown); + switch (dragThumb) { + case POSITION: + setDragPosition(downCursor + delta); + changed = true; + break; + case MIN: + changed = setMinValue(downMin + delta); + break; + case MAX: + changed = setMaxValue(downMax + delta); + break; + } + if (changed && onRangeChangeListener != null) { + if (dragThumb == Thumb.POSITION) { + onRangeChangeListener.onPositionDrag(dragPosition); + } else { + onRangeChangeListener.onRangeDrag(getMinValue(), getMaxValue(), getDuration(), dragThumb); + setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); + } + } + return true; + } + + if (actionMasked == MotionEvent.ACTION_UP) { + if (onRangeChangeListener != null) { + if (dragThumb == Thumb.POSITION) { + onRangeChangeListener.onEndPositionDrag(dragPosition); + } else { + onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), dragThumb); + setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration())); + } + lastDragThumb = dragThumb; + dragEndTimeMs = System.currentTimeMillis(); + dragThumb = null; + invalidate(); + } + return true; + } + + if (actionMasked == MotionEvent.ACTION_CANCEL) { + dragThumb = null; + } + + return true; + } + + private void setOutputQuality(@Nullable Quality outputQuality) { + if (!Objects.equals(this.outputQuality, outputQuality)) { + if (this.outputQuality == null) { + qualityAvailableTimeMs = System.currentTimeMillis(); + } + this.outputQuality = outputQuality; + invalidate(); + } + } + + private @Nullable Thumb closestThumb(@Px float x) { + float midPoint = (right + left) / 2f; + Thumb possibleThumb = x < midPoint ? Thumb.MIN : Thumb.MAX; + int possibleThumbX = x < midPoint ? left : right; + + if (Math.abs(x - possibleThumbX) < thumbTouchRadius) { + return possibleThumb; + } + + return null; + } + + private long pixelToDuration(float pixel) { + return (long) (pixel / getDrawableWidth() * getDuration()); + } + + private int getDrawableWidth() { + return getWidth() - getPaddingLeft() - getPaddingRight(); + } + + private int getDrawableHeight() { + return getHeight() - getPaddingBottom() - getPaddingTop(); + } + + public void setRange(long minValue, long maxValue) { + if (getDuration() > 0) { + setMinMax(minValue, maxValue, Thumb.MIN); + setMinMax(minValue, maxValue, Thumb.MAX); + } else { + externalMinValue = minValue; + externalMaxValue = maxValue; + } + } + + public void setTimeLimit(int t, @NonNull TimeUnit timeUnit) { + maximumSelectableRangeMicros = timeUnit.toMicros(t); + } + + public enum Thumb { + MIN, + MAX, + POSITION + } + + public interface OnRangeChangeListener { + + void onPositionDrag(long position); + + void onEndPositionDrag(long position); + + void onRangeDrag(long minValue, long maxValue, long duration, Thumb thumb); + + void onRangeDragEnd(long minValue, long maxValue, long duration, Thumb thumb); + + @Nullable Quality getQuality(long clipDurationUs, long totalDurationUs); + } + + public static final class Quality { + private final long fileSize; + private final int qualityRange; + + public Quality(long fileSize, int qualityRange) { + this.fileSize = fileSize; + this.qualityRange = qualityRange; + } + + @Override public boolean equals(Object o) { + if (!(o instanceof Quality)) { + return false; + } + + final Quality quality = (Quality) o; + + return fileSize == quality.fileSize && + qualityRange == quality.qualityRange; + } + + @Override public int hashCode() { + int result = (int) (fileSize ^ (fileSize >>> 32)); + result = 31 * result + qualityRange; + return result; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java new file mode 100644 index 00000000..8913cf70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java @@ -0,0 +1,229 @@ +package org.thoughtcrime.securesms.video.videoconverter; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.AsyncTask; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; + +@RequiresApi(api = 23) +public class VideoThumbnailsView extends View { + + private static final String TAG = Log.tag(VideoThumbnailsView.class); + + private MediaInput input; + private ArrayList thumbnails; + private AsyncTask thumbnailsTask; + private OnDurationListener durationListener; + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF tempRect = new RectF(); + private final Rect drawRect = new Rect(); + private final Rect tempDrawRect = new Rect(); + private long duration = 0; + + public VideoThumbnailsView(final Context context) { + super(context); + } + + public VideoThumbnailsView(final Context context, final @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public VideoThumbnailsView(final Context context, final @Nullable AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setInput(@NonNull MediaInput input) { + this.input = input; + this.thumbnails = null; + if (thumbnailsTask != null) { + thumbnailsTask.cancel(true); + thumbnailsTask = null; + } + invalidate(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + thumbnails = null; + if (thumbnailsTask != null) { + thumbnailsTask.cancel(true); + thumbnailsTask = null; + } + + if (input != null) { + try { + input.close(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + + if (input == null) { + return; + } + + tempDrawRect.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), getHeight() - getPaddingBottom()); + + if (!drawRect.equals(tempDrawRect)) { + drawRect.set(tempDrawRect); + thumbnails = null; + if (thumbnailsTask != null) { + thumbnailsTask.cancel(true); + thumbnailsTask = null; + } + } + + if (thumbnails == null) { + if (thumbnailsTask == null) { + final int thumbnailCount = drawRect.width() / drawRect.height(); + final float thumbnailWidth = (float) drawRect.width() / thumbnailCount; + final float thumbnailHeight = drawRect.height(); + + thumbnails = new ArrayList<>(thumbnailCount); + thumbnailsTask = new ThumbnailsTask(this, input, thumbnailWidth, thumbnailHeight, thumbnailCount); + thumbnailsTask.execute(); + } + } else { + final int thumbnailCount = drawRect.width() / drawRect.height(); + final float thumbnailWidth = (float) drawRect.width() / thumbnailCount; + final float thumbnailHeight = drawRect.height(); + + tempRect.top = drawRect.top; + tempRect.bottom = drawRect.bottom; + + for (int i = 0; i < thumbnails.size(); i++) { + tempRect.left = drawRect.left + i * thumbnailWidth; + tempRect.right = tempRect.left + thumbnailWidth; + + final Bitmap thumbnailBitmap = thumbnails.get(i); + if (thumbnailBitmap != null) { + canvas.save(); + canvas.rotate(180, tempRect.centerX(), tempRect.centerY()); + tempDrawRect.set(0, 0, thumbnailBitmap.getWidth(), thumbnailBitmap.getHeight()); + if (tempDrawRect.width() * thumbnailHeight > tempDrawRect.height() * thumbnailWidth) { + float w = tempDrawRect.height() * thumbnailWidth / thumbnailHeight; + tempDrawRect.left = tempDrawRect.centerX() - (int) (w / 2); + tempDrawRect.right = tempDrawRect.left + (int) w; + } else { + float h = tempDrawRect.width() * thumbnailHeight / thumbnailWidth; + tempDrawRect.top = tempDrawRect.centerY() - (int) (h / 2); + tempDrawRect.bottom = tempDrawRect.top + (int) h; + } + canvas.drawBitmap(thumbnailBitmap, tempDrawRect, tempRect, paint); + canvas.restore(); + } + } + } + } + + public void setDurationListener(OnDurationListener durationListener) { + this.durationListener = durationListener; + } + + private void setDuration(long duration) { + if (durationListener != null) { + durationListener.onDurationKnown(duration); + } + if (this.duration != duration) { + this.duration = duration; + afterDurationChange(duration); + } + } + + protected void afterDurationChange(long duration) { + } + + protected long getDuration() { + return duration; + } + + private static class ThumbnailsTask extends AsyncTask { + + final WeakReference viewReference; + final MediaInput input; + final float thumbnailWidth; + final float thumbnailHeight; + final int thumbnailCount; + long duration; + + ThumbnailsTask(final @NonNull VideoThumbnailsView view, final @NonNull MediaInput input, final float thumbnailWidth, final float thumbnailHeight, final int thumbnailCount) { + this.viewReference = new WeakReference<>(view); + this.input = input; + this.thumbnailWidth = thumbnailWidth; + this.thumbnailHeight = thumbnailHeight; + this.thumbnailCount = thumbnailCount; + } + + @Override + protected Void doInBackground(Void... params) { + Log.i(TAG, "generate " + thumbnailCount + " thumbnails " + thumbnailWidth + "x" + thumbnailHeight); + VideoThumbnailsExtractor.extractThumbnails(input, thumbnailCount, (int) thumbnailHeight, new VideoThumbnailsExtractor.Callback() { + + @Override + public void durationKnown(long duration) { + ThumbnailsTask.this.duration = duration; + } + + @Override + public boolean publishProgress(int index, Bitmap thumbnail) { + ThumbnailsTask.this.publishProgress(thumbnail); + return !isCancelled(); + } + + @Override + public void failed() { + Log.w(TAG, "Thumbnail extraction failed"); + } + }); + return null; + } + + @Override + protected void onProgressUpdate(Bitmap... values) { + final VideoThumbnailsView view = viewReference.get(); + if (view != null) { + view.thumbnails.addAll(Arrays.asList(values)); + view.invalidate(); + } + } + + @Override + protected void onPostExecute(Void result) { + final VideoThumbnailsView view = viewReference.get(); + if (view != null) { + view.setDuration(duration); + view.invalidate(); + Log.i(TAG, "onPostExecute, we have " + view.thumbnails.size() + " thumbs"); + } + } + } + + public interface OnDurationListener { + void onDurationKnown(long duration); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java new file mode 100644 index 00000000..3c404d81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java @@ -0,0 +1,512 @@ +package org.thoughtcrime.securesms.video.videoconverter; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.view.Surface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.media.MediaInput; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; + +final class VideoTrackConverter { + + private static final String TAG = "media-converter"; + private static final boolean VERBOSE = false; // lots of logging + + private static final int OUTPUT_VIDEO_IFRAME_INTERVAL = 1; // 1 second between I-frames + private static final int OUTPUT_VIDEO_FRAME_RATE = 30; // needed only for MediaFormat.KEY_I_FRAME_INTERVAL to work; the actual frame rate matches the source + + private static final int TIMEOUT_USEC = 10000; + + private static final String MEDIA_FORMAT_KEY_DISPLAY_WIDTH = "display-width"; + private static final String MEDIA_FORMAT_KEY_DISPLAY_HEIGHT = "display-height"; + + private final long mTimeFrom; + private final long mTimeTo; + + final long mInputDuration; + + private final MediaExtractor mVideoExtractor; + private final MediaCodec mVideoDecoder; + private final MediaCodec mVideoEncoder; + + private final InputSurface mInputSurface; + private final OutputSurface mOutputSurface; + + private final ByteBuffer[] mVideoDecoderInputBuffers; + private ByteBuffer[] mVideoEncoderOutputBuffers; + private final MediaCodec.BufferInfo mVideoDecoderOutputBufferInfo; + private final MediaCodec.BufferInfo mVideoEncoderOutputBufferInfo; + + MediaFormat mEncoderOutputVideoFormat; + + boolean mVideoExtractorDone; + private boolean mVideoDecoderDone; + boolean mVideoEncoderDone; + + private int mOutputVideoTrack = -1; + + long mMuxingVideoPresentationTime; + + private int mVideoExtractedFrameCount; + private int mVideoDecodedFrameCount; + private int mVideoEncodedFrameCount; + + private Muxer mMuxer; + + @RequiresApi(23) + static @Nullable VideoTrackConverter create( + final @NonNull MediaInput input, + final long timeFrom, + final long timeTo, + final int videoResolution, + final int videoBitrate, + final @NonNull String videoCodec) throws IOException, TranscodingException { + + final MediaExtractor videoExtractor = input.createExtractor(); + final int videoInputTrack = getAndSelectVideoTrackIndex(videoExtractor); + if (videoInputTrack == -1) { + videoExtractor.release(); + return null; + } + return new VideoTrackConverter(videoExtractor, videoInputTrack, timeFrom, timeTo, videoResolution, videoBitrate, videoCodec); + } + + + @RequiresApi(23) + private VideoTrackConverter( + final @NonNull MediaExtractor videoExtractor, + final int videoInputTrack, + final long timeFrom, + final long timeTo, + final int videoResolution, + final int videoBitrate, + final @NonNull String videoCodec) throws IOException, TranscodingException { + + mTimeFrom = timeFrom; + mTimeTo = timeTo; + mVideoExtractor = videoExtractor; + + final MediaCodecInfo videoCodecInfo = MediaConverter.selectCodec(videoCodec); + if (videoCodecInfo == null) { + // Don't fail CTS if they don't have an AVC codec (not here, anyway). + Log.e(TAG, "Unable to find an appropriate codec for " + videoCodec); + throw new FileNotFoundException(); + } + if (VERBOSE) Log.d(TAG, "video found codec: " + videoCodecInfo.getName()); + + final MediaFormat inputVideoFormat = mVideoExtractor.getTrackFormat(videoInputTrack); + + mInputDuration = inputVideoFormat.containsKey(MediaFormat.KEY_DURATION) ? inputVideoFormat.getLong(MediaFormat.KEY_DURATION) : 0; + + final int rotation = inputVideoFormat.containsKey(MediaFormat.KEY_ROTATION) ? inputVideoFormat.getInteger(MediaFormat.KEY_ROTATION) : 0; + final int width = inputVideoFormat.containsKey(MEDIA_FORMAT_KEY_DISPLAY_WIDTH) + ? inputVideoFormat.getInteger(MEDIA_FORMAT_KEY_DISPLAY_WIDTH) + : inputVideoFormat.getInteger(MediaFormat.KEY_WIDTH); + final int height = inputVideoFormat.containsKey(MEDIA_FORMAT_KEY_DISPLAY_HEIGHT) + ? inputVideoFormat.getInteger(MEDIA_FORMAT_KEY_DISPLAY_HEIGHT) + : inputVideoFormat.getInteger(MediaFormat.KEY_HEIGHT); + int outputWidth = width; + int outputHeight = height; + if (outputWidth < outputHeight) { + outputWidth = videoResolution; + outputHeight = height * outputWidth / width; + } else { + outputHeight = videoResolution; + outputWidth = width * outputHeight / height; + } + // many encoders do not work when height and width are not multiple of 16 (also, some iPhones do not play some heights) + outputHeight = (outputHeight + 7) & ~0xF; + outputWidth = (outputWidth + 7) & ~0xF; + + final int outputWidthRotated; + final int outputHeightRotated; + if ((rotation % 180 == 90)) { + //noinspection SuspiciousNameCombination + outputWidthRotated = outputHeight; + //noinspection SuspiciousNameCombination + outputHeightRotated = outputWidth; + } else { + outputWidthRotated = outputWidth; + outputHeightRotated = outputHeight; + } + + final MediaFormat outputVideoFormat = MediaFormat.createVideoFormat(videoCodec, outputWidthRotated, outputHeightRotated); + + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + outputVideoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + outputVideoFormat.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate); + outputVideoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, OUTPUT_VIDEO_FRAME_RATE); + outputVideoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, OUTPUT_VIDEO_IFRAME_INTERVAL); + if (VERBOSE) Log.d(TAG, "video format: " + outputVideoFormat); + + // Create a MediaCodec for the desired codec, then configure it as an encoder with + // our desired properties. Request a Surface to use for input. + final AtomicReference inputSurfaceReference = new AtomicReference<>(); + mVideoEncoder = createVideoEncoder(videoCodecInfo, outputVideoFormat, inputSurfaceReference); + mInputSurface = new InputSurface(inputSurfaceReference.get()); + mInputSurface.makeCurrent(); + // Create a MediaCodec for the decoder, based on the extractor's format. + mOutputSurface = new OutputSurface(); + + mOutputSurface.changeFragmentShader(createFragmentShader( + inputVideoFormat.getInteger(MediaFormat.KEY_WIDTH), inputVideoFormat.getInteger(MediaFormat.KEY_HEIGHT), + outputWidth, outputHeight)); + + mVideoDecoder = createVideoDecoder(inputVideoFormat, mOutputSurface.getSurface()); + + mVideoDecoderInputBuffers = mVideoDecoder.getInputBuffers(); + mVideoEncoderOutputBuffers = mVideoEncoder.getOutputBuffers(); + mVideoDecoderOutputBufferInfo = new MediaCodec.BufferInfo(); + mVideoEncoderOutputBufferInfo = new MediaCodec.BufferInfo(); + + if (mTimeFrom > 0) { + mVideoExtractor.seekTo(mTimeFrom * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + Log.i(TAG, "Seek video:" + mTimeFrom + " " + mVideoExtractor.getSampleTime()); + } + } + + void setMuxer(final @NonNull Muxer muxer) throws IOException { + mMuxer = muxer; + if (mEncoderOutputVideoFormat != null) { + Log.d(TAG, "muxer: adding video track."); + mOutputVideoTrack = muxer.addTrack(mEncoderOutputVideoFormat); + } + } + + void step() throws IOException, TranscodingException { + // Extract video from file and feed to decoder. + // Do not extract video if we have determined the output format but we are not yet + // ready to mux the frames. + while (!mVideoExtractorDone + && (mEncoderOutputVideoFormat == null || mMuxer != null)) { + int decoderInputBufferIndex = mVideoDecoder.dequeueInputBuffer(TIMEOUT_USEC); + if (decoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no video decoder input buffer"); + break; + } + if (VERBOSE) { + Log.d(TAG, "video decoder: returned input buffer: " + decoderInputBufferIndex); + } + final ByteBuffer decoderInputBuffer = mVideoDecoderInputBuffers[decoderInputBufferIndex]; + final int size = mVideoExtractor.readSampleData(decoderInputBuffer, 0); + final long presentationTime = mVideoExtractor.getSampleTime(); + if (VERBOSE) { + Log.d(TAG, "video extractor: returned buffer of size " + size); + Log.d(TAG, "video extractor: returned buffer for time " + presentationTime); + } + mVideoExtractorDone = size < 0 || (mTimeTo > 0 && presentationTime > mTimeTo * 1000); + + if (mVideoExtractorDone) { + if (VERBOSE) Log.d(TAG, "video extractor: EOS"); + mVideoDecoder.queueInputBuffer( + decoderInputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } else { + mVideoDecoder.queueInputBuffer( + decoderInputBufferIndex, + 0, + size, + presentationTime, + mVideoExtractor.getSampleFlags()); + } + mVideoExtractor.advance(); + mVideoExtractedFrameCount++; + // We extracted a frame, let's try something else next. + break; + } + + // Poll output frames from the video decoder and feed the encoder. + while (!mVideoDecoderDone && (mEncoderOutputVideoFormat == null || mMuxer != null)) { + final int decoderOutputBufferIndex = + mVideoDecoder.dequeueOutputBuffer( + mVideoDecoderOutputBufferInfo, TIMEOUT_USEC); + if (decoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no video decoder output buffer"); + break; + } + if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + if (VERBOSE) Log.d(TAG, "video decoder: output buffers changed"); + break; + } + if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (VERBOSE) { + Log.d(TAG, "video decoder: output format changed: " + mVideoDecoder.getOutputFormat()); + } + break; + } + if (VERBOSE) { + Log.d(TAG, "video decoder: returned output buffer: " + + decoderOutputBufferIndex); + Log.d(TAG, "video decoder: returned buffer of size " + + mVideoDecoderOutputBufferInfo.size); + } + if ((mVideoDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (VERBOSE) Log.d(TAG, "video decoder: codec config buffer"); + mVideoDecoder.releaseOutputBuffer(decoderOutputBufferIndex, false); + break; + } + if (mVideoDecoderOutputBufferInfo.presentationTimeUs < mTimeFrom * 1000 && + (mVideoDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) == 0) { + if (VERBOSE) Log.d(TAG, "video decoder: frame prior to " + mVideoDecoderOutputBufferInfo.presentationTimeUs); + mVideoDecoder.releaseOutputBuffer(decoderOutputBufferIndex, false); + break; + } + if (VERBOSE) { + Log.d(TAG, "video decoder: returned buffer for time " + mVideoDecoderOutputBufferInfo.presentationTimeUs); + } + boolean render = mVideoDecoderOutputBufferInfo.size != 0; + mVideoDecoder.releaseOutputBuffer(decoderOutputBufferIndex, render); + if (render) { + if (VERBOSE) Log.d(TAG, "output surface: await new image"); + mOutputSurface.awaitNewImage(); + // Edit the frame and send it to the encoder. + if (VERBOSE) Log.d(TAG, "output surface: draw image"); + mOutputSurface.drawImage(); + mInputSurface.setPresentationTime(mVideoDecoderOutputBufferInfo.presentationTimeUs * 1000); + if (VERBOSE) Log.d(TAG, "input surface: swap buffers"); + mInputSurface.swapBuffers(); + if (VERBOSE) Log.d(TAG, "video encoder: notified of new frame"); + } + if ((mVideoDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + if (VERBOSE) Log.d(TAG, "video decoder: EOS"); + mVideoDecoderDone = true; + mVideoEncoder.signalEndOfInputStream(); + } + mVideoDecodedFrameCount++; + // We extracted a pending frame, let's try something else next. + break; + } + + // Poll frames from the video encoder and send them to the muxer. + while (!mVideoEncoderDone && (mEncoderOutputVideoFormat == null || mMuxer != null)) { + final int encoderOutputBufferIndex = mVideoEncoder.dequeueOutputBuffer(mVideoEncoderOutputBufferInfo, TIMEOUT_USEC); + if (encoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (VERBOSE) Log.d(TAG, "no video encoder output buffer"); + if (mVideoDecoderDone) { + // on some devices and encoder stops after signalEndOfInputStream + Log.w(TAG, "mVideoDecoderDone, but didn't get BUFFER_FLAG_END_OF_STREAM"); + mVideoEncodedFrameCount = mVideoDecodedFrameCount; + mVideoEncoderDone = true; + } + break; + } + if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + if (VERBOSE) Log.d(TAG, "video encoder: output buffers changed"); + mVideoEncoderOutputBuffers = mVideoEncoder.getOutputBuffers(); + break; + } + if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (VERBOSE) Log.d(TAG, "video encoder: output format changed"); + Preconditions.checkState("video encoder changed its output format again?", mOutputVideoTrack < 0); + mEncoderOutputVideoFormat = mVideoEncoder.getOutputFormat(); + break; + } + Preconditions.checkState("should have added track before processing output", mMuxer != null); + if (VERBOSE) { + Log.d(TAG, "video encoder: returned output buffer: " + encoderOutputBufferIndex); + Log.d(TAG, "video encoder: returned buffer of size " + mVideoEncoderOutputBufferInfo.size); + } + final ByteBuffer encoderOutputBuffer = mVideoEncoderOutputBuffers[encoderOutputBufferIndex]; + if ((mVideoEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (VERBOSE) Log.d(TAG, "video encoder: codec config buffer"); + // Simply ignore codec config buffers. + mVideoEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false); + break; + } + if (VERBOSE) { + Log.d(TAG, "video encoder: returned buffer for time " + mVideoEncoderOutputBufferInfo.presentationTimeUs); + } + if (mVideoEncoderOutputBufferInfo.size != 0) { + mMuxer.writeSampleData(mOutputVideoTrack, encoderOutputBuffer, mVideoEncoderOutputBufferInfo); + mMuxingVideoPresentationTime = Math.max(mMuxingVideoPresentationTime, mVideoEncoderOutputBufferInfo.presentationTimeUs); + } + if ((mVideoEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + if (VERBOSE) Log.d(TAG, "video encoder: EOS"); + mVideoEncoderDone = true; + } + mVideoEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false); + mVideoEncodedFrameCount++; + // We enqueued an encoded frame, let's try something else next. + break; + } + } + + void release() throws Exception { + Exception exception = null; + try { + if (mVideoExtractor != null) { + mVideoExtractor.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mVideoExtractor", e); + exception = e; + } + try { + if (mVideoDecoder != null) { + mVideoDecoder.stop(); + mVideoDecoder.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mVideoDecoder", e); + if (exception == null) { + exception = e; + } + } + try { + if (mOutputSurface != null) { + mOutputSurface.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mOutputSurface", e); + if (exception == null) { + exception = e; + } + } + try { + if (mInputSurface != null) { + mInputSurface.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mInputSurface", e); + if (exception == null) { + exception = e; + } + } + try { + if (mVideoEncoder != null) { + mVideoEncoder.stop(); + mVideoEncoder.release(); + } + } catch (Exception e) { + Log.e(TAG, "error while releasing mVideoEncoder", e); + if (exception == null) { + exception = e; + } + } + if (exception != null) { + throw exception; + } + } + + String dumpState() { + return String.format(Locale.US, + "V{" + + "extracted:%d(done:%b) " + + "decoded:%d(done:%b) " + + "encoded:%d(done:%b) " + + "muxing:%b(track:%d)} ", + mVideoExtractedFrameCount, mVideoExtractorDone, + mVideoDecodedFrameCount, mVideoDecoderDone, + mVideoEncodedFrameCount, mVideoEncoderDone, + mMuxer != null, mOutputVideoTrack); + } + + void verifyEndState() { + Preconditions.checkState("encoded (" + mVideoEncodedFrameCount + ") and decoded (" + mVideoDecodedFrameCount + ") video frame counts should match", mVideoDecodedFrameCount == mVideoEncodedFrameCount); + Preconditions.checkState("decoded frame count should be less than extracted frame count", mVideoDecodedFrameCount <= mVideoExtractedFrameCount); + } + + private static String createFragmentShader( + final int srcWidth, + final int srcHeight, + final int dstWidth, + final int dstHeight) { + final float kernelSizeX = (float) srcWidth / (float) dstWidth; + final float kernelSizeY = (float) srcHeight / (float) dstHeight; + Log.i(TAG, "kernel " + kernelSizeX + "x" + kernelSizeY); + final String shader; + if (kernelSizeX <= 2 && kernelSizeY <= 2) { + shader = + "#extension GL_OES_EGL_image_external : require\n" + + "precision mediump float;\n" + // highp here doesn't seem to matter + "varying vec2 vTextureCoord;\n" + + "uniform samplerExternalOES sTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(sTexture, vTextureCoord);\n" + + "}\n"; + } else { + final int kernelRadiusX = (int) Math.ceil(kernelSizeX - .1f) / 2; + final int kernelRadiusY = (int) Math.ceil(kernelSizeY - .1f) / 2; + final float stepX = kernelSizeX / (1 + 2 * kernelRadiusX) * (1f / srcWidth); + final float stepY = kernelSizeY / (1 + 2 * kernelRadiusY) * (1f / srcHeight); + final float sum = (1 + 2 * kernelRadiusX) * (1 + 2 * kernelRadiusY); + final StringBuilder colorLoop = new StringBuilder(); + for (int i = -kernelRadiusX; i <=kernelRadiusX; i++) { + for (int j = -kernelRadiusY; j <=kernelRadiusY; j++) { + if (i != 0 || j != 0) { + colorLoop.append(" + texture2D(sTexture, vTextureCoord.xy + vec2(") + .append(i * stepX).append(", ").append(j * stepY).append("))\n"); + } + } + } + shader = + "#extension GL_OES_EGL_image_external : require\n" + + "precision mediump float;\n" + // highp here doesn't seem to matter + "varying vec2 vTextureCoord;\n" + + "uniform samplerExternalOES sTexture;\n" + + "void main() {\n" + + " gl_FragColor = (texture2D(sTexture, vTextureCoord)\n" + + colorLoop.toString() + + " ) / " + sum + ";\n" + + "}\n"; + } + Log.i(TAG, shader); + return shader; + } + + private @NonNull + MediaCodec createVideoDecoder( + final @NonNull MediaFormat inputFormat, + final @NonNull Surface surface) throws IOException { + final MediaCodec decoder = MediaCodec.createDecoderByType(MediaConverter.getMimeTypeFor(inputFormat)); + decoder.configure(inputFormat, surface, null, 0); + decoder.start(); + return decoder; + } + + private @NonNull + MediaCodec createVideoEncoder( + final @NonNull MediaCodecInfo codecInfo, + final @NonNull MediaFormat format, + final @NonNull AtomicReference surfaceReference) throws IOException { + final MediaCodec encoder = MediaCodec.createByCodecName(codecInfo.getName()); + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + // Must be called before start() + surfaceReference.set(encoder.createInputSurface()); + encoder.start(); + return encoder; + } + + private static int getAndSelectVideoTrackIndex(@NonNull MediaExtractor extractor) { + for (int index = 0; index < extractor.getTrackCount(); ++index) { + if (VERBOSE) { + Log.d(TAG, "format for track " + index + " is " + MediaConverter.getMimeTypeFor(extractor.getTrackFormat(index))); + } + if (isVideoFormat(extractor.getTrackFormat(index))) { + extractor.selectTrack(index); + return index; + } + } + return -1; + } + + private static boolean isVideoFormat(final @NonNull MediaFormat format) { + return MediaConverter.getMimeTypeFor(format).startsWith("video/"); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java new file mode 100644 index 00000000..914d4dd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.os.Parcelable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + +import java.util.Arrays; +import java.util.List; + +public interface ChatWallpaper extends Parcelable { + + float FIXED_DIM_LEVEL_FOR_DARK_THEME = 0.2f; + + List BUILTINS = Arrays.asList(SingleColorChatWallpaper.SOLID_1, + SingleColorChatWallpaper.SOLID_2, + SingleColorChatWallpaper.SOLID_3, + SingleColorChatWallpaper.SOLID_4, + SingleColorChatWallpaper.SOLID_5, + SingleColorChatWallpaper.SOLID_6, + SingleColorChatWallpaper.SOLID_7, + SingleColorChatWallpaper.SOLID_8, + SingleColorChatWallpaper.SOLID_9, + SingleColorChatWallpaper.SOLID_10, + SingleColorChatWallpaper.SOLID_11, + SingleColorChatWallpaper.SOLID_12, + GradientChatWallpaper.GRADIENT_1, + GradientChatWallpaper.GRADIENT_2, + GradientChatWallpaper.GRADIENT_3, + GradientChatWallpaper.GRADIENT_4, + GradientChatWallpaper.GRADIENT_5, + GradientChatWallpaper.GRADIENT_6, + GradientChatWallpaper.GRADIENT_7, + GradientChatWallpaper.GRADIENT_8, + GradientChatWallpaper.GRADIENT_9); + + float getDimLevelForDarkTheme(); + + void loadInto(@NonNull ImageView imageView); + + @NonNull Wallpaper serialize(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java new file mode 100644 index 00000000..b32bfe57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ActivityTransitionUtil; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public final class ChatWallpaperActivity extends PassphraseRequiredActivity { + + private static final String EXTRA_RECIPIENT_ID = "extra.recipient.id"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static @NonNull Intent createIntent(@NonNull Context context) { + return createIntent(context, null); + } + + public static @NonNull Intent createIntent(@NonNull Context context, @Nullable RecipientId recipientId) { + Intent intent = new Intent(context, ChatWallpaperActivity.class); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + ChatWallpaperViewModel.Factory factory = new ChatWallpaperViewModel.Factory(getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID)); + ViewModelProviders.of(this, factory).get(ChatWallpaperViewModel.class); + + dynamicTheme.onCreate(this); + setContentView(R.layout.chat_wallpaper_activity); + + Toolbar toolbar = findViewById(R.id.toolbar); + + toolbar.setNavigationOnClickListener(unused -> { + if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) { + finish(); + ActivityTransitionUtil.setSlideOutTransition(this); + } + }); + + if (savedInstanceState == null) { + Bundle extras = getIntent().getExtras(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle()); + } + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + ActivityTransitionUtil.setSlideOutTransition(this); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperAlignmentDecoration.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperAlignmentDecoration.java new file mode 100644 index 00000000..4aeffa80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperAlignmentDecoration.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.util.ViewUtil; + +class ChatWallpaperAlignmentDecoration extends RecyclerView.ItemDecoration { + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + int itemPosition = parent.getChildAdapterPosition(view); + int itemCount = state.getItemCount(); + + if (itemCount > 0 && itemPosition == itemCount - 1) { + outRect.set(0, 0, 0, 0); + + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + int viewWidth = view.getMeasuredWidth() + params.rightMargin + params.leftMargin; + int availableWidth = (parent.getRight() - parent.getPaddingRight()) - (parent.getLeft() + parent.getPaddingLeft()); + int itemsPerRow = availableWidth / viewWidth; + + if (itemsPerRow == 1 || (itemPosition + 1) % itemsPerRow == 0) { + return; + } + + int extraCellsNeeded = itemsPerRow - ((itemPosition + 1) % itemsPerRow); + + setEnd(outRect, ViewUtil.isLtr(view), extraCellsNeeded * viewWidth); + } else { + super.getItemOffsets(outRect, view, parent, state); + } + } + + private void setEnd(@NonNull Rect outRect, boolean ltr, int end) { + if (ltr) { + outRect.right = end; + } else { + outRect.left = end; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperDimLevelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperDimLevelUtil.java new file mode 100644 index 00000000..7c10536b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperDimLevelUtil.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.ThemeUtil; + +public final class ChatWallpaperDimLevelUtil { + + private ChatWallpaperDimLevelUtil() { + } + + public static void applyDimLevelForNightMode(@NonNull View dimmer, @NonNull ChatWallpaper chatWallpaper) { + if (ThemeUtil.isDarkTheme(dimmer.getContext())) { + dimmer.setAlpha(chatWallpaper.getDimLevelForDarkTheme()); + dimmer.setVisibility(View.VISIBLE); + } else { + dimmer.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java new file mode 100644 index 00000000..782a09a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.net.Uri; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + +/** + * Converts persisted models of wallpaper into usable {@link ChatWallpaper} instances. + */ +public final class ChatWallpaperFactory { + + private ChatWallpaperFactory() {} + + public static @NonNull ChatWallpaper create(@NonNull Wallpaper model) { + if (model.hasSingleColor()) { + return buildForSingleColor(model.getSingleColor(), model.getDimLevelInDarkTheme()); + } else if (model.hasLinearGradient()) { + return buildForLinearGradinent(model.getLinearGradient(), model.getDimLevelInDarkTheme()); + } else if (model.hasFile()) { + return buildForFile(model.getFile(), model.getDimLevelInDarkTheme()); + } else { + throw new IllegalArgumentException(); + } + } + + public static @NonNull ChatWallpaper updateWithDimming(@NonNull ChatWallpaper wallpaper, float dimLevelInDarkTheme) { + Wallpaper model = wallpaper.serialize(); + + return create(model.toBuilder().setDimLevelInDarkTheme(dimLevelInDarkTheme).build()); + } + + public static @NonNull ChatWallpaper create(@NonNull Uri uri) { + return new UriChatWallpaper(uri, 0f); + } + + private static @NonNull ChatWallpaper buildForSingleColor(@NonNull Wallpaper.SingleColor singleColor, float dimLevelInDarkTheme) { + return new SingleColorChatWallpaper(singleColor.getColor(), dimLevelInDarkTheme); + } + + private static @NonNull ChatWallpaper buildForLinearGradinent(@NonNull Wallpaper.LinearGradient gradient, float dimLevelInDarkTheme) { + int[] colors = new int[gradient.getColorsCount()]; + for (int i = 0; i < colors.length; i++) { + colors[i] = gradient.getColors(i); + } + + float[] positions = new float[gradient.getPositionsCount()]; + for (int i = 0; i < positions.length; i++) { + positions[i] = gradient.getPositions(i); + } + + return new GradientChatWallpaper(gradient.getRotation(), colors, positions, dimLevelInDarkTheme); + } + + private static @NonNull ChatWallpaper buildForFile(@NonNull Wallpaper.File file, float dimLevelInDarkTheme) { + Uri uri = Uri.parse(file.getUri()); + return new UriChatWallpaper(uri, dimLevelInDarkTheme); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java new file mode 100644 index 00000000..059c9287 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.SwitchCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DisplayMetricsUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public class ChatWallpaperFragment extends Fragment { + + private boolean isSettingDimFromViewModel; + private TextView clearWallpaper; + private View resetAllWallpaper; + private View divider; + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.chat_wallpaper_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + ChatWallpaperViewModel viewModel = ViewModelProviders.of(requireActivity()).get(ChatWallpaperViewModel.class); + ImageView chatWallpaperPreview = view.findViewById(R.id.chat_wallpaper_preview_background); + View setWallpaper = view.findViewById(R.id.chat_wallpaper_set_wallpaper); + SwitchCompat dimInNightMode = view.findViewById(R.id.chat_wallpaper_dark_theme_dims_wallpaper); + View chatWallpaperDim = view.findViewById(R.id.chat_wallpaper_dim); + + clearWallpaper = view.findViewById(R.id.chat_wallpaper_clear_wallpaper); + resetAllWallpaper = view.findViewById(R.id.chat_wallpaper_reset_all_wallpapers); + divider = view.findViewById(R.id.chat_wallpaper_divider); + + forceAspectRatioToScreenByAdjustingHeight(chatWallpaperPreview); + + viewModel.getCurrentWallpaper().observe(getViewLifecycleOwner(), wallpaper -> { + if (wallpaper.isPresent()) { + wallpaper.get().loadInto(chatWallpaperPreview); + } else { + chatWallpaperPreview.setImageDrawable(null); + chatWallpaperPreview.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)); + } + }); + + viewModel.getDimInDarkTheme().observe(getViewLifecycleOwner(), shouldDimInNightMode -> { + if (shouldDimInNightMode != dimInNightMode.isChecked()) { + isSettingDimFromViewModel = true; + dimInNightMode.setChecked(shouldDimInNightMode); + isSettingDimFromViewModel = false; + } + + chatWallpaperDim.setAlpha(ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME); + chatWallpaperDim.setVisibility(shouldDimInNightMode && ThemeUtil.isDarkTheme(requireContext()) ? View.VISIBLE : View.GONE); + }); + + viewModel.getEnableWallpaperControls().observe(getViewLifecycleOwner(), enableWallpaperControls -> { + dimInNightMode.setEnabled(enableWallpaperControls); + dimInNightMode.setAlpha(enableWallpaperControls ? 1 : 0.5f); + clearWallpaper.setEnabled(enableWallpaperControls); + clearWallpaper.setAlpha(enableWallpaperControls ? 1 : 0.5f); + }); + + chatWallpaperPreview.setOnClickListener(unused -> setWallpaper.performClick()); + setWallpaper.setOnClickListener(unused -> Navigation.findNavController(view) + .navigate(R.id.action_chatWallpaperFragment_to_chatWallpaperSelectionFragment)); + + resetAllWallpaper.setVisibility(viewModel.isGlobal() ? View.VISIBLE : View.GONE); + + clearWallpaper.setOnClickListener(unused -> { + confirmAction(viewModel.isGlobal() ? R.string.ChatWallpaperFragment__clear_wallpaper_this_will_not + : R.string.ChatWallpaperFragment__clear_wallpaper_for_this_chat, + R.string.ChatWallpaperFragment__clear, + () -> { + viewModel.setWallpaper(null); + viewModel.setDimInDarkTheme(true); + viewModel.saveWallpaperSelection(); + }); + }); + + resetAllWallpaper.setOnClickListener(unused -> { + confirmAction(R.string.ChatWallpaperFragment__reset_all_wallpapers_including_custom, + R.string.ChatWallpaperFragment__reset, + () -> { + viewModel.setWallpaper(null); + viewModel.setDimInDarkTheme(true); + viewModel.resetAllWallpaper(); + }); + }); + + dimInNightMode.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (!isSettingDimFromViewModel) { + viewModel.setDimInDarkTheme(isChecked); + } + }); + } + + private void confirmAction(@StringRes int title, @StringRes int positiveActionLabel, @NonNull Runnable onPositiveAction) { + new AlertDialog.Builder(requireContext()) + .setMessage(title) + .setPositiveButton(positiveActionLabel, (dialog, which) -> { + onPositiveAction.run(); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .setCancelable(true) + .show(); + } + + private void forceAspectRatioToScreenByAdjustingHeight(@NonNull View view) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + requireActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + DisplayMetricsUtil.forceAspectRatioToScreenByAdjustingHeight(displayMetrics, view); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java new file mode 100644 index 00000000..f41901cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.content.Context; +import android.content.Intent; +import android.graphics.PorterDuff; +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.view.ViewCompat; +import androidx.viewpager2.widget.ViewPager2; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ActivityTransitionUtil; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FullscreenHelper; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.WindowUtil; + +import java.util.Collections; + +public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { + + public static final String EXTRA_CHAT_WALLPAPER = "extra.chat.wallpaper"; + private static final String EXTRA_DIM_IN_DARK_MODE = "extra.dim.in.dark.mode"; + private static final String EXTRA_RECIPIENT_ID = "extra.recipient.id"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static @NonNull Intent create(@NonNull Context context, @NonNull ChatWallpaper selection, @NonNull RecipientId recipientId, boolean dimInDarkMode) { + Intent intent = new Intent(context, ChatWallpaperPreviewActivity.class); + + intent.putExtra(EXTRA_CHAT_WALLPAPER, selection); + intent.putExtra(EXTRA_DIM_IN_DARK_MODE, dimInDarkMode); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.chat_wallpaper_preview_activity); + + ViewPager2 viewPager = findViewById(R.id.preview_pager); + ChatWallpaperPreviewAdapter adapter = new ChatWallpaperPreviewAdapter(); + View submit = findViewById(R.id.preview_set_wallpaper); + ChatWallpaperRepository repository = new ChatWallpaperRepository(); + ChatWallpaper selected = getIntent().getParcelableExtra(EXTRA_CHAT_WALLPAPER); + boolean dim = getIntent().getBooleanExtra(EXTRA_DIM_IN_DARK_MODE, false); + Toolbar toolbar = findViewById(R.id.toolbar); + View bubble1 = findViewById(R.id.preview_bubble_1); + TextView bubble2 = findViewById(R.id.preview_bubble_2_text); + + toolbar.setNavigationOnClickListener(unused -> { + finish(); + ActivityTransitionUtil.setSlideOutTransition(this); + }); + + viewPager.setAdapter(adapter); + + adapter.submitList(Collections.singletonList(new ChatWallpaperSelectionMappingModel(selected))); + repository.getAllWallpaper(wallpapers -> adapter.submitList(Stream.of(wallpapers) + .map(wallpaper -> ChatWallpaperFactory.updateWithDimming(wallpaper, dim ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME : 0f)) + .>map(ChatWallpaperSelectionMappingModel::new) + .toList())); + + submit.setOnClickListener(unused -> { + ChatWallpaperSelectionMappingModel model = (ChatWallpaperSelectionMappingModel) adapter.getCurrentList().get(viewPager.getCurrentItem()); + + setResult(RESULT_OK, new Intent().putExtra(EXTRA_CHAT_WALLPAPER, model.getWallpaper())); + finish(); + }); + + RecipientId recipientId = getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + if (recipientId != null) { + Recipient recipient = Recipient.live(recipientId).get(); + bubble1.getBackground().setColorFilter(recipient.getColor().toConversationColor(this), PorterDuff.Mode.SRC_IN); + bubble2.setText(getString(R.string.ChatWallpaperPreviewActivity__set_wallpaper_for_s, recipient.getDisplayName(this))); + } + + new FullscreenHelper(this).showSystemUI(); + WindowUtil.setLightStatusBarFromTheme(this); + WindowUtil.setLightNavigationBarFromTheme(this); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + ActivityTransitionUtil.setSlideOutTransition(this); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewAdapter.java new file mode 100644 index 00000000..e98b04ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewAdapter.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.wallpaper; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +class ChatWallpaperPreviewAdapter extends MappingAdapter { + ChatWallpaperPreviewAdapter() { + registerFactory(ChatWallpaperSelectionMappingModel.class, ChatWallpaperViewHolder.createFactory(R.layout.chat_wallpaper_preview_fragment_adapter_item, null, null)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java new file mode 100644 index 00000000..dd0a773c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.wallpaper; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executor; + +class ChatWallpaperRepository { + + private static final Executor EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED); + + @MainThread + @Nullable ChatWallpaper getCurrentWallpaper(@Nullable RecipientId recipientId) { + if (recipientId != null) { + return Recipient.live(recipientId).get().getWallpaper(); + } else { + return SignalStore.wallpaper().getWallpaper(); + } + } + + void getAllWallpaper(@NonNull Consumer> consumer) { + EXECUTOR.execute(() -> { + List wallpapers = new ArrayList<>(ChatWallpaper.BUILTINS); + + wallpapers.addAll(WallpaperStorage.getAll(ApplicationDependencies.getApplication())); + consumer.accept(wallpapers); + }); + } + + void saveWallpaper(@Nullable RecipientId recipientId, @Nullable ChatWallpaper chatWallpaper) { + if (recipientId != null) { + //noinspection CodeBlock2Expr + EXECUTOR.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).setWallpaper(recipientId, chatWallpaper); + }); + } else { + SignalStore.wallpaper().setWallpaper(ApplicationDependencies.getApplication(), chatWallpaper); + } + } + + void resetAllWallpaper() { + SignalStore.wallpaper().setWallpaper(ApplicationDependencies.getApplication(), null); + EXECUTOR.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).resetAllWallpaper(); + }); + } + + void setDimInDarkTheme(@Nullable RecipientId recipientId, boolean dimInDarkTheme) { + if (recipientId != null) { + EXECUTOR.execute(() -> { + Recipient recipient = Recipient.resolved(recipientId); + if (recipient.hasOwnWallpaper()) { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).setDimWallpaperInDarkTheme(recipientId, dimInDarkTheme); + } else if (recipient.hasWallpaper()) { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()) + .setWallpaper(recipientId, + ChatWallpaperFactory.updateWithDimming(recipient.getWallpaper(), + dimInDarkTheme ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME + : 0f)); + } else { + throw new IllegalStateException("Unexpected call to setDimInDarkTheme, no wallpaper has been set on the given recipient or globally."); + } + }); + } else { + SignalStore.wallpaper().setDimInDarkTheme(dimInDarkTheme); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java new file mode 100644 index 00000000..099c6fd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.util.DisplayMetrics; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +class ChatWallpaperSelectionAdapter extends MappingAdapter { + ChatWallpaperSelectionAdapter(@Nullable ChatWallpaperViewHolder.EventListener eventListener, @NonNull DisplayMetrics windowDisplayMetrics) { + registerFactory(ChatWallpaperSelectionMappingModel.class, ChatWallpaperViewHolder.createFactory(R.layout.chat_wallpaper_selection_fragment_adapter_item, eventListener, windowDisplayMetrics)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java new file mode 100644 index 00000000..1dcd2a04 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.flexbox.FlexboxLayoutManager; +import com.google.android.flexbox.JustifyContent; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ActivityTransitionUtil; +import org.thoughtcrime.securesms.wallpaper.crop.WallpaperImageSelectionActivity; + +public class ChatWallpaperSelectionFragment extends Fragment { + + private static final short CHOOSE_WALLPAPER = 1; + + private ChatWallpaperViewModel viewModel; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.chat_wallpaper_selection_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + View chooseFromPhotos = view.findViewById(R.id.chat_wallpaper_choose_from_photos); + RecyclerView recyclerView = view.findViewById(R.id.chat_wallpaper_recycler); + FlexboxLayoutManager flexboxLayoutManager = new FlexboxLayoutManager(requireContext()); + + chooseFromPhotos.setOnClickListener(unused -> { + askForPermissionIfNeededAndLaunchPhotoSelection(); + }); + + DisplayMetrics displayMetrics = new DisplayMetrics(); + requireActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + + @SuppressWarnings("CodeBlock2Expr") + ChatWallpaperSelectionAdapter adapter = new ChatWallpaperSelectionAdapter(chatWallpaper -> { + startActivityForResult(ChatWallpaperPreviewActivity.create(requireActivity(), chatWallpaper, viewModel.getRecipientId(), viewModel.getDimInDarkTheme().getValue()), CHOOSE_WALLPAPER); + ActivityTransitionUtil.setSlideInTransition(requireActivity()); + }, displayMetrics); + + flexboxLayoutManager.setJustifyContent(JustifyContent.CENTER); + recyclerView.setLayoutManager(flexboxLayoutManager); + recyclerView.setAdapter(adapter); + recyclerView.addItemDecoration(new ChatWallpaperAlignmentDecoration()); + + viewModel = ViewModelProviders.of(requireActivity()).get(ChatWallpaperViewModel.class); + viewModel.getWallpapers().observe(getViewLifecycleOwner(), adapter::submitList); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.refreshWallpaper(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == CHOOSE_WALLPAPER && resultCode == Activity.RESULT_OK && data != null) { + ChatWallpaper chatWallpaper = data.getParcelableExtra(ChatWallpaperPreviewActivity.EXTRA_CHAT_WALLPAPER); + viewModel.setWallpaper(chatWallpaper); + viewModel.saveWallpaperSelection(); + Navigation.findNavController(requireView()).popBackStack(); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void askForPermissionIfNeededAndLaunchPhotoSelection() { + Permissions.with(this) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + startActivityForResult(WallpaperImageSelectionActivity.getIntent(requireContext(), viewModel.getRecipientId()), CHOOSE_WALLPAPER); + ActivityTransitionUtil.setSlideInTransition(requireActivity()); + }) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT) + .show()) + .execute(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionMappingModel.java new file mode 100644 index 00000000..75f08bc8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionMappingModel.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.MappingModel; + +class ChatWallpaperSelectionMappingModel implements MappingModel { + + private final ChatWallpaper chatWallpaper; + + ChatWallpaperSelectionMappingModel(@NonNull ChatWallpaper chatWallpaper) { + this.chatWallpaper = chatWallpaper; + } + + ChatWallpaper getWallpaper() { + return chatWallpaper; + } + + public void loadInto(@NonNull ImageView imageView) { + chatWallpaper.loadInto(imageView); + } + + @Override + public boolean areItemsTheSame(@NonNull ChatWallpaperSelectionMappingModel newItem) { + return areContentsTheSame(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull ChatWallpaperSelectionMappingModel newItem) { + return chatWallpaper.equals(newItem.chatWallpaper); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java new file mode 100644 index 00000000..3c54e449 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.util.DisplayMetrics; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DisplayMetricsUtil; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +class ChatWallpaperViewHolder extends MappingViewHolder { + + private final ImageView preview; + private final View dimmer; + private final EventListener eventListener; + + public ChatWallpaperViewHolder(@NonNull View itemView, @Nullable EventListener eventListener, @Nullable DisplayMetrics windowDisplayMetrics) { + super(itemView); + this.preview = itemView.findViewById(R.id.chat_wallpaper_preview); + this.dimmer = itemView.findViewById(R.id.chat_wallpaper_dim); + this.eventListener = eventListener; + + if (windowDisplayMetrics != null) { + DisplayMetricsUtil.forceAspectRatioToScreenByAdjustingHeight(windowDisplayMetrics, itemView); + } + } + + @Override + public void bind(@NonNull ChatWallpaperSelectionMappingModel model) { + model.loadInto(preview); + + ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(dimmer, model.getWallpaper()); + + if (eventListener != null) { + preview.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + eventListener.onModelClick(model); + } + }); + } + } + + public static @NonNull MappingAdapter.Factory createFactory(@LayoutRes int layout, @Nullable EventListener listener, @Nullable DisplayMetrics windowDisplayMetrics) { + return new MappingAdapter.LayoutFactory<>(view -> new ChatWallpaperViewHolder(view, listener, windowDisplayMetrics), layout); + } + + public interface EventListener { + default void onModelClick(@NonNull ChatWallpaperSelectionMappingModel model) { + onClick(model.getWallpaper()); + } + + void onClick(@NonNull ChatWallpaper chatWallpaper); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java new file mode 100644 index 00000000..b67651bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.wallpaper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; +import java.util.Objects; + +public class ChatWallpaperViewModel extends ViewModel { + + private final ChatWallpaperRepository repository = new ChatWallpaperRepository(); + private final MutableLiveData> wallpaper = new MutableLiveData<>(); + private final MutableLiveData> builtins = new MutableLiveData<>(); + private final MutableLiveData dimInDarkTheme = new MutableLiveData<>(); + private final MutableLiveData enableWallpaperControls = new MutableLiveData<>(); + private final RecipientId recipientId; + + private ChatWallpaperViewModel(@Nullable RecipientId recipientId) { + this.recipientId = recipientId; + + ChatWallpaper currentWallpaper = repository.getCurrentWallpaper(recipientId); + dimInDarkTheme.setValue(currentWallpaper == null || currentWallpaper.getDimLevelForDarkTheme() > 0f); + enableWallpaperControls.setValue(hasClearableWallpaper()); + wallpaper.setValue(Optional.fromNullable(currentWallpaper)); + } + + void refreshWallpaper() { + repository.getAllWallpaper(builtins::postValue); + } + + void setDimInDarkTheme(boolean shouldDimInDarkTheme) { + dimInDarkTheme.setValue(shouldDimInDarkTheme); + + Optional wallpaper = this.wallpaper.getValue(); + if (wallpaper.isPresent()) { + repository.setDimInDarkTheme(recipientId, shouldDimInDarkTheme); + } + } + + void setWallpaper(@Nullable ChatWallpaper chatWallpaper) { + wallpaper.setValue(Optional.fromNullable(chatWallpaper)); + } + + void saveWallpaperSelection() { + Optional wallpaper = this.wallpaper.getValue(); + boolean dimInDarkTheme = this.dimInDarkTheme.getValue(); + + if (!wallpaper.isPresent()) { + repository.saveWallpaper(recipientId, null); + + if (recipientId != null) { + ChatWallpaper globalWallpaper = SignalStore.wallpaper().getWallpaper(); + + this.wallpaper.setValue(Optional.fromNullable(globalWallpaper)); + this.dimInDarkTheme.setValue(globalWallpaper == null || globalWallpaper.getDimLevelForDarkTheme() > 0); + } + + enableWallpaperControls.setValue(false); + return; + } else { + enableWallpaperControls.setValue(true); + } + + Optional updated = wallpaper.transform(paper -> ChatWallpaperFactory.updateWithDimming(paper, dimInDarkTheme ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME : 0f)); + + if (updated.isPresent()) { + repository.saveWallpaper(recipientId, updated.get()); + } + } + + void resetAllWallpaper() { + repository.resetAllWallpaper(); + } + + @Nullable RecipientId getRecipientId() { + return recipientId; + } + + @NonNull LiveData> getCurrentWallpaper() { + return wallpaper; + } + + @NonNull LiveData>> getWallpapers() { + return LiveDataUtil.combineLatest(builtins, dimInDarkTheme, (wallpapers, dimInDarkMode) -> + Stream.of(wallpapers) + .map(paper -> ChatWallpaperFactory.updateWithDimming(paper, dimInDarkMode ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME : 0f)) + .>map(ChatWallpaperSelectionMappingModel::new).toList() + ); + } + + @NonNull LiveData getDimInDarkTheme() { + return dimInDarkTheme; + } + + @NonNull LiveData getEnableWallpaperControls() { + return enableWallpaperControls; + } + + boolean isGlobal() { + return recipientId == null; + } + + private boolean hasClearableWallpaper() { + return (isGlobal() && SignalStore.wallpaper().hasWallpaperSet()) || + (recipientId != null && Recipient.live(recipientId).get().hasOwnWallpaper()); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@Nullable RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new ChatWallpaperViewModel(recipientId))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java new file mode 100644 index 00000000..bc8c2628 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java @@ -0,0 +1,252 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.os.Parcel; +import android.os.Parcelable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + +import java.util.Arrays; +import java.util.Objects; + +final class GradientChatWallpaper implements ChatWallpaper, Parcelable { + + public static final ChatWallpaper GRADIENT_1 = new GradientChatWallpaper(167.96f, + new int[] { 0xFFF3DC47, 0xFFF3DA47, 0xFFF2D546, 0xFFF2CC46, 0xFFF1C146, 0xFFEFB445, 0xFFEEA544, 0xFFEC9644, 0xFFEB8743, 0xFFE97743, 0xFFE86942, 0xFFE65C41, 0xFFE55041, 0xFFE54841, 0xFFE44240, 0xFFE44040 }, + new float[] { 0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1f }, + 0f); + public static final ChatWallpaper GRADIENT_2 = new GradientChatWallpaper(180f, + new int[] { 0xFF16161D, 0xFF17171E, 0xFF1A1A22, 0xFF1F1F28, 0xFF26262F, 0xFF2D2D38, 0xFF353542, 0xFF3E3E4C, 0xFF474757, 0xFF4F4F61, 0xFF57576B, 0xFF5F5F74, 0xFF65657C, 0xFF6A6A82, 0xFF6D6D85, 0xFF6E6E87 }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_3 = new GradientChatWallpaper(192.04f, + new int[] { 0xFFF53844, 0xFFF33845, 0xFFEC3848, 0xFFE2384C, 0xFFD63851, 0xFFC73857, 0xFFB6385E, 0xFFA43866, 0xFF93376D, 0xFF813775, 0xFF70377C, 0xFF613782, 0xFF553787, 0xFF4B378B, 0xFF44378E, 0xFF42378F }, + new float[] { 0.0000f, 0.0075f, 0.0292f, 0.0637f, 0.1097f, 0.1659f, 0.2310f, 0.3037f, 0.3827f, 0.4666f, 0.5541f, 0.6439f, 0.7347f, 0.8252f, 0.9141f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_4 = new GradientChatWallpaper(180f, + new int[] { 0xFF0093E9, 0xFF0294E9, 0xFF0696E7, 0xFF0D99E5, 0xFF169EE3, 0xFF21A3E0, 0xFF2DA8DD, 0xFF3AAEDA, 0xFF46B5D6, 0xFF53BBD3, 0xFF5FC0D0, 0xFF6AC5CD, 0xFF73CACB, 0xFF7ACDC9, 0xFF7ECFC7, 0xFF80D0C7 }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_5 = new GradientChatWallpaper(192.04f, + new int[] { 0xFFF04CE6, 0xFFEE4BE6, 0xFFE54AE5, 0xFFD949E5, 0xFFC946E4, 0xFFB644E3, 0xFFA141E3, 0xFF8B3FE2, 0xFF743CE1, 0xFF5E39E0, 0xFF4936DF, 0xFF3634DE, 0xFF2632DD, 0xFF1930DD, 0xFF112FDD, 0xFF0E2FDD }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_6 = new GradientChatWallpaper(180f, + new int[] { 0xFF65CDAC, 0xFF64CDAB, 0xFF60CBA8, 0xFF5BC8A3, 0xFF55C49D, 0xFF4DC096, 0xFF45BB8F, 0xFF3CB687, 0xFF33B17F, 0xFF2AAC76, 0xFF21A76F, 0xFF1AA268, 0xFF139F62, 0xFF0E9C5E, 0xFF0B9A5B, 0xFF0A995A }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_7 = new GradientChatWallpaper(180f, + new int[] { 0xFFD8E1FA, 0xFFD8E0F9, 0xFFD8DEF7, 0xFFD8DBF3, 0xFFD8D6EE, 0xFFD7D1E8, 0xFFD7CCE2, 0xFFD7C6DB, 0xFFD7BFD4, 0xFFD7B9CD, 0xFFD6B4C7, 0xFFD6AFC1, 0xFFD6AABC, 0xFFD6A7B8, 0xFFD6A5B6, 0xFFD6A4B5 }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_8 = new GradientChatWallpaper(180f, + new int[] { 0xFFD8EBFD, 0xFFD7EAFD, 0xFFD5E9FD, 0xFFD2E7FD, 0xFFCDE5FD, 0xFFC8E3FD, 0xFFC3E0FD, 0xFFBDDDFC, 0xFFB7DAFC, 0xFFB2D7FC, 0xFFACD4FC, 0xFFA7D1FC, 0xFFA3CFFB, 0xFFA0CDFB, 0xFF9ECCFB, 0xFF9DCCFB }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + public static final ChatWallpaper GRADIENT_9 = new GradientChatWallpaper(192.04f, + new int[] { 0xFFFFE5C2, 0xFFFFE4C1, 0xFFFFE2BF, 0xFFFFDFBD, 0xFFFEDBB9, 0xFFFED6B5, 0xFFFED1B1, 0xFFFDCCAC, 0xFFFDC6A8, 0xFFFDC0A3, 0xFFFCBB9F, 0xFFFCB69B, 0xFFFCB297, 0xFFFCAF95, 0xFFFCAD93, 0xFFFCAC92 }, + new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, + 0f); + + + private final float degrees; + private final int[] colors; + private final float[] positions; + private final float dimLevelInDarkTheme; + + GradientChatWallpaper(float degrees, int[] colors, float[] positions, float dimLevelInDarkTheme) { + this.degrees = degrees; + this.colors = colors; + this.positions = positions; + this.dimLevelInDarkTheme = dimLevelInDarkTheme; + } + + private GradientChatWallpaper(Parcel in) { + degrees = in.readFloat(); + colors = in.createIntArray(); + positions = in.createFloatArray(); + dimLevelInDarkTheme = in.readFloat(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeFloat(degrees); + dest.writeIntArray(colors); + dest.writeFloatArray(positions); + dest.writeFloat(dimLevelInDarkTheme); + } + + @Override + public int describeContents() { + return 0; + } + + private @NonNull Drawable buildDrawable() { + return new RotatableGradientDrawable(degrees, colors, positions); + } + + @Override + public float getDimLevelForDarkTheme() { + return dimLevelInDarkTheme; + } + + @Override + public void loadInto(@NonNull ImageView imageView) { + imageView.setImageDrawable(buildDrawable()); + } + + @Override + public @NonNull Wallpaper serialize() { + Wallpaper.LinearGradient.Builder builder = Wallpaper.LinearGradient.newBuilder(); + + builder.setRotation(degrees); + + for (int color : colors) { + builder.addColors(color); + } + + for (float position : positions) { + builder.addPositions(position); + } + + return Wallpaper.newBuilder() + .setLinearGradient(builder) + .setDimLevelInDarkTheme(dimLevelInDarkTheme) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GradientChatWallpaper that = (GradientChatWallpaper) o; + return Float.compare(that.degrees, degrees) == 0 && + Arrays.equals(colors, that.colors) && + Arrays.equals(positions, that.positions) && + Float.compare(that.dimLevelInDarkTheme, dimLevelInDarkTheme) == 0; + } + + @Override + public int hashCode() { + int result = Objects.hash(degrees, dimLevelInDarkTheme); + result = 31 * result + Arrays.hashCode(colors); + result = 31 * result + Arrays.hashCode(positions); + return result; + } + + public static final Creator CREATOR = new Creator() { + @Override + public GradientChatWallpaper createFromParcel(Parcel in) { + return new GradientChatWallpaper(in); + } + + @Override + public GradientChatWallpaper[] newArray(int size) { + return new GradientChatWallpaper[size]; + } + }; + + private static final class RotatableGradientDrawable extends Drawable { + + private final float degrees; + private final int[] colors; + private final float[] positions; + + private final Rect fillRect = new Rect(); + private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + + private RotatableGradientDrawable(float degrees, int[] colors, @Nullable float[] positions) { + this.degrees = degrees + 225f; + this.colors = colors; + this.positions = positions; + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + + Point topLeft = new Point(left, top); + Point topRight = new Point(right, top); + Point bottomLeft = new Point(left, bottom); + Point bottomRight = new Point(right, bottom); + Point origin = new Point(getBounds().width() / 2, getBounds().height() / 2); + + Point rotationTopLeft = cornerPrime(origin, topLeft, degrees); + Point rotationTopRight = cornerPrime(origin, topRight, degrees); + Point rotationBottomLeft = cornerPrime(origin, bottomLeft, degrees); + Point rotationBottomRight = cornerPrime(origin, bottomRight, degrees); + + fillRect.left = Integer.MAX_VALUE; + fillRect.top = Integer.MAX_VALUE; + fillRect.right = Integer.MIN_VALUE; + fillRect.bottom = Integer.MIN_VALUE; + + for (Point point : Arrays.asList(topLeft, topRight, bottomLeft, bottomRight, rotationTopLeft, rotationTopRight, rotationBottomLeft, rotationBottomRight)) { + if (point.x < fillRect.left) { + fillRect.left = point.x; + } + + if (point.x > fillRect.right) { + fillRect.right = point.x; + } + + if (point.y < fillRect.top) { + fillRect.top = point.y; + } + + if (point.y > fillRect.bottom) { + fillRect.bottom = point.y; + } + } + + fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP)); + } + + private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) { + return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees))); + } + + private static int xPrime(@NonNull Point origin, @NonNull Point corner, double theta) { + return (int) Math.ceil(((corner.x - origin.x) * Math.cos(theta)) - ((corner.y - origin.y) * Math.sin(theta)) + origin.x); + } + + private static int yPrime(@NonNull Point origin, @NonNull Point corner, double theta) { + return (int) Math.ceil(((corner.x - origin.x) * Math.sin(theta)) + ((corner.y - origin.y) * Math.cos(theta)) + origin.y); + } + + @Override + public void draw(Canvas canvas) { + int save = canvas.save(); + canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f); + canvas.drawRect(fillRect, fillPaint); + canvas.restoreToCount(save); + } + + @Override + public void setAlpha(int alpha) { + // Not supported + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + // Not supported + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java new file mode 100644 index 00000000..f337754c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.graphics.drawable.ColorDrawable; +import android.os.Parcel; +import android.os.Parcelable; +import android.widget.ImageView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + +import java.util.Objects; + +final class SingleColorChatWallpaper implements ChatWallpaper, Parcelable { + + public static final ChatWallpaper SOLID_1 = new SingleColorChatWallpaper(0xFFE26983, 0f); + public static final ChatWallpaper SOLID_2 = new SingleColorChatWallpaper(0xFFDF9171, 0f); + public static final ChatWallpaper SOLID_3 = new SingleColorChatWallpaper(0xFF9E9887, 0f); + public static final ChatWallpaper SOLID_4 = new SingleColorChatWallpaper(0xFF89AE8F, 0f); + public static final ChatWallpaper SOLID_5 = new SingleColorChatWallpaper(0xFF32C7E2, 0f); + public static final ChatWallpaper SOLID_6 = new SingleColorChatWallpaper(0xFF7C99B6, 0f); + public static final ChatWallpaper SOLID_7 = new SingleColorChatWallpaper(0xFFC988E7, 0f); + public static final ChatWallpaper SOLID_8 = new SingleColorChatWallpaper(0xFFE297C3, 0f); + public static final ChatWallpaper SOLID_9 = new SingleColorChatWallpaper(0xFFA2A2AA, 0f); + public static final ChatWallpaper SOLID_10 = new SingleColorChatWallpaper(0xFF146148, 0f); + public static final ChatWallpaper SOLID_11 = new SingleColorChatWallpaper(0xFF403B91, 0f); + public static final ChatWallpaper SOLID_12 = new SingleColorChatWallpaper(0xFF624249, 0f); + + private final @ColorInt int color; + private final float dimLevelInDarkTheme; + + SingleColorChatWallpaper(@ColorInt int color, float dimLevelInDarkTheme) { + this.color = color; + this.dimLevelInDarkTheme = dimLevelInDarkTheme; + } + + private SingleColorChatWallpaper(Parcel in) { + color = in.readInt(); + dimLevelInDarkTheme = in.readFloat(); + } + + @Override + public float getDimLevelForDarkTheme() { + return dimLevelInDarkTheme; + } + + @Override + public void loadInto(@NonNull ImageView imageView) { + imageView.setImageDrawable(new ColorDrawable(color)); + } + + @Override + public @NonNull Wallpaper serialize() { + Wallpaper.SingleColor.Builder builder = Wallpaper.SingleColor.newBuilder(); + + builder.setColor(color); + + return Wallpaper.newBuilder() + .setSingleColor(builder) + .setDimLevelInDarkTheme(dimLevelInDarkTheme) + .build(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeFloat(dimLevelInDarkTheme); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SingleColorChatWallpaper that = (SingleColorChatWallpaper) o; + return color == that.color && Float.compare(dimLevelInDarkTheme, that.dimLevelInDarkTheme) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(color, dimLevelInDarkTheme); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SingleColorChatWallpaper createFromParcel(Parcel in) { + return new SingleColorChatWallpaper(in); + } + + @Override + public SingleColorChatWallpaper[] newArray(int size) { + return new SingleColorChatWallpaper[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java new file mode 100644 index 00000000..67c75709 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; + +import java.util.Objects; + +final class UriChatWallpaper implements ChatWallpaper, Parcelable { + + private static final String TAG = Log.tag(UriChatWallpaper.class); + + private final Uri uri; + private final float dimLevelInDarkTheme; + + public UriChatWallpaper(@NonNull Uri uri, float dimLevelInDarkTheme) { + this.uri = uri; + this.dimLevelInDarkTheme = dimLevelInDarkTheme; + } + + @Override + public float getDimLevelForDarkTheme() { + return dimLevelInDarkTheme; + } + + @Override + public void loadInto(@NonNull ImageView imageView) { + GlideApp.with(imageView) + .load(new DecryptableStreamUriLoader.DecryptableUri(uri)) + .addListener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + Log.w(TAG, "Failed to load wallpaper " + uri); + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + Log.i(TAG, "Loaded wallpaper " + uri); + return false; + } + }) + .into(imageView); + } + + @Override + public @NonNull Wallpaper serialize() { + return Wallpaper.newBuilder() + .setFile(Wallpaper.File.newBuilder().setUri(uri.toString())) + .setDimLevelInDarkTheme(dimLevelInDarkTheme) + .build(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(uri.toString()); + dest.writeFloat(dimLevelInDarkTheme); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UriChatWallpaper that = (UriChatWallpaper) o; + return Float.compare(that.dimLevelInDarkTheme, dimLevelInDarkTheme) == 0 && + uri.equals(that.uri); + } + + @Override + public int hashCode() { + return Objects.hash(uri, dimLevelInDarkTheme); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriChatWallpaper createFromParcel(Parcel in) { + return new UriChatWallpaper(Uri.parse(in.readString()), in.readFloat()); + } + + @Override + public UriChatWallpaper[] newArray(int size) { + return new UriChatWallpaper[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java new file mode 100644 index 00000000..70ab2ce4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Manages the storage of custom wallpaper files. + */ +public final class WallpaperStorage { + + private static final String TAG = Log.tag(WallpaperStorage.class); + + private static final String DIRECTORY = "wallpapers"; + private static final String FILENAME_BASE = "wallpaper"; + + /** + * Saves the provided input stream as a new wallpaper file. + */ + @WorkerThread + public static @NonNull ChatWallpaper save(@NonNull Context context, @NonNull InputStream wallpaperStream, @NonNull String extension) throws IOException { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File file = File.createTempFile(FILENAME_BASE, "." + extension, directory); + + StreamUtil.copy(wallpaperStream, getOutputStream(context, file)); + + return ChatWallpaperFactory.create(PartAuthority.getWallpaperUri(file.getName())); + } + + @WorkerThread + public static @NonNull InputStream read(@NonNull Context context, String filename) throws IOException { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File wallpaperFile = new File(directory, filename); + + return getInputStream(context, wallpaperFile); + } + + @WorkerThread + public static @NonNull List getAll(@NonNull Context context) { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File[] allFiles = directory.listFiles(pathname -> pathname.getName().contains(FILENAME_BASE)); + + if (allFiles != null) { + return Stream.of(allFiles) + .map(File::getName) + .map(PartAuthority::getWallpaperUri) + .map(ChatWallpaperFactory::create) + .toList(); + } else { + return Collections.emptyList(); + } + } + + /** + * Called when wallpaper is deselected. This will check anywhere the wallpaper could be used, and + * if we discover it's unused, we'll delete the file. + */ + @WorkerThread + public static void onWallpaperDeselected(@NonNull Context context, @NonNull Uri uri) { + Uri globalUri = SignalStore.wallpaper().getWallpaperUri(); + if (Objects.equals(uri, globalUri)) { + return; + } + + int recipientCount = DatabaseFactory.getRecipientDatabase(context).getWallpaperUriUsageCount(uri); + if (recipientCount > 0) { + return; + } + + String filename = PartAuthority.getWallpaperFilename(uri); + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File wallpaperFile = new File(directory, filename); + + if (!wallpaperFile.delete()) { + Log.w(TAG, "Failed to delete " + filename + "!"); + } + } + + private static @NonNull OutputStream getOutputStream(@NonNull Context context, File outputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; + } + + private static @NonNull InputStream getInputStream(@NonNull Context context, File inputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java new file mode 100644 index 00000000..895b5962 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java @@ -0,0 +1,217 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.ImageEditorView; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.renderers.FaceBlurRenderer; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.scribbles.UriGlideRenderer; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperPreviewActivity; + +import java.util.Locale; +import java.util.Objects; + +public final class WallpaperCropActivity extends BaseActivity { + + private static final String TAG = Log.tag(WallpaperCropActivity.class); + + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + private static final String EXTRA_IMAGE_URI = "IMAGE_URI"; + + private final DynamicTheme dynamicTheme = new DynamicWallpaperTheme(); + + private ImageEditorView imageEditor; + private WallpaperCropViewModel viewModel; + + public static Intent newIntent(@NonNull Context context, + @Nullable RecipientId recipientId, + @NonNull Uri imageUri) + { + Intent intent = new Intent(context, WallpaperCropActivity.class); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + intent.putExtra(EXTRA_IMAGE_URI, Objects.requireNonNull(imageUri)); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dynamicTheme.onCreate(this); + setContentView(R.layout.chat_wallpaper_crop_activity); + + RecipientId recipientId = getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + Uri inputImage = Objects.requireNonNull(getIntent().getParcelableExtra(EXTRA_IMAGE_URI)); + + Log.i(TAG, "Cropping wallpaper for " + (recipientId == null ? "default wallpaper" : recipientId)); + + WallpaperCropViewModel.Factory factory = new WallpaperCropViewModel.Factory(recipientId); + viewModel = ViewModelProviders.of(this, factory).get(WallpaperCropViewModel.class); + + imageEditor = findViewById(R.id.image_editor); + View receivedBubble = findViewById(R.id.preview_bubble_1); + TextView bubble2Text = findViewById(R.id.chat_wallpaper_bubble2_text); + View setWallPaper = findViewById(R.id.preview_set_wallpaper); + SwitchCompat blur = findViewById(R.id.preview_blur); + + setupImageEditor(inputImage); + + setWallPaper.setOnClickListener(v -> setWallpaper()); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar supportActionBar = Objects.requireNonNull(getSupportActionBar()); + supportActionBar.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_left_24)); + supportActionBar.setDisplayHomeAsUpEnabled(true); + + blur.setOnCheckedChangeListener((v, checked) -> viewModel.setBlur(checked)); + + viewModel.getBlur() + .observe(this, blurred -> { + setBlurred(blurred); + if (blurred != blur.isChecked()) { + blur.setChecked(blurred); + } + }); + + viewModel.getRecipient() + .observe(this, r -> { + if (r.getId().isUnknown()) { + bubble2Text.setText(R.string.WallpaperCropActivity__set_wallpaper_for_all_chats); + } else { + bubble2Text.setText(getString(R.string.WallpaperCropActivity__set_wallpaper_for_s, r.getDisplayName(this))); + receivedBubble.getBackground().setColorFilter(r.getColor().toConversationColor(this), PorterDuff.Mode.SRC_IN); + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (super.onOptionsItemSelected(item)) { + return true; + } + + int itemId = item.getItemId(); + + if (itemId == android.R.id.home) { + finish(); + return true; + } + + return false; + } + + private void setWallpaper() { + EditorModel model = imageEditor.getModel(); + + Point size = new Point(imageEditor.getWidth(), imageEditor.getHeight()); + + AlertDialog dialog = SimpleProgressDialog.show(this); + viewModel.render(this, model, size, + new AsynchronousCallback.MainThread() { + @Override public void onComplete(@Nullable ChatWallpaper result) { + dialog.dismiss(); + setResult(RESULT_OK, new Intent().putExtra(ChatWallpaperPreviewActivity.EXTRA_CHAT_WALLPAPER, result)); + finish(); + } + + @Override public void onError(@Nullable WallpaperCropViewModel.Error error) { + dialog.dismiss(); + Toast.makeText(WallpaperCropActivity.this, R.string.WallpaperCropActivity__error_setting_wallpaper, Toast.LENGTH_SHORT).show(); + } + }.toWorkerCallback()); + } + + private void setupImageEditor(@NonNull Uri imageUri) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + int height = displayMetrics.heightPixels; + int width = displayMetrics.widthPixels; + float ratio = width / (float) height; + + EditorModel editorModel = EditorModel.createForWallpaperEditing(ratio); + + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, width, height, UriGlideRenderer.WEAK_BLUR)); + image.getFlags() + .setSelectable(false) + .persist(); + + editorModel.addElement(image); + + imageEditor.setModel(editorModel); + + imageEditor.setSizeChangedListener((newWidth, newHeight) -> { + float newRatio = newWidth / (float) newHeight; + Log.i(TAG, String.format(Locale.US, "Output size (%d, %d) (ratio %.2f)", newWidth, newHeight, newRatio)); + + editorModel.setFixedRatio(newRatio); + }); + } + + private void setBlurred(boolean blurred) { + imageEditor.getModel().clearFaceRenderers(); + + if (blurred) { + EditorElement mainImage = imageEditor.getModel().getMainImage(); + + if (mainImage != null) { + EditorElement element = new EditorElement(new FaceBlurRenderer(), EditorModel.Z_MASK); + + element.getFlags() + .setEditable(false) + .setSelectable(false) + .persist(); + + mainImage.addElement(element); + imageEditor.invalidate(); + } + } + } + + private static final class DynamicWallpaperTheme extends DynamicTheme { + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_WallpaperCropper; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java new file mode 100644 index 00000000..dcb856b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +final class WallpaperCropRepository { + + private static final String TAG = Log.tag(WallpaperCropRepository.class); + + @Nullable private final RecipientId recipientId; + private final Context context; + + public WallpaperCropRepository(@Nullable RecipientId recipientId) { + this.context = ApplicationDependencies.getApplication(); + this.recipientId = recipientId; + } + + @WorkerThread + @NonNull ChatWallpaper setWallPaper(byte[] bytes) throws IOException { + try (InputStream inputStream = new ByteArrayInputStream(bytes)) { + ChatWallpaper wallpaper = WallpaperStorage.save(context, inputStream, "webp"); + + if (recipientId != null) { + Log.i(TAG, "Setting image wallpaper for " + recipientId); + DatabaseFactory.getRecipientDatabase(context).setWallpaper(recipientId, wallpaper); + } else { + Log.i(TAG, "Setting image wallpaper for default"); + SignalStore.wallpaper().setWallpaper(context, wallpaper); + } + + return wallpaper; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java new file mode 100644 index 00000000..656b514a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; + +import java.io.IOException; +import java.util.Objects; + +final class WallpaperCropViewModel extends ViewModel { + + private static final String TAG = Log.tag(WallpaperCropViewModel.class); + + private final @NonNull WallpaperCropRepository repository; + private final @NonNull MutableLiveData blur; + private final @NonNull LiveData recipient; + + public WallpaperCropViewModel(@Nullable RecipientId recipientId, + @NonNull WallpaperCropRepository repository) + { + this.repository = repository; + this.blur = new MutableLiveData<>(false); + this.recipient = recipientId != null ? Recipient.live(recipientId).getLiveData() : LiveDataUtil.just(Recipient.UNKNOWN); + } + + void render(@NonNull Context context, + @NonNull EditorModel model, + @NonNull Point size, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.BOUNDED.execute( + () -> { + Bitmap bitmap = model.render(context, size); + try { + ChatWallpaper chatWallpaper = repository.setWallPaper(BitmapUtil.toWebPByteArray(bitmap)); + callback.onComplete(chatWallpaper); + } catch (IOException e) { + Log.w(TAG, e); + callback.onError(Error.SAVING); + } finally { + bitmap.recycle(); + } + }); + } + + LiveData getBlur() { + return Transformations.distinctUntilChanged(blur); + } + + LiveData getRecipient() { + return recipient; + } + + @MainThread + void setBlur(boolean blur) { + this.blur.setValue(blur); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@Nullable RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + + WallpaperCropRepository wallpaperCropRepository = new WallpaperCropRepository(recipientId); + + return Objects.requireNonNull(modelClass.cast(new WallpaperCropViewModel(recipientId, wallpaperCropRepository))); + } + } + + enum Error { + SAVING + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java new file mode 100644 index 00000000..23197f23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaFolder; +import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment; +import org.thoughtcrime.securesms.mediasend.MediaPickerItemFragment; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public final class WallpaperImageSelectionActivity extends AppCompatActivity + implements MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller +{ + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + private static final int CROP = 901; + + @RequiresPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + public static Intent getIntent(@NonNull Context context, + @Nullable RecipientId recipientId) + { + Intent intent = new Intent(context, WallpaperImageSelectionActivity.class); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.wallpaper_image_selection_activity); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, MediaPickerFolderFragment.newInstance(getString(R.string.WallpaperImageSelectionActivity__choose_wallpaper_image), true)) + .commit(); + } + + @Override + public void onFolderSelected(@NonNull MediaFolder folder) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false, true)) + .addToBackStack(null) + .commit(); + } + + @Override + public void onCameraSelected() { + throw new AssertionError("Unexpected, Camera disabled"); + } + + @Override + public void onMediaSelected(@NonNull Media media) { + startActivityForResult(WallpaperCropActivity.newIntent(this, getRecipientId(), media.getUri()), CROP); + } + + private RecipientId getRecipientId() { + return getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + } + + @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CROP && resultCode == RESULT_OK) { + setResult(RESULT_OK, data); + finish(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallBandwidthMode.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallBandwidthMode.java new file mode 100644 index 00000000..91526326 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallBandwidthMode.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.NetworkUtil; + +/** + * Represents the user's desired bandwidth mode for calls. + */ +public enum CallBandwidthMode { + LOW_ALWAYS(0), + HIGH_ON_WIFI(1), + HIGH_ALWAYS(2); + + private final int code; + + CallBandwidthMode(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static CallBandwidthMode fromCode(int code) { + switch (code) { + case 1: + return HIGH_ON_WIFI; + case 2: + return HIGH_ALWAYS; + default: + return LOW_ALWAYS; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java new file mode 100644 index 00000000..a2c682d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.service.WebRtcCallService; + +/** + * Manages the state of the WebRtc items in the Android notification bar. + * + * @author Moxie Marlinspike + * + */ + +public class CallNotificationBuilder { + + private static final int WEBRTC_NOTIFICATION = 313388; + private static final int WEBRTC_NOTIFICATION_RINGING = 313389; + + public static final int TYPE_INCOMING_RINGING = 1; + public static final int TYPE_OUTGOING_RINGING = 2; + public static final int TYPE_ESTABLISHED = 3; + public static final int TYPE_INCOMING_CONNECTING = 4; + + public static Notification getCallInProgressNotification(Context context, int type, Recipient recipient) { + Intent contentIntent = new Intent(context, WebRtcCallActivity.class); + contentIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, 0); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getNotificationChannel(context, type)) + .setSmallIcon(R.drawable.ic_call_secure_white_24dp) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setContentTitle(recipient.getDisplayName(context)); + + if (type == TYPE_INCOMING_CONNECTING) { + builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting)); + builder.setPriority(NotificationCompat.PRIORITY_MIN); + } else if (type == TYPE_INCOMING_RINGING) { + builder.setContentText(context.getString(R.string.NotificationBarManager__incoming_signal_call)); + builder.addAction(getServiceNotificationAction(context, WebRtcCallService.ACTION_DENY_CALL, R.drawable.ic_close_grey600_32dp, R.string.NotificationBarManager__deny_call)); + builder.addAction(getActivityNotificationAction(context, WebRtcCallActivity.ANSWER_ACTION, R.drawable.ic_phone_grey600_32dp, R.string.NotificationBarManager__answer_call)); + + if (callActivityRestricted()) { + builder.setFullScreenIntent(pendingIntent, true); + builder.setPriority(NotificationCompat.PRIORITY_HIGH); + builder.setCategory(NotificationCompat.CATEGORY_CALL); + } + } else if (type == TYPE_OUTGOING_RINGING) { + builder.setContentText(context.getString(R.string.NotificationBarManager__establishing_signal_call)); + builder.addAction(getServiceNotificationAction(context, WebRtcCallService.ACTION_LOCAL_HANGUP, R.drawable.ic_call_end_grey600_32dp, R.string.NotificationBarManager__cancel_call)); + } else { + builder.setContentText(context.getString(R.string.NotificationBarManager_signal_call_in_progress)); + builder.addAction(getServiceNotificationAction(context, WebRtcCallService.ACTION_LOCAL_HANGUP, R.drawable.ic_call_end_grey600_32dp, R.string.NotificationBarManager__end_call)); + } + + return builder.build(); + } + + public static int getNotificationId(int type) { + if (callActivityRestricted() && type == TYPE_INCOMING_RINGING) { + return WEBRTC_NOTIFICATION_RINGING; + } else { + return WEBRTC_NOTIFICATION; + } + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean isWebRtcNotification(int notificationId) { + return notificationId == WEBRTC_NOTIFICATION || notificationId == WEBRTC_NOTIFICATION_RINGING; + } + + private static @NonNull String getNotificationChannel(@NonNull Context context, int type) { + if (callActivityRestricted() && type == TYPE_INCOMING_RINGING) { + return NotificationChannels.CALLS; + } else { + return NotificationChannels.OTHER; + } + } + + private static NotificationCompat.Action getServiceNotificationAction(Context context, String action, int iconResId, int titleResId) { + Intent intent = new Intent(context, WebRtcCallService.class); + intent.setAction(action); + + PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, 0); + + return new NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent); + } + + private static NotificationCompat.Action getActivityNotificationAction(@NonNull Context context, @NonNull String action, + @DrawableRes int iconResId, @StringRes int titleResId) + { + Intent intent = new Intent(context, WebRtcCallActivity.class); + intent.setAction(action); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + return new NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent); + } + + private static boolean callActivityRestricted() { + return Build.VERSION.SDK_INT >= 29 && !ApplicationDependencies.getAppForegroundObserver().isForegrounded(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java new file mode 100644 index 00000000..ef34b3f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/UncaughtExceptionHandlerManager.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.webrtc; + +import org.signal.core.util.logging.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * Allows multiple default uncaught exception handlers to be registered + * + * Calls all registered handlers in reverse order of registration. + * Errors in one handler do not prevent subsequent handlers from being called. + */ +public class UncaughtExceptionHandlerManager implements Thread.UncaughtExceptionHandler { + + private static final String TAG = Log.tag(UncaughtExceptionHandlerManager.class); + + private final Thread.UncaughtExceptionHandler originalHandler; + private final List handlers = new ArrayList(); + + public UncaughtExceptionHandlerManager() { + originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + registerHandler(originalHandler); + Thread.setDefaultUncaughtExceptionHandler(this); + } + + public void registerHandler(Thread.UncaughtExceptionHandler handler) { + handlers.add(handler); + } + + public void unregister() { + Thread.setDefaultUncaughtExceptionHandler(originalHandler); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + for (int i = handlers.size() - 1; i >= 0; i--) { + try { + handlers.get(i).uncaughtException(thread, throwable); + } catch(Throwable t) { + Log.e(TAG, "Error in uncaught exception handling", t); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java new file mode 100644 index 00000000..881c2462 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; + +public class VoiceCallShare extends Activity { + + private static final String TAG = VoiceCallShare.class.getSimpleName(); + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + if (getIntent().getData() != null && "content".equals(getIntent().getData().getScheme())) { + Cursor cursor = null; + + try { + cursor = getContentResolver().query(getIntent().getData(), null, null, null, null); + + if (cursor != null && cursor.moveToNext()) { + String destination = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)); + + SimpleTask.run(() -> Recipient.external(this, destination), recipient -> { + if (!TextUtils.isEmpty(destination)) { + Intent serviceIntent = new Intent(this, WebRtcCallService.class); + serviceIntent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.AUDIO_CALL.getCode()); + startService(serviceIntent); + + Intent activityIntent = new Intent(this, WebRtcCallActivity.class); + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(activityIntent); + } + }); + } + } finally { + if (cursor != null) cursor.close(); + } + } + + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java new file mode 100644 index 00000000..e4155aa5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.webrtc.audio; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.SoundPool; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.ServiceUtil; + +public abstract class AudioManagerCompat { + + private static final String TAG = Log.tag(AudioManagerCompat.class); + + private static final int AUDIOFOCUS_GAIN = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE; + + protected final AudioManager audioManager; + + @SuppressWarnings("CodeBlock2Expr") + protected final AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener = focusChange -> { + Log.i(TAG, "onAudioFocusChangeListener: " + focusChange); + }; + + private AudioManagerCompat(@NonNull Context context) { + audioManager = ServiceUtil.getAudioManager(context); + } + + abstract public SoundPool createSoundPool(); + abstract public void requestCallAudioFocus(); + abstract public void abandonCallAudioFocus(); + + public static AudioManagerCompat create(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 26) { + return new Api26AudioManagerCompat(context); + } else if (Build.VERSION.SDK_INT >= 21) { + return new Api21AudioManagerCompat(context); + } else { + return new Api19AudioManagerCompat(context); + } + } + + @RequiresApi(26) + private static class Api26AudioManagerCompat extends AudioManagerCompat { + + private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build(); + + private AudioFocusRequest audioFocusRequest; + + private Api26AudioManagerCompat(@NonNull Context context) { + super(context); + } + + @Override + public SoundPool createSoundPool() { + return new SoundPool.Builder() + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setMaxStreams(1) + .build(); + } + + @Override + public void requestCallAudioFocus() { + if (audioFocusRequest != null) { + Log.w(TAG, "Already requested audio focus. Ignoring..."); + return; + } + + audioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN) + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setOnAudioFocusChangeListener(onAudioFocusChangeListener) + .build(); + + int result = audioManager.requestAudioFocus(audioFocusRequest); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted. Result code: " + result); + } + } + + @Override + public void abandonCallAudioFocus() { + if (audioFocusRequest == null) { + Log.w(TAG, "Don't currently have audio focus. Ignoring..."); + return; + } + + int result = audioManager.abandonAudioFocusRequest(audioFocusRequest); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus abandon failed. Result code: " + result); + } + + audioFocusRequest = null; + } + } + + @RequiresApi(21) + private static class Api21AudioManagerCompat extends Api19AudioManagerCompat { + + private static AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL) + .build(); + + private Api21AudioManagerCompat(@NonNull Context context) { + super(context); + } + + @Override + public SoundPool createSoundPool() { + return new SoundPool.Builder() + .setAudioAttributes(AUDIO_ATTRIBUTES) + .setMaxStreams(1) + .build(); + } + } + + private static class Api19AudioManagerCompat extends AudioManagerCompat { + + private Api19AudioManagerCompat(@NonNull Context context) { + super(context); + } + + @Override + public SoundPool createSoundPool() { + return new SoundPool(1, AudioManager.STREAM_VOICE_CALL, 0); + } + + @Override + public void requestCallAudioFocus() { + int result = audioManager.requestAudioFocus(onAudioFocusChangeListener, AudioManager.STREAM_VOICE_CALL, AUDIOFOCUS_GAIN); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted. Result code: " + result); + } + } + + @Override + public void abandonCallAudioFocus() { + int result = audioManager.abandonAudioFocus(onAudioFocusChangeListener); + + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus abandon failed. Result code: " + result); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java new file mode 100644 index 00000000..2794e078 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java @@ -0,0 +1,232 @@ +package org.thoughtcrime.securesms.webrtc.audio; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +public class BluetoothStateManager { + + private static final String TAG = Log.tag(BluetoothStateManager.class); + + private enum ScoConnection { + DISCONNECTED, + IN_PROGRESS, + CONNECTED + } + + private final Object LOCK = new Object(); + + private final Context context; + private final BluetoothAdapter bluetoothAdapter; + private BluetoothScoReceiver bluetoothScoReceiver; + private BluetoothConnectionReceiver bluetoothConnectionReceiver; + private final BluetoothStateListener listener; + private final AtomicBoolean destroyed; + + private volatile ScoConnection scoConnection = ScoConnection.DISCONNECTED; + + private BluetoothHeadset bluetoothHeadset = null; + private boolean wantsConnection = false; + + public BluetoothStateManager(@NonNull Context context, @Nullable BluetoothStateListener listener) { + this.context = context.getApplicationContext(); + this.bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + this.bluetoothScoReceiver = new BluetoothScoReceiver(); + this.bluetoothConnectionReceiver = new BluetoothConnectionReceiver(); + this.listener = listener; + this.destroyed = new AtomicBoolean(false); + + if (this.bluetoothAdapter == null) + return; + + requestHeadsetProxyProfile(); + + this.context.registerReceiver(bluetoothConnectionReceiver, new IntentFilter(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)); + + Intent sticky = this.context.registerReceiver(bluetoothScoReceiver, new IntentFilter(getScoChangeIntent())); + + if (sticky != null) { + bluetoothScoReceiver.onReceive(context, sticky); + } + + handleBluetoothStateChange(); + } + + public void onDestroy() { + destroyed.set(true); + + if (bluetoothHeadset != null && bluetoothAdapter != null) { + this.bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset); + } + + if (bluetoothConnectionReceiver != null) { + context.unregisterReceiver(bluetoothConnectionReceiver); + bluetoothConnectionReceiver = null; + } + + if (bluetoothScoReceiver != null) { + context.unregisterReceiver(bluetoothScoReceiver); + bluetoothScoReceiver = null; + } + + this.bluetoothHeadset = null; + } + + public void setWantsConnection(boolean enabled) { + synchronized (LOCK) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + + this.wantsConnection = enabled; + + if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) { + audioManager.startBluetoothSco(); + scoConnection = ScoConnection.IN_PROGRESS; + } else if (!wantsConnection && scoConnection == ScoConnection.CONNECTED) { + audioManager.stopBluetoothSco(); + audioManager.setBluetoothScoOn(false); + scoConnection = ScoConnection.DISCONNECTED; + } else if (!wantsConnection && scoConnection == ScoConnection.IN_PROGRESS) { + audioManager.stopBluetoothSco(); + scoConnection = ScoConnection.DISCONNECTED; + } + } + } + + private void handleBluetoothStateChange() { + if (!destroyed.get()) { + boolean isBluetoothAvailable = isBluetoothAvailable(); + + if (!isBluetoothAvailable) { + setWantsConnection(false); + } + + if (listener != null) { + listener.onBluetoothStateChanged(isBluetoothAvailable); + } + } + } + + private boolean isBluetoothAvailable() { + try { + synchronized (LOCK) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) return false; + if (!audioManager.isBluetoothScoAvailableOffCall()) return false; + + return bluetoothHeadset != null && !bluetoothHeadset.getConnectedDevices().isEmpty(); + } + } catch (Exception e) { + Log.w(TAG, e); + return false; + } + } + + private String getScoChangeIntent() { + return AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED; + } + + + private void requestHeadsetProxyProfile() { + this.bluetoothAdapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + if (destroyed.get()) { + Log.w(TAG, "Got bluetooth profile event after the service was destroyed. Ignoring."); + return; + } + + if (profile == BluetoothProfile.HEADSET) { + synchronized (LOCK) { + bluetoothHeadset = (BluetoothHeadset) proxy; + } + + Intent sticky = context.registerReceiver(null, new IntentFilter(getScoChangeIntent())); + bluetoothScoReceiver.onReceive(context, sticky); + + synchronized (LOCK) { + if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + audioManager.startBluetoothSco(); + scoConnection = ScoConnection.IN_PROGRESS; + } + } + + handleBluetoothStateChange(); + } + } + + @Override + public void onServiceDisconnected(int profile) { + Log.i(TAG, "onServiceDisconnected"); + if (profile == BluetoothProfile.HEADSET) { + bluetoothHeadset = null; + handleBluetoothStateChange(); + } + } + }, BluetoothProfile.HEADSET); + } + + private class BluetoothScoReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + Log.i(TAG, "onReceive"); + + synchronized (LOCK) { + if (getScoChangeIntent().equals(intent.getAction())) { + int status = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR); + + if (status == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + if (bluetoothHeadset != null) { + List devices = bluetoothHeadset.getConnectedDevices(); + + for (BluetoothDevice device : devices) { + if (bluetoothHeadset.isAudioConnected(device)) { + scoConnection = ScoConnection.CONNECTED; + + if (wantsConnection) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + audioManager.setBluetoothScoOn(true); + } + } + } + } + } else if (status == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { + setWantsConnection(false); + } + } + } + + handleBluetoothStateChange(); + } + } + + private class BluetoothConnectionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "onReceive"); + handleBluetoothStateChange(); + } + } + + public interface BluetoothStateListener { + public void onBluetoothStateChanged(boolean isAvailable); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java new file mode 100644 index 00000000..57cd938f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.webrtc.audio; + + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.Vibrator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.util.ServiceUtil; + +import java.io.IOException; + +public class IncomingRinger { + + private static final String TAG = IncomingRinger.class.getSimpleName(); + + private static final long[] VIBRATE_PATTERN = {0, 1000, 1000}; + + private final Context context; + private final Vibrator vibrator; + + private MediaPlayer player; + + IncomingRinger(Context context) { + this.context = context.getApplicationContext(); + this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + + public void start(@Nullable Uri uri, boolean vibrate) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + + if (player != null) player.release(); + if (uri != null) player = createPlayer(uri); + + int ringerMode = audioManager.getRingerMode(); + + if (shouldVibrate(context, player, ringerMode, vibrate)) { + Log.i(TAG, "Starting vibration"); + vibrator.vibrate(VIBRATE_PATTERN, 1); + } + + if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) { + try { + if (!player.isPlaying()) { + player.prepare(); + player.start(); + Log.i(TAG, "Playing ringtone now..."); + } else { + Log.w(TAG, "Ringtone is already playing, declining to restart."); + } + } catch (IllegalStateException | IOException e) { + Log.w(TAG, e); + player = null; + } + } else { + Log.w(TAG, "Not ringing, mode: " + ringerMode); + } + } + + public void stop() { + if (player != null) { + Log.i(TAG, "Stopping ringer"); + player.release(); + player = null; + } + + Log.i(TAG, "Cancelling vibrator"); + vibrator.cancel(); + } + + private boolean shouldVibrate(Context context, MediaPlayer player, int ringerMode, boolean vibrate) { + if (player == null) { + return true; + } + + return shouldVibrateNew(context, ringerMode, vibrate); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private boolean shouldVibrateNew(Context context, int ringerMode, boolean vibrate) { + Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + + if (vibrator == null || !vibrator.hasVibrator()) { + return false; + } + + if (vibrate) { + return ringerMode != AudioManager.RINGER_MODE_SILENT; + } else { + return ringerMode == AudioManager.RINGER_MODE_VIBRATE; + } + } + + private boolean shouldVibrateOld(Context context, boolean vibrate) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + return vibrate && audioManager.shouldVibrate(AudioManager.VIBRATE_TYPE_RINGER); + } + + private MediaPlayer createPlayer(@NonNull Uri ringtoneUri) { + try { + MediaPlayer mediaPlayer = new MediaPlayer(); + + mediaPlayer.setOnErrorListener(new MediaPlayerErrorListener()); + mediaPlayer.setDataSource(context, ringtoneUri); + mediaPlayer.setLooping(true); + + if (Build.VERSION.SDK_INT <= 21) { + mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING); + } else { + mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING) + .build()); + } + + return mediaPlayer; + } catch (IOException e) { + Log.e(TAG, "Failed to create player for incoming call ringer"); + return null; + } + } + + + private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + Log.w(TAG, "onError(" + mp + ", " + what + ", " + extra); + player = null; + return false; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.java new file mode 100644 index 00000000..8d682b9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/OutgoingRinger.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.webrtc.audio; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; + +import java.io.IOException; + +public class OutgoingRinger { + + private static final String TAG = OutgoingRinger.class.getSimpleName(); + + public enum Type { + RINGING, + BUSY + } + + private final Context context; + + private MediaPlayer mediaPlayer; + + public OutgoingRinger(@NonNull Context context) { + this.context = context; + } + + public void start(Type type) { + int soundId; + + if (type == Type.RINGING) soundId = R.raw.redphone_outring; + else if (type == Type.BUSY) soundId = R.raw.redphone_busy; + else throw new IllegalArgumentException("Not a valid sound type"); + + if( mediaPlayer != null ) { + mediaPlayer.release(); + } + + mediaPlayer = new MediaPlayer(); + + if (Build.VERSION.SDK_INT <= 21) { + mediaPlayer.setAudioStreamType(AudioManager.STREAM_VOICE_CALL); + } else { + mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build()); + } + mediaPlayer.setLooping(true); + + String packageName = context.getPackageName(); + Uri dataUri = Uri.parse("android.resource://" + packageName + "/" + soundId); + + try { + mediaPlayer.setDataSource(context, dataUri); + mediaPlayer.prepare(); + mediaPlayer.start(); + } catch (IllegalArgumentException | SecurityException | IllegalStateException | IOException e) { + Log.w(TAG, e); + } + } + + public void stop() { + if (mediaPlayer == null) return; + mediaPlayer.release(); + mediaPlayer = null; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java new file mode 100644 index 00000000..cd2c4e19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.webrtc.audio; + + +import android.content.Context; +import android.media.AudioManager; +import android.media.SoundPool; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ServiceUtil; + +public class SignalAudioManager { + + @SuppressWarnings("unused") + private static final String TAG = SignalAudioManager.class.getSimpleName(); + + private final Context context; + private final IncomingRinger incomingRinger; + private final OutgoingRinger outgoingRinger; + + private final SoundPool soundPool; + private final int connectedSoundId; + private final int disconnectedSoundId; + + private final AudioManagerCompat audioManagerCompat; + + public SignalAudioManager(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.incomingRinger = new IncomingRinger(context); + this.outgoingRinger = new OutgoingRinger(context); + this.audioManagerCompat = AudioManagerCompat.create(context); + this.soundPool = audioManagerCompat.createSoundPool(); + + this.connectedSoundId = this.soundPool.load(context, R.raw.webrtc_completed, 1); + this.disconnectedSoundId = this.soundPool.load(context, R.raw.webrtc_disconnected, 1); + } + + public void initializeAudioForCall() { + audioManagerCompat.requestCallAudioFocus(); + } + + public void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + boolean speaker = !audioManager.isWiredHeadsetOn() && !audioManager.isBluetoothScoOn(); + + audioManager.setMode(AudioManager.MODE_RINGTONE); + audioManager.setMicrophoneMute(false); + audioManager.setSpeakerphoneOn(speaker); + + incomingRinger.start(ringtoneUri, vibrate); + } + + public void startOutgoingRinger(OutgoingRinger.Type type) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + audioManager.setMicrophoneMute(false); + + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + + outgoingRinger.start(type); + } + + public void silenceIncomingRinger() { + incomingRinger.stop(); + } + + public void startCommunication(boolean preserveSpeakerphone) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + + incomingRinger.stop(); + outgoingRinger.stop(); + + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + + if (!preserveSpeakerphone) { + audioManager.setSpeakerphoneOn(false); + } + + soundPool.play(connectedSoundId, 1.0f, 1.0f, 0, 0, 1.0f); + } + + public void stop(boolean playDisconnected) { + AudioManager audioManager = ServiceUtil.getAudioManager(context); + + incomingRinger.stop(); + outgoingRinger.stop(); + + if (playDisconnected) { + soundPool.play(disconnectedSoundId, 1.0f, 1.0f, 0, 0, 1.0f); + } + + audioManager.setMode(AudioManager.MODE_NORMAL); + + audioManagerCompat.abandonCallAudioFocus(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java new file mode 100644 index 00000000..5c30bfb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/AccelerometerListener.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.webrtc.locks; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import org.signal.core.util.logging.Log; + +/** + * This class is used to listen to the accelerometer to monitor the + * orientation of the phone. The client of this class is notified when + * the orientation changes between horizontal and vertical. + */ +public final class AccelerometerListener { + private static final String TAG = "AccelerometerListener"; + private static final boolean DEBUG = true; + private static final boolean VDEBUG = false; + + private SensorManager mSensorManager; + private Sensor mSensor; + + // mOrientation is the orientation value most recently reported to the client. + private int mOrientation; + + // mPendingOrientation is the latest orientation computed based on the sensor value. + // This is sent to the client after a rebounce delay, at which point it is copied to + // mOrientation. + private int mPendingOrientation; + + private OrientationListener mListener; + + // Device orientation + public static final int ORIENTATION_UNKNOWN = 0; + public static final int ORIENTATION_VERTICAL = 1; + public static final int ORIENTATION_HORIZONTAL = 2; + + private static final int ORIENTATION_CHANGED = 1234; + + private static final int VERTICAL_DEBOUNCE = 100; + private static final int HORIZONTAL_DEBOUNCE = 500; + private static final double VERTICAL_ANGLE = 50.0; + + public interface OrientationListener { + public void orientationChanged(int orientation); + } + + public AccelerometerListener(Context context, OrientationListener listener) { + mListener = listener; + mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); + mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + + public void enable(boolean enable) { + if (DEBUG) Log.d(TAG, "enable(" + enable + ")"); + synchronized (this) { + if (enable) { + mOrientation = ORIENTATION_UNKNOWN; + mPendingOrientation = ORIENTATION_UNKNOWN; + mSensorManager.registerListener(mSensorListener, mSensor, + SensorManager.SENSOR_DELAY_NORMAL); + } else { + mSensorManager.unregisterListener(mSensorListener); + mHandler.removeMessages(ORIENTATION_CHANGED); + } + } + } + + private void setOrientation(int orientation) { + synchronized (this) { + if (mPendingOrientation == orientation) { + // Pending orientation has not changed, so do nothing. + return; + } + + // Cancel any pending messages. + // We will either start a new timer or cancel alltogether + // if the orientation has not changed. + mHandler.removeMessages(ORIENTATION_CHANGED); + + if (mOrientation != orientation) { + // Set timer to send an event if the orientation has changed since its + // previously reported value. + mPendingOrientation = orientation; + Message m = mHandler.obtainMessage(ORIENTATION_CHANGED); + // set delay to our debounce timeout + int delay = (orientation == ORIENTATION_VERTICAL ? VERTICAL_DEBOUNCE + : HORIZONTAL_DEBOUNCE); + mHandler.sendMessageDelayed(m, delay); + } else { + // no message is pending + mPendingOrientation = ORIENTATION_UNKNOWN; + } + } + } + + private void onSensorEvent(double x, double y, double z) { + if (VDEBUG) Log.d(TAG, "onSensorEvent(" + x + ", " + y + ", " + z + ")"); + + // If some values are exactly zero, then likely the sensor is not powered up yet. + // ignore these events to avoid false horizontal positives. + if (x == 0.0 || y == 0.0 || z == 0.0) return; + + // magnitude of the acceleration vector projected onto XY plane + double xy = Math.sqrt(x * x + y * y); + // compute the vertical angle + double angle = Math.atan2(xy, z); + // convert to degrees + angle = angle * 180.0 / Math.PI; + int orientation = (angle > VERTICAL_ANGLE ? ORIENTATION_VERTICAL : ORIENTATION_HORIZONTAL); + if (VDEBUG) Log.d(TAG, "angle: " + angle + " orientation: " + orientation); + setOrientation(orientation); + } + + SensorEventListener mSensorListener = new SensorEventListener() { + public void onSensorChanged(SensorEvent event) { + onSensorEvent(event.values[0], event.values[1], event.values[2]); + } + + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // ignore + } + }; + + Handler mHandler = new Handler(Looper.getMainLooper()) { + public void handleMessage(Message msg) { + switch (msg.what) { + case ORIENTATION_CHANGED: + synchronized (this) { + mOrientation = mPendingOrientation; + if (DEBUG) { + Log.d(TAG, "orientation: " + + (mOrientation == ORIENTATION_HORIZONTAL ? "horizontal" + : (mOrientation == ORIENTATION_VERTICAL ? "vertical" + : "unknown"))); + } + mListener.orientationChanged(mOrientation); + } + break; + } + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java new file mode 100644 index 00000000..83be6fa5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.webrtc.locks; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.provider.Settings; + +import org.signal.core.util.logging.Log; + +/** + * Maintains wake lock state. + * + * @author Stuart O. Anderson + */ +public class LockManager { + + private static final String TAG = LockManager.class.getSimpleName(); + + private final PowerManager.WakeLock fullLock; + private final PowerManager.WakeLock partialLock; + private final WifiManager.WifiLock wifiLock; + private final ProximityLock proximityLock; + + private final AccelerometerListener accelerometerListener; + private final boolean wifiLockEnforced; + + + private int orientation = AccelerometerListener.ORIENTATION_UNKNOWN; + private boolean proximityDisabled = false; + + public enum PhoneState { + IDLE, + PROCESSING, //used when the phone is active but before the user should be alerted. + INTERACTIVE, + IN_CALL, + IN_HANDS_FREE_CALL, + IN_VIDEO + } + + private enum LockState { + FULL, + PARTIAL, + SLEEP, + PROXIMITY + } + + public LockManager(Context context) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + fullLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, "signal:full"); + partialLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:partial"); + proximityLock = new ProximityLock(pm); + + WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "signal:wifi"); + + fullLock.setReferenceCounted(false); + partialLock.setReferenceCounted(false); + wifiLock.setReferenceCounted(false); + + accelerometerListener = new AccelerometerListener(context, new AccelerometerListener.OrientationListener() { + @Override + public void orientationChanged(int newOrientation) { + orientation = newOrientation; + Log.d(TAG, "Orentation Update: " + newOrientation); + updateInCallLockState(); + } + }); + + wifiLockEnforced = isWifiPowerActiveModeEnabled(context); + } + + private boolean isWifiPowerActiveModeEnabled(Context context) { + int wifi_pwr_active_mode = Settings.Secure.getInt(context.getContentResolver(), "wifi_pwr_active_mode", -1); + Log.d(TAG, "Wifi Activity Policy: " + wifi_pwr_active_mode); + + if (wifi_pwr_active_mode == 0) { + return false; + } + + return true; + } + + private void updateInCallLockState() { + if (orientation != AccelerometerListener.ORIENTATION_HORIZONTAL && wifiLockEnforced && !proximityDisabled) { + setLockState(LockState.PROXIMITY); + } else { + setLockState(LockState.FULL); + } + } + + public void updatePhoneState(PhoneState state) { + switch(state) { + case IDLE: + setLockState(LockState.SLEEP); + accelerometerListener.enable(false); + break; + case PROCESSING: + setLockState(LockState.PARTIAL); + accelerometerListener.enable(false); + break; + case INTERACTIVE: + setLockState(LockState.FULL); + accelerometerListener.enable(false); + break; + case IN_HANDS_FREE_CALL: + setLockState(LockState.PARTIAL); + proximityDisabled = true; + accelerometerListener.enable(false); + break; + case IN_VIDEO: + proximityDisabled = true; + accelerometerListener.enable(false); + updateInCallLockState(); + break; + case IN_CALL: + proximityDisabled = false; + accelerometerListener.enable(true); + updateInCallLockState(); + break; + } + } + + private synchronized void setLockState(LockState newState) { + switch(newState) { + case FULL: + fullLock.acquire(); + partialLock.acquire(); + wifiLock.acquire(); + proximityLock.release(); + break; + case PARTIAL: + partialLock.acquire(); + wifiLock.acquire(); + fullLock.release(); + proximityLock.release(); + break; + case SLEEP: + fullLock.release(); + partialLock.release(); + wifiLock.release(); + proximityLock.release(); + break; + case PROXIMITY: + partialLock.acquire(); + proximityLock.acquire(); + wifiLock.acquire(); + fullLock.release(); + break; + default: + throw new IllegalArgumentException("Unhandled Mode: " + newState); + } + Log.d(TAG, "Entered Lock State: " + newState); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java new file mode 100644 index 00000000..d732eef2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/ProximityLock.java @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.webrtc.locks; + +import android.os.Build; +import android.os.PowerManager; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Controls access to the proximity lock. + * The proximity lock is not part of the public API. + * + * @author Stuart O. Anderson +*/ +class ProximityLock { + + private static final String TAG = ProximityLock.class.getSimpleName(); + + private final Method wakelockParameterizedRelease = getWakelockParamterizedReleaseMethod(); + private final Optional proximityLock; + + private static final int PROXIMITY_SCREEN_OFF_WAKE_LOCK = 32; + private static final int WAIT_FOR_PROXIMITY_NEGATIVE = 1; + + ProximityLock(PowerManager pm) { + proximityLock = getProximityLock(pm); + } + + private Optional getProximityLock(PowerManager pm) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (pm.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + return Optional.fromNullable(pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "signal:proximity")); + } else { + return Optional.absent(); + } + } else { + try { + return Optional.fromNullable(pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "signal:incall")); + } catch (Throwable t) { + Log.e(TAG, "Failed to create proximity lock", t); + return Optional.absent(); + } + } + } + + public void acquire() { + if (!proximityLock.isPresent() || proximityLock.get().isHeld()) { + return; + } + + proximityLock.get().acquire(); + } + + public void release() { + if (!proximityLock.isPresent() || !proximityLock.get().isHeld()) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + proximityLock.get().release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); + } else { + boolean released = false; + + if (wakelockParameterizedRelease != null) { + try { + wakelockParameterizedRelease.invoke(proximityLock.get(), WAIT_FOR_PROXIMITY_NEGATIVE); + released = true; + } catch (IllegalAccessException e) { + Log.w(TAG, e); + } catch (InvocationTargetException e) { + Log.w(TAG, e); + } + } + + if (!released) { + proximityLock.get().release(); + } + } + + Log.d(TAG, "Released proximity lock:" + proximityLock.get().isHeld()); + } + + private static Method getWakelockParamterizedReleaseMethod() { + try { + return PowerManager.WakeLock.class.getDeclaredMethod("release", Integer.TYPE); + } catch (NoSuchMethodException e) { + Log.d(TAG, "Parameterized WakeLock release not available on this device."); + } + return null; + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libnative-utils.so b/app/src/main/jniLibs/arm64-v8a/libnative-utils.so new file mode 100644 index 00000000..0d84ac84 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libnative-utils.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libnative-utils.so b/app/src/main/jniLibs/armeabi-v7a/libnative-utils.so new file mode 100644 index 00000000..a6da60b8 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libnative-utils.so differ diff --git a/app/src/main/jniLibs/x86/libnative-utils.so b/app/src/main/jniLibs/x86/libnative-utils.so new file mode 100644 index 00000000..882e67e5 Binary files /dev/null and b/app/src/main/jniLibs/x86/libnative-utils.so differ diff --git a/app/src/main/jniLibs/x86_64/libnative-utils.so b/app/src/main/jniLibs/x86_64/libnative-utils.so new file mode 100644 index 00000000..fc03a67d Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libnative-utils.so differ diff --git a/app/src/main/proto/Backups.proto b/app/src/main/proto/Backups.proto new file mode 100644 index 00000000..925ea267 --- /dev/null +++ b/app/src/main/proto/Backups.proto @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2018 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto2"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.backup"; +option java_outer_classname = "BackupProtos"; + +message SqlStatement { + message SqlParameter { + optional string stringParamter = 1; + optional uint64 integerParameter = 2; + optional double doubleParameter = 3; + optional bytes blobParameter = 4; + optional bool nullparameter = 5; + } + + optional string statement = 1; + repeated SqlParameter parameters = 2; +} + +message SharedPreference { + optional string file = 1; + optional string key = 2; + optional string value = 3; +} + +message Attachment { + optional uint64 rowId = 1; + optional uint64 attachmentId = 2; + optional uint32 length = 3; +} + +message Sticker { + optional uint64 rowId = 1; + optional uint32 length = 2; +} + +message Avatar { + optional string name = 1; + optional string recipientId = 3; + optional uint32 length = 2; +} + +message DatabaseVersion { + optional uint32 version = 1; +} + +message Header { + optional bytes iv = 1; + optional bytes salt = 2; +} + +message BackupFrame { + optional Header header = 1; + optional SqlStatement statement = 2; + optional SharedPreference preference = 3; + optional Attachment attachment = 4; + optional DatabaseVersion version = 5; + optional bool end = 6; + optional Avatar avatar = 7; + optional Sticker sticker = 8; +} \ No newline at end of file diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto new file mode 100644 index 00000000..bbf45539 --- /dev/null +++ b/app/src/main/proto/Database.proto @@ -0,0 +1,117 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.database.model.databaseprotos"; +option java_multiple_files = true; + + +message ReactionList { + message Reaction { + string emoji = 1; + uint64 author = 2; + uint64 sentTime = 3; + uint64 receivedTime = 4; + } + + repeated Reaction reactions = 1; +} + + +import "SignalService.proto"; +import "DecryptedGroups.proto"; + +message DecryptedGroupV2Context { + signalservice.GroupContextV2 context = 1; + DecryptedGroupChange change = 2; + DecryptedGroup groupState = 3; + DecryptedGroup previousGroupState = 4; +} + +message TemporalAuthCredentialResponse { + int32 date = 1; + bytes authCredentialResponse = 2; +} + +message TemporalAuthCredentialResponses { + repeated TemporalAuthCredentialResponse credentialResponse = 1; +} + +message AudioWaveFormData { + int64 durationUs = 1; + bytes waveForm = 2; +} + +message ProfileChangeDetails { + message StringChange { + string previous = 1; + string new = 2; + } + + StringChange profileNameChange = 1; +} + +message BodyRangeList { + message BodyRange { + int32 start = 1; + int32 length = 2; + + oneof associatedValue { + string mentionUuid = 3; + } + } + + repeated BodyRange ranges = 1; +} + +message GroupCallUpdateDetails { + string eraId = 1; + string startedCallUuid = 2; + int64 startedCallTimestamp = 3; + repeated string inCallUuids = 4; + bool isCallFull = 5; +} + +message ProfileKeyCredentialColumnData { + bytes profileKey = 1; + bytes profileKeyCredential = 2; +} + +message DeviceLastResetTime { + message Pair { + int32 deviceId = 1; + int64 lastResetTime = 2; + } + + repeated Pair resetTime = 1; +} + +message Wallpaper { + message SingleColor { + int32 color = 1; + } + + message LinearGradient { + float rotation = 1; + repeated int32 colors = 2; + repeated float positions = 3; + } + + message File { + string uri = 1; + } + + oneof wallpaper { + SingleColor singleColor = 1; + LinearGradient linearGradient = 2; + File file = 3; + } + + float dimLevelInDarkTheme = 4; +} \ No newline at end of file diff --git a/app/src/main/proto/DeviceName.proto b/app/src/main/proto/DeviceName.proto new file mode 100644 index 00000000..ef008842 --- /dev/null +++ b/app/src/main/proto/DeviceName.proto @@ -0,0 +1,18 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto2"; + +package signalservice; + +option java_package = "org.thoughtcrime.securesms.devicelist"; +option java_outer_classname = "DeviceNameProtos"; + +message DeviceName { + optional bytes ephemeralPublic = 1; + optional bytes syntheticIv = 2; + optional bytes ciphertext = 3; +} diff --git a/app/src/main/res/anim-ldrtl/slide_from_end.xml b/app/src/main/res/anim-ldrtl/slide_from_end.xml new file mode 100644 index 00000000..7e00a027 --- /dev/null +++ b/app/src/main/res/anim-ldrtl/slide_from_end.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim-ldrtl/slide_from_start.xml b/app/src/main/res/anim-ldrtl/slide_from_start.xml new file mode 100644 index 00000000..feeaaf75 --- /dev/null +++ b/app/src/main/res/anim-ldrtl/slide_from_start.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim-ldrtl/slide_to_end.xml b/app/src/main/res/anim-ldrtl/slide_to_end.xml new file mode 100644 index 00000000..8fd13ee4 --- /dev/null +++ b/app/src/main/res/anim-ldrtl/slide_to_end.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim-ldrtl/slide_to_start.xml b/app/src/main/res/anim-ldrtl/slide_to_start.xml new file mode 100644 index 00000000..0a33f3dd --- /dev/null +++ b/app/src/main/res/anim-ldrtl/slide_to_start.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/animation_toggle_in.xml b/app/src/main/res/anim/animation_toggle_in.xml new file mode 100644 index 00000000..ffffd02d --- /dev/null +++ b/app/src/main/res/anim/animation_toggle_in.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/animation_toggle_out.xml b/app/src/main/res/anim/animation_toggle_out.xml new file mode 100644 index 00000000..1e6f7ed6 --- /dev/null +++ b/app/src/main/res/anim/animation_toggle_out.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/camera_capture_button_grow.xml b/app/src/main/res/anim/camera_capture_button_grow.xml new file mode 100644 index 00000000..e376f573 --- /dev/null +++ b/app/src/main/res/anim/camera_capture_button_grow.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/camera_capture_button_shrink.xml b/app/src/main/res/anim/camera_capture_button_shrink.xml new file mode 100644 index 00000000..45e82ca6 --- /dev/null +++ b/app/src/main/res/anim/camera_capture_button_shrink.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/camera_slide_from_bottom.xml b/app/src/main/res/anim/camera_slide_from_bottom.xml new file mode 100644 index 00000000..5d7343cf --- /dev/null +++ b/app/src/main/res/anim/camera_slide_from_bottom.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/camera_slide_to_bottom.xml b/app/src/main/res/anim/camera_slide_to_bottom.xml new file mode 100644 index 00000000..d50cccb1 --- /dev/null +++ b/app/src/main/res/anim/camera_slide_to_bottom.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 00000000..508f8be3 --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 00000000..e8f16d01 --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/anim/fade_scale_in.xml b/app/src/main/res/anim/fade_scale_in.xml new file mode 100644 index 00000000..0f2def07 --- /dev/null +++ b/app/src/main/res/anim/fade_scale_in.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_scale_out.xml b/app/src/main/res/anim/fade_scale_out.xml new file mode 100644 index 00000000..2ee72907 --- /dev/null +++ b/app/src/main/res/anim/fade_scale_out.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_bottom.xml b/app/src/main/res/anim/slide_from_bottom.xml new file mode 100644 index 00000000..a6febc21 --- /dev/null +++ b/app/src/main/res/anim/slide_from_bottom.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_end.xml b/app/src/main/res/anim/slide_from_end.xml new file mode 100644 index 00000000..feeaaf75 --- /dev/null +++ b/app/src/main/res/anim/slide_from_end.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_start.xml b/app/src/main/res/anim/slide_from_start.xml new file mode 100644 index 00000000..7e00a027 --- /dev/null +++ b/app/src/main/res/anim/slide_from_start.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_top.xml b/app/src/main/res/anim/slide_from_top.xml new file mode 100644 index 00000000..761b9151 --- /dev/null +++ b/app/src/main/res/anim/slide_from_top.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_bottom.xml b/app/src/main/res/anim/slide_to_bottom.xml new file mode 100644 index 00000000..98e2232c --- /dev/null +++ b/app/src/main/res/anim/slide_to_bottom.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_end.xml b/app/src/main/res/anim/slide_to_end.xml new file mode 100644 index 00000000..0a33f3dd --- /dev/null +++ b/app/src/main/res/anim/slide_to_end.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_start.xml b/app/src/main/res/anim/slide_to_start.xml new file mode 100644 index 00000000..8fd13ee4 --- /dev/null +++ b/app/src/main/res/anim/slide_to_start.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_top.xml b/app/src/main/res/anim/slide_to_top.xml new file mode 100644 index 00000000..cc204acb --- /dev/null +++ b/app/src/main/res/anim/slide_to_top.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/stationary.xml b/app/src/main/res/anim/stationary.xml new file mode 100644 index 00000000..92cf98d6 --- /dev/null +++ b/app/src/main/res/anim/stationary.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/reactions_scrubber_hide.xml b/app/src/main/res/animator/reactions_scrubber_hide.xml new file mode 100644 index 00000000..bfaf01ea --- /dev/null +++ b/app/src/main/res/animator/reactions_scrubber_hide.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/reactions_scrubber_reveal.xml b/app/src/main/res/animator/reactions_scrubber_reveal.xml new file mode 100644 index 00000000..1b753fda --- /dev/null +++ b/app/src/main/res/animator/reactions_scrubber_reveal.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color-night/help_fragment_next.xml b/app/src/main/res/color-night/help_fragment_next.xml new file mode 100644 index 00000000..8fd6e060 --- /dev/null +++ b/app/src/main/res/color-night/help_fragment_next.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/core_green_text_button.xml b/app/src/main/res/color/core_green_text_button.xml new file mode 100644 index 00000000..45111a23 --- /dev/null +++ b/app/src/main/res/color/core_green_text_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/help_fragment_next.xml b/app/src/main/res/color/help_fragment_next.xml new file mode 100644 index 00000000..84421ed1 --- /dev/null +++ b/app/src/main/res/color/help_fragment_next.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/signal_button_primary_text_selector.xml b/app/src/main/res/color/signal_button_primary_text_selector.xml new file mode 100644 index 00000000..d09719de --- /dev/null +++ b/app/src/main/res/color/signal_button_primary_text_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/signal_button_secondary_text_selector.xml b/app/src/main/res/color/signal_button_secondary_text_selector.xml new file mode 100644 index 00000000..92f65579 --- /dev/null +++ b/app/src/main/res/color/signal_button_secondary_text_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_accent_enabled_selector.xml b/app/src/main/res/color/text_color_accent_enabled_selector.xml new file mode 100644 index 00000000..e9d6beac --- /dev/null +++ b/app/src/main/res/color/text_color_accent_enabled_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_dark_theme.xml b/app/src/main/res/color/text_color_dark_theme.xml new file mode 100644 index 00000000..224c4d4a --- /dev/null +++ b/app/src/main/res/color/text_color_dark_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_light_theme.xml b/app/src/main/res/color/text_color_light_theme.xml new file mode 100644 index 00000000..6eb49ffc --- /dev/null +++ b/app/src/main/res/color/text_color_light_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_primary_enabled_selector.xml b/app/src/main/res/color/text_color_primary_enabled_selector.xml new file mode 100644 index 00000000..84a7fdf4 --- /dev/null +++ b/app/src/main/res/color/text_color_primary_enabled_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_secondary_dark_theme.xml b/app/src/main/res/color/text_color_secondary_dark_theme.xml new file mode 100644 index 00000000..1ce14a89 --- /dev/null +++ b/app/src/main/res/color/text_color_secondary_dark_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_secondary_enabled_selector.xml b/app/src/main/res/color/text_color_secondary_enabled_selector.xml new file mode 100644 index 00000000..ec930105 --- /dev/null +++ b/app/src/main/res/color/text_color_secondary_enabled_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/text_color_secondary_light_theme.xml b/app/src/main/res/color/text_color_secondary_light_theme.xml new file mode 100644 index 00000000..5f5cbf81 --- /dev/null +++ b/app/src/main/res/color/text_color_secondary_light_theme.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/ultramarine_text_button.xml b/app/src/main/res/color/ultramarine_text_button.xml new file mode 100644 index 00000000..62c66430 --- /dev/null +++ b/app/src/main/res/color/ultramarine_text_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/baseline_account_circle_white_24.webp b/app/src/main/res/drawable-hdpi/baseline_account_circle_white_24.webp new file mode 100644 index 00000000..da28de18 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_account_circle_white_24.webp differ diff --git a/app/src/main/res/drawable-hdpi/baseline_email_white_24.webp b/app/src/main/res/drawable-hdpi/baseline_email_white_24.webp new file mode 100644 index 00000000..81444d4a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_email_white_24.webp differ diff --git a/app/src/main/res/drawable-hdpi/check.webp b/app/src/main/res/drawable-hdpi/check.webp new file mode 100644 index 00000000..007bfc40 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/check.webp differ diff --git a/app/src/main/res/drawable-hdpi/clear_profile_avatar.webp b/app/src/main/res/drawable-hdpi/clear_profile_avatar.webp new file mode 100644 index 00000000..5e8f2924 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/clear_profile_avatar.webp differ diff --git a/app/src/main/res/drawable-hdpi/flash_auto_32.webp b/app/src/main/res/drawable-hdpi/flash_auto_32.webp new file mode 100644 index 00000000..b2f9129a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/flash_auto_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/flash_off_32.webp b/app/src/main/res/drawable-hdpi/flash_off_32.webp new file mode 100644 index 00000000..83133ae8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/flash_off_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/flash_on_32.webp b/app/src/main/res/drawable-hdpi/flash_on_32.webp new file mode 100644 index 00000000..65a6ee20 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/flash_on_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_account_box.webp b/app/src/main/res/drawable-hdpi/ic_account_box.webp new file mode 100644 index 00000000..7e89a9a4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_warning_red.webp b/app/src/main/res/drawable-hdpi/ic_action_warning_red.webp new file mode 100644 index 00000000..566f486d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_warning_red.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_add_white_original_24dp.webp b/app/src/main/res/drawable-hdpi/ic_add_white_original_24dp.webp new file mode 100644 index 00000000..544145e9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_white_original_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-hdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..439083bd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_right.webp b/app/src/main/res/drawable-hdpi/ic_arrow_right.webp new file mode 100644 index 00000000..c984c8e7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_arrow_right.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_audio.webp b/app/src/main/res/drawable-hdpi/ic_audio.webp new file mode 100644 index 00000000..b4b5d301 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png new file mode 100644 index 00000000..a007fb4c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.webp b/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.webp new file mode 100644 index 00000000..0bd6cc2b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_block_white_18dp.webp b/app/src/main/res/drawable-hdpi/ic_block_white_18dp.webp new file mode 100644 index 00000000..c2a59fa0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_block_white_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_block_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_block_white_24dp.webp new file mode 100644 index 00000000..7d1f35b3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_block_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_broken_link.webp b/app/src/main/res/drawable-hdpi/ic_broken_link.webp new file mode 100644 index 00000000..4db5a3df Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_broken_link.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp b/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp new file mode 100644 index 00000000..fad9c778 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_brush_marker_32.webp b/app/src/main/res/drawable-hdpi/ic_brush_marker_32.webp new file mode 100644 index 00000000..fd4b6e8b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_brush_marker_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-hdpi/ic_call_end_grey600_32dp.webp new file mode 100644 index 00000000..de1b0c84 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_end_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_call_end_white_48dp.webp new file mode 100644 index 00000000..6ac9f58b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_end_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_made_grey600_24dp.webp b/app/src/main/res/drawable-hdpi/ic_call_made_grey600_24dp.webp new file mode 100644 index 00000000..3a458e08 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_made_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_missed_grey600_24dp.webp b/app/src/main/res/drawable-hdpi/ic_call_missed_grey600_24dp.webp new file mode 100644 index 00000000..24e225d8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_missed_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_received_grey600_24dp.webp b/app/src/main/res/drawable-hdpi/ic_call_received_grey600_24dp.webp new file mode 100644 index 00000000..6ce436b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_received_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_secure_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_call_secure_white_24dp.webp new file mode 100644 index 00000000..d7c45d92 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_secure_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_call_split_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_call_split_white_24dp.webp new file mode 100644 index 00000000..10b8113f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_call_split_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_caption_28.webp b/app/src/main/res/drawable-hdpi/ic_caption_28.webp new file mode 100644 index 00000000..1bf447ac Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_caption_28.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_circle_32.webp b/app/src/main/res/drawable-hdpi/ic_check_circle_32.webp new file mode 100644 index 00000000..c8f032e9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_circle_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_circle_white_18dp.webp b/app/src/main/res/drawable-hdpi/ic_check_circle_white_18dp.webp new file mode 100644 index 00000000..fe3141fc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_circle_white_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_check_white_24dp.webp new file mode 100644 index 00000000..4c18f6f5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_check_white_48dp.webp new file mode 100644 index 00000000..5b995997 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.webp new file mode 100644 index 00000000..43a36dfd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-hdpi/ic_close_grey600_32dp.webp new file mode 100644 index 00000000..4d421ff9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_18dp.webp b/app/src/main/res/drawable-hdpi/ic_close_white_18dp.webp new file mode 100644 index 00000000..63a6bf18 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_close_white_24dp.webp new file mode 100644 index 00000000..1f97cd41 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_close_white_48dp.webp new file mode 100644 index 00000000..1ae09899 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_contact_picture.webp b/app/src/main/res/drawable-hdpi/ic_contact_picture.webp new file mode 100644 index 00000000..6d1f5e32 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_contact_picture.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_contact_picture_large.webp b/app/src/main/res/drawable-hdpi/ic_contact_picture_large.webp new file mode 100644 index 00000000..53c8929c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_contact_picture_large.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_contacts_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_contacts_white_48dp.webp new file mode 100644 index 00000000..0d2d887f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_contacts_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_create_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_create_white_24dp.webp new file mode 100644 index 00000000..236efc9c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_create_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_32.webp b/app/src/main/res/drawable-hdpi/ic_crop_32.webp new file mode 100644 index 00000000..9bb129a9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_crop_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_lock_32.webp b/app/src/main/res/drawable-hdpi/ic_crop_lock_32.webp new file mode 100644 index 00000000..9860809b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_crop_lock_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.webp b/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.webp new file mode 100644 index 00000000..c5b68984 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.webp b/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.webp new file mode 100644 index 00000000..a3ed7edf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_read.webp b/app/src/main/res/drawable-hdpi/ic_delivery_status_read.webp new file mode 100644 index 00000000..19525c31 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delivery_status_read.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.webp b/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.webp new file mode 100644 index 00000000..10ccbb0e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.webp b/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.webp new file mode 100644 index 00000000..b3a33476 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_devices_white.webp b/app/src/main/res/drawable-hdpi/ic_devices_white.webp new file mode 100644 index 00000000..6ac1da6e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_devices_white.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_document_large.webp b/app/src/main/res/drawable-hdpi/ic_document_large.webp new file mode 100644 index 00000000..cb51be04 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_document_small.webp b/app/src/main/res/drawable-hdpi/ic_document_small.webp new file mode 100644 index 00000000..7c638e3e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_download_32.png b/app/src/main/res/drawable-hdpi/ic_download_32.png new file mode 100644 index 00000000..38e7786c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_download_32.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_download_circle_fill_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_download_circle_fill_white_48dp.webp new file mode 100644 index 00000000..fbdd7b41 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_download_circle_fill_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_error.webp b/app/src/main/res/drawable-hdpi/ic_error.webp new file mode 100644 index 00000000..7d60a6e6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_error.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_face_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_face_white_24dp.webp new file mode 100644 index 00000000..e2ab21b8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_face_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_favorite_grey600_24dp.webp b/app/src/main/res/drawable-hdpi/ic_favorite_grey600_24dp.webp new file mode 100644 index 00000000..a4874787 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_favorite_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_fingerprint_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_fingerprint_white_48dp.webp new file mode 100644 index 00000000..5c7abb07 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_fingerprint_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_flip_32.webp b/app/src/main/res/drawable-hdpi/ic_flip_32.webp new file mode 100644 index 00000000..749ec606 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_flip_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_folder_white_48dp.webp new file mode 100644 index 00000000..361edbb3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_image_editor_blur.png b/app/src/main/res/drawable-hdpi/ic_image_editor_blur.png new file mode 100644 index 00000000..77606a77 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_image_editor_blur.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline.webp b/app/src/main/res/drawable-hdpi/ic_info_outline.webp new file mode 100644 index 00000000..dd8ff594 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.webp new file mode 100644 index 00000000..879e382d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..b269b869 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white_36dp.webp b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white_36dp.webp new file mode 100644 index 00000000..0d77a622 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white_36dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_launch_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_launch_white_24dp.webp new file mode 100644 index 00000000..00fb672b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launch_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.webp new file mode 100644 index 00000000..72c634d2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_lock_white_24dp.webp new file mode 100644 index 00000000..b3ef62a6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_lock_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_lock_white_48dp.webp b/app/src/main/res/drawable-hdpi/ic_lock_white_48dp.webp new file mode 100644 index 00000000..efb7f01a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_lock_white_48dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_add_field_holo_light.webp b/app/src/main/res/drawable-hdpi/ic_menu_add_field_holo_light.webp new file mode 100644 index 00000000..1b3f8bbe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_add_field_holo_light.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.webp b/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.webp new file mode 100644 index 00000000..cb5c5dd7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_login.webp b/app/src/main/res/drawable-hdpi/ic_menu_login.webp new file mode 100644 index 00000000..c01bd300 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_login.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_search_holo_light.webp b/app/src/main/res/drawable-hdpi/ic_menu_search_holo_light.webp new file mode 100644 index 00000000..dff4d4c4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_search_holo_light.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.webp b/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.webp new file mode 100644 index 00000000..03981ed4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 00000000..fb6d1c25 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_person_add_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_person_add_white_24dp.webp new file mode 100644 index 00000000..0489ddd6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_person_add_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_person_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_person_white_24dp.webp new file mode 100644 index 00000000..65cabe34 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_person_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-hdpi/ic_phone_grey600_32dp.webp new file mode 100644 index 00000000..014a16dd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_photo_library_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_photo_library_white_24dp.webp new file mode 100644 index 00000000..27ea8ab3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_photo_library_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_plus_28.webp b/app/src/main/res/drawable-hdpi/ic_plus_28.webp new file mode 100644 index 00000000..ec7a96b7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_plus_28.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_reply.webp b/app/src/main/res/drawable-hdpi/ic_reply.webp new file mode 100644 index 00000000..bd9741a6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_reply.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.webp b/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.webp new file mode 100644 index 00000000..e7330613 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_rotate_32.webp b/app/src/main/res/drawable-hdpi/ic_rotate_32.webp new file mode 100644 index 00000000..46f9698e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_rotate_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_select_all_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_select_all_white_24dp.webp new file mode 100644 index 00000000..a1f84dfc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_select_all_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_select_off.webp b/app/src/main/res/drawable-hdpi/ic_select_off.webp new file mode 100644 index 00000000..861e7c30 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_select_off.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_select_on.webp b/app/src/main/res/drawable-hdpi/ic_select_on.webp new file mode 100644 index 00000000..f09a0a35 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_select_on.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_share_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_share_white_24dp.webp new file mode 100644 index 00000000..4970cb14 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_signal_background_connection.webp b/app/src/main/res/drawable-hdpi/ic_signal_background_connection.webp new file mode 100644 index 00000000..4f54a250 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_signal_background_connection.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_signal_backup.webp b/app/src/main/res/drawable-hdpi/ic_signal_backup.webp new file mode 100644 index 00000000..4bd21477 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_signal_backup.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_speaker_solid_24.xml b/app/src/main/res/drawable-hdpi/ic_speaker_solid_24.xml new file mode 100644 index 00000000..fe4c351f --- /dev/null +++ b/app/src/main/res/drawable-hdpi/ic_speaker_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-hdpi/ic_sticker_32.webp b/app/src/main/res/drawable-hdpi/ic_sticker_32.webp new file mode 100644 index 00000000..09d338c3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sticker_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_switch_camera_32.webp b/app/src/main/res/drawable-hdpi/ic_switch_camera_32.webp new file mode 100644 index 00000000..46560ab4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_switch_camera_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_text_32.webp b/app/src/main/res/drawable-hdpi/ic_text_32.webp new file mode 100644 index 00000000..3f0d7b02 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_text_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_trash_filled_32.webp b/app/src/main/res/drawable-hdpi/ic_trash_filled_32.webp new file mode 100644 index 00000000..5adc7f92 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_trash_filled_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.webp new file mode 100644 index 00000000..479a9953 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_unarchive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_unarchive_white_36dp.webp b/app/src/main/res/drawable-hdpi/ic_unarchive_white_36dp.webp new file mode 100644 index 00000000..0c9520e9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_unarchive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_undo_32.webp b/app/src/main/res/drawable-hdpi/ic_undo_32.webp new file mode 100644 index 00000000..5c0b44eb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_undo_32.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_unidentified_delivery.webp b/app/src/main/res/drawable-hdpi/ic_unidentified_delivery.webp new file mode 100644 index 00000000..039edb0d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_unidentified_delivery.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_unlocked_white_18dp.webp b/app/src/main/res/drawable-hdpi/ic_unlocked_white_18dp.webp new file mode 100644 index 00000000..201e472f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_unlocked_white_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_video.webp b/app/src/main/res/drawable-hdpi/ic_video.webp new file mode 100644 index 00000000..1fd5eab0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_view_infinite_32.png b/app/src/main/res/drawable-hdpi/ic_view_infinite_32.png new file mode 100644 index 00000000..983bee58 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_view_infinite_32.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_view_once_32.png b/app/src/main/res/drawable-hdpi/ic_view_once_32.png new file mode 100644 index 00000000..16ddff5f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_view_once_32.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-hdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..15c4e73c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-hdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..1a5fc9d6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp new file mode 100644 index 00000000..1df83242 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.webp b/app/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.webp new file mode 100644 index 00000000..17e391f5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_off_white_18dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_warning.webp b/app/src/main/res/drawable-hdpi/ic_warning.webp new file mode 100644 index 00000000..685af09f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-hdpi/ic_x_28.webp b/app/src/main/res/drawable-hdpi/ic_x_28.webp new file mode 100644 index 00000000..063ab827 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_x_28.webp differ diff --git a/app/src/main/res/drawable-hdpi/icon_cached.webp b/app/src/main/res/drawable-hdpi/icon_cached.webp new file mode 100644 index 00000000..3c0cb945 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/icon_cached.webp differ diff --git a/app/src/main/res/drawable-hdpi/icon_dialog.webp b/app/src/main/res/drawable-hdpi/icon_dialog.webp new file mode 100644 index 00000000..cf4e7e85 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/icon_dialog.webp differ diff --git a/app/src/main/res/drawable-hdpi/import_database.webp b/app/src/main/res/drawable-hdpi/import_database.webp new file mode 100644 index 00000000..7d1779cb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/import_database.webp differ diff --git a/app/src/main/res/drawable-hdpi/marker_shadow.webp b/app/src/main/res/drawable-hdpi/marker_shadow.webp new file mode 100644 index 00000000..59217c52 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/marker_shadow.webp differ diff --git a/app/src/main/res/drawable-hdpi/megaphone_notifications_64.webp b/app/src/main/res/drawable-hdpi/megaphone_notifications_64.webp new file mode 100644 index 00000000..fc819496 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/megaphone_notifications_64.webp differ diff --git a/app/src/main/res/drawable-hdpi/message_24dp.webp b/app/src/main/res/drawable-hdpi/message_24dp.webp new file mode 100644 index 00000000..457b497f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/message_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/no_contacts.png b/app/src/main/res/drawable-hdpi/no_contacts.png new file mode 100644 index 00000000..941759dd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/no_contacts.png differ diff --git a/app/src/main/res/drawable-hdpi/notify_panel_notification_icon_bg.webp b/app/src/main/res/drawable-hdpi/notify_panel_notification_icon_bg.webp new file mode 100644 index 00000000..a39d3563 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notify_panel_notification_icon_bg.webp differ diff --git a/app/src/main/res/drawable-hdpi/phone_24dp.webp b/app/src/main/res/drawable-hdpi/phone_24dp.webp new file mode 100644 index 00000000..50fe3376 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/phone_24dp.webp differ diff --git a/app/src/main/res/drawable-hdpi/poweredby_giphy.webp b/app/src/main/res/drawable-hdpi/poweredby_giphy.webp new file mode 100644 index 00000000..09752ab5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/poweredby_giphy.webp differ diff --git a/app/src/main/res/drawable-hdpi/signal_research.webp b/app/src/main/res/drawable-hdpi/signal_research.webp new file mode 100644 index 00000000..6a0648ac Binary files /dev/null and b/app/src/main/res/drawable-hdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-hdpi/welcome.webp b/app/src/main/res/drawable-hdpi/welcome.webp new file mode 100644 index 00000000..4ad7c3da Binary files /dev/null and b/app/src/main/res/drawable-hdpi/welcome.webp differ diff --git a/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..7aee3d20 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..369f2014 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..8003d672 Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..2877169a Binary files /dev/null and b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/baseline_account_circle_white_24.webp b/app/src/main/res/drawable-mdpi/baseline_account_circle_white_24.webp new file mode 100644 index 00000000..44f3c39c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_account_circle_white_24.webp differ diff --git a/app/src/main/res/drawable-mdpi/baseline_email_white_24.webp b/app/src/main/res/drawable-mdpi/baseline_email_white_24.webp new file mode 100644 index 00000000..9a6678f1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_email_white_24.webp differ diff --git a/app/src/main/res/drawable-mdpi/check.webp b/app/src/main/res/drawable-mdpi/check.webp new file mode 100644 index 00000000..3b9b3209 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/check.webp differ diff --git a/app/src/main/res/drawable-mdpi/clear_profile_avatar.webp b/app/src/main/res/drawable-mdpi/clear_profile_avatar.webp new file mode 100644 index 00000000..4b988a5f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/clear_profile_avatar.webp differ diff --git a/app/src/main/res/drawable-mdpi/flash_auto_32.webp b/app/src/main/res/drawable-mdpi/flash_auto_32.webp new file mode 100644 index 00000000..b92093f0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/flash_auto_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/flash_off_32.webp b/app/src/main/res/drawable-mdpi/flash_off_32.webp new file mode 100644 index 00000000..5751024a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/flash_off_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/flash_on_32.webp b/app/src/main/res/drawable-mdpi/flash_on_32.webp new file mode 100644 index 00000000..386de74f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/flash_on_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_account_box.webp b/app/src/main/res/drawable-mdpi/ic_account_box.webp new file mode 100644 index 00000000..6dfeb6b1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_warning_red.webp b/app/src/main/res/drawable-mdpi/ic_action_warning_red.webp new file mode 100644 index 00000000..27110de5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_warning_red.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_add_white_original_24dp.webp b/app/src/main/res/drawable-mdpi/ic_add_white_original_24dp.webp new file mode 100644 index 00000000..d6aa25be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_white_original_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_archive_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_archive_white_24dp.webp new file mode 100644 index 00000000..80033275 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_archive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-mdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..edade98d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_right.webp b/app/src/main/res/drawable-mdpi/ic_arrow_right.webp new file mode 100644 index 00000000..970a3704 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_arrow_right.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_audio.png b/app/src/main/res/drawable-mdpi/ic_audio.png new file mode 100644 index 00000000..fd2d3d06 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_audio.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png new file mode 100644 index 00000000..46da3d5a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.webp b/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.webp new file mode 100644 index 00000000..08afbc9e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_block_white_18dp.webp b/app/src/main/res/drawable-mdpi/ic_block_white_18dp.webp new file mode 100644 index 00000000..cb1173ca Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_block_white_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_block_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_block_white_24dp.webp new file mode 100644 index 00000000..47b6ebfb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_block_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_broken_link.webp b/app/src/main/res/drawable-mdpi/ic_broken_link.webp new file mode 100644 index 00000000..3ab7ebef Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_broken_link.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp b/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp new file mode 100644 index 00000000..d49bbfb9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_brush_marker_32.webp b/app/src/main/res/drawable-mdpi/ic_brush_marker_32.webp new file mode 100644 index 00000000..4354917b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_brush_marker_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-mdpi/ic_call_end_grey600_32dp.webp new file mode 100644 index 00000000..b42bcd9f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_end_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_call_end_white_48dp.webp new file mode 100644 index 00000000..627d07b1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_end_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_made_grey600_24dp.webp b/app/src/main/res/drawable-mdpi/ic_call_made_grey600_24dp.webp new file mode 100644 index 00000000..afa212c3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_made_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_missed_grey600_24dp.webp b/app/src/main/res/drawable-mdpi/ic_call_missed_grey600_24dp.webp new file mode 100644 index 00000000..4a604132 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_missed_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_received_grey600_24dp.webp b/app/src/main/res/drawable-mdpi/ic_call_received_grey600_24dp.webp new file mode 100644 index 00000000..07228e83 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_received_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_secure_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_call_secure_white_24dp.webp new file mode 100644 index 00000000..3963ccb9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_secure_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_call_split_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_call_split_white_24dp.webp new file mode 100644 index 00000000..953f846f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_call_split_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_caption_28.webp b/app/src/main/res/drawable-mdpi/ic_caption_28.webp new file mode 100644 index 00000000..b47add58 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_caption_28.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle_32.webp b/app/src/main/res/drawable-mdpi/ic_check_circle_32.webp new file mode 100644 index 00000000..9ccf5f0e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_circle_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle_white_18dp.webp b/app/src/main/res/drawable-mdpi/ic_check_circle_white_18dp.webp new file mode 100644 index 00000000..f8be216f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_circle_white_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_check_white_24dp.webp new file mode 100644 index 00000000..9267e097 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_check_white_48dp.webp new file mode 100644 index 00000000..b29483e0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.webp new file mode 100644 index 00000000..1266fa57 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-mdpi/ic_close_grey600_32dp.webp new file mode 100644 index 00000000..3fd498cb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_18dp.webp b/app/src/main/res/drawable-mdpi/ic_close_white_18dp.webp new file mode 100644 index 00000000..779b7b51 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_close_white_24dp.webp new file mode 100644 index 00000000..5943a7a9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_close_white_48dp.webp new file mode 100644 index 00000000..c1d0fd16 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_contact_picture.webp b/app/src/main/res/drawable-mdpi/ic_contact_picture.webp new file mode 100644 index 00000000..56f1d7ed Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_contact_picture.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_contact_picture_large.webp b/app/src/main/res/drawable-mdpi/ic_contact_picture_large.webp new file mode 100644 index 00000000..3c87e8c0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_contact_picture_large.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_contacts_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_contacts_white_48dp.webp new file mode 100644 index 00000000..eab84d08 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_contacts_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_create_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_create_white_24dp.webp new file mode 100644 index 00000000..bf789ff1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_create_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_32.webp b/app/src/main/res/drawable-mdpi/ic_crop_32.webp new file mode 100644 index 00000000..8a1b98be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_crop_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_lock_32.webp b/app/src/main/res/drawable-mdpi/ic_crop_lock_32.webp new file mode 100644 index 00000000..01386bde Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_crop_lock_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.webp b/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.webp new file mode 100644 index 00000000..87dc7b66 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.webp b/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.webp new file mode 100644 index 00000000..8efe94fb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_read.webp b/app/src/main/res/drawable-mdpi/ic_delivery_status_read.webp new file mode 100644 index 00000000..693ce088 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delivery_status_read.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.webp b/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.webp new file mode 100644 index 00000000..c90b0b6f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.webp b/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.webp new file mode 100644 index 00000000..d95c4e48 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_devices_white.webp b/app/src/main/res/drawable-mdpi/ic_devices_white.webp new file mode 100644 index 00000000..e8d720b5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_devices_white.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_document_large.webp b/app/src/main/res/drawable-mdpi/ic_document_large.webp new file mode 100644 index 00000000..704d5cb4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_document_small.webp b/app/src/main/res/drawable-mdpi/ic_document_small.webp new file mode 100644 index 00000000..361293f0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_download_32.png b/app/src/main/res/drawable-mdpi/ic_download_32.png new file mode 100644 index 00000000..072e14da Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_download_32.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_download_circle_fill_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_download_circle_fill_white_48dp.webp new file mode 100644 index 00000000..7b1362be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_download_circle_fill_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_error.webp b/app/src/main/res/drawable-mdpi/ic_error.webp new file mode 100644 index 00000000..c4de1c10 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_error.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_face_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_face_white_24dp.webp new file mode 100644 index 00000000..ec9be98f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_face_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_favorite_grey600_24dp.webp b/app/src/main/res/drawable-mdpi/ic_favorite_grey600_24dp.webp new file mode 100644 index 00000000..e8a41ab2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_favorite_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_fingerprint_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_fingerprint_white_48dp.webp new file mode 100644 index 00000000..02e64de8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_fingerprint_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_flip_32.webp b/app/src/main/res/drawable-mdpi/ic_flip_32.webp new file mode 100644 index 00000000..453a05bf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_flip_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_folder_white_48dp.webp new file mode 100644 index 00000000..2cad1dca Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_group_calls_megaphone.webp b/app/src/main/res/drawable-mdpi/ic_group_calls_megaphone.webp new file mode 100644 index 00000000..5f185d35 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_group_calls_megaphone.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_image_editor_blur.png b/app/src/main/res/drawable-mdpi/ic_image_editor_blur.png new file mode 100644 index 00000000..4ab4864c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_image_editor_blur.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline.webp b/app/src/main/res/drawable-mdpi/ic_info_outline.webp new file mode 100644 index 00000000..3e7b4272 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.webp new file mode 100644 index 00000000..bcce2a55 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..e3bb5d37 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white_36dp.webp b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white_36dp.webp new file mode 100644 index 00000000..ede63a91 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white_36dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_launch_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_launch_white_24dp.webp new file mode 100644 index 00000000..612990e0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launch_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.webp new file mode 100644 index 00000000..e6cac064 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_lock_white_24dp.webp new file mode 100644 index 00000000..e8506ad5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_lock_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_lock_white_48dp.webp b/app/src/main/res/drawable-mdpi/ic_lock_white_48dp.webp new file mode 100644 index 00000000..b0459f5c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_lock_white_48dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_add_field_holo_light.webp b/app/src/main/res/drawable-mdpi/ic_menu_add_field_holo_light.webp new file mode 100644 index 00000000..e5f89bbf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_add_field_holo_light.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.webp b/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.webp new file mode 100644 index 00000000..9b307675 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_login.png b/app/src/main/res/drawable-mdpi/ic_menu_login.png new file mode 100644 index 00000000..07dd6a51 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_login.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_search_holo_light.webp b/app/src/main/res/drawable-mdpi/ic_menu_search_holo_light.webp new file mode 100644 index 00000000..a99e8f67 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_search_holo_light.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_message_24.webp b/app/src/main/res/drawable-mdpi/ic_message_24.webp new file mode 100644 index 00000000..3a98fd19 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.webp b/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.webp new file mode 100644 index 00000000..0ac35c1d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 00000000..3c23af7b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_person_add_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_person_add_white_24dp.webp new file mode 100644 index 00000000..39826d44 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_person_add_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_person_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_person_white_24dp.webp new file mode 100644 index 00000000..ca771d7f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_person_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-mdpi/ic_phone_grey600_32dp.webp new file mode 100644 index 00000000..fef359da Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_photo_library_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_photo_library_white_24dp.webp new file mode 100644 index 00000000..78b83366 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_photo_library_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_plus_28.webp b/app/src/main/res/drawable-mdpi/ic_plus_28.webp new file mode 100644 index 00000000..58be3406 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_plus_28.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_reply.webp b/app/src/main/res/drawable-mdpi/ic_reply.webp new file mode 100644 index 00000000..7144445f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_reply.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.webp b/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.webp new file mode 100644 index 00000000..7ee6e6aa Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_rotate_32.webp b/app/src/main/res/drawable-mdpi/ic_rotate_32.webp new file mode 100644 index 00000000..18f19326 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_rotate_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_select_all_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_select_all_white_24dp.webp new file mode 100644 index 00000000..bfc2625c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_select_all_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_select_off.webp b/app/src/main/res/drawable-mdpi/ic_select_off.webp new file mode 100644 index 00000000..d89f4c52 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_select_off.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_select_on.webp b/app/src/main/res/drawable-mdpi/ic_select_on.webp new file mode 100644 index 00000000..d553427f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_select_on.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_share_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_share_white_24dp.webp new file mode 100644 index 00000000..22e38434 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_signal_background_connection.webp b/app/src/main/res/drawable-mdpi/ic_signal_background_connection.webp new file mode 100644 index 00000000..826038dc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_signal_background_connection.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_signal_backup.webp b/app/src/main/res/drawable-mdpi/ic_signal_backup.webp new file mode 100644 index 00000000..cb8cfb5f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_signal_backup.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_switch_camera_32.webp b/app/src/main/res/drawable-mdpi/ic_switch_camera_32.webp new file mode 100644 index 00000000..06043683 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_switch_camera_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_text_32.webp b/app/src/main/res/drawable-mdpi/ic_text_32.webp new file mode 100644 index 00000000..8b450bea Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_text_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_trash_filled_32.webp b/app/src/main/res/drawable-mdpi/ic_trash_filled_32.webp new file mode 100644 index 00000000..4264fd40 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_trash_filled_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.webp b/app/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.webp new file mode 100644 index 00000000..e6109465 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_unarchive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_unarchive_white_36dp.webp b/app/src/main/res/drawable-mdpi/ic_unarchive_white_36dp.webp new file mode 100644 index 00000000..479a9953 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_unarchive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_undo_32.webp b/app/src/main/res/drawable-mdpi/ic_undo_32.webp new file mode 100644 index 00000000..1a2440dd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_undo_32.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_unidentified_delivery.webp b/app/src/main/res/drawable-mdpi/ic_unidentified_delivery.webp new file mode 100644 index 00000000..1158b60d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_unidentified_delivery.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_unlocked_white_18dp.webp b/app/src/main/res/drawable-mdpi/ic_unlocked_white_18dp.webp new file mode 100644 index 00000000..5bb16c3e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_unlocked_white_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_video.webp b/app/src/main/res/drawable-mdpi/ic_video.webp new file mode 100644 index 00000000..ac01780f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_view_infinite_32.png b/app/src/main/res/drawable-mdpi/ic_view_infinite_32.png new file mode 100644 index 00000000..64f179ae Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_view_infinite_32.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_view_once_32.png b/app/src/main/res/drawable-mdpi/ic_view_once_32.png new file mode 100644 index 00000000..4044e60c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_view_once_32.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_visibility_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_visibility_grey600_24dp.png new file mode 100644 index 00000000..9d554fe3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_visibility_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-mdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..21566931 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp new file mode 100644 index 00000000..2bc08c0d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.webp b/app/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.webp new file mode 100644 index 00000000..cac78a23 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_off_white_18dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_warning.webp b/app/src/main/res/drawable-mdpi/ic_warning.webp new file mode 100644 index 00000000..c13bfaef Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_x_24_outlined.webp b/app/src/main/res/drawable-mdpi/ic_x_24_outlined.webp new file mode 100644 index 00000000..e390913c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_x_24_outlined.webp differ diff --git a/app/src/main/res/drawable-mdpi/ic_x_28.webp b/app/src/main/res/drawable-mdpi/ic_x_28.webp new file mode 100644 index 00000000..714e7f8a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_x_28.webp differ diff --git a/app/src/main/res/drawable-mdpi/icon_cached.webp b/app/src/main/res/drawable-mdpi/icon_cached.webp new file mode 100644 index 00000000..823555c4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/icon_cached.webp differ diff --git a/app/src/main/res/drawable-mdpi/icon_dialog.webp b/app/src/main/res/drawable-mdpi/icon_dialog.webp new file mode 100644 index 00000000..bae7049b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/icon_dialog.webp differ diff --git a/app/src/main/res/drawable-mdpi/import_database.webp b/app/src/main/res/drawable-mdpi/import_database.webp new file mode 100644 index 00000000..a47e06f0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/import_database.webp differ diff --git a/app/src/main/res/drawable-mdpi/kbs_pin_megaphone.webp b/app/src/main/res/drawable-mdpi/kbs_pin_megaphone.webp new file mode 100644 index 00000000..55641034 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/kbs_pin_megaphone.webp differ diff --git a/app/src/main/res/drawable-mdpi/marker_shadow.webp b/app/src/main/res/drawable-mdpi/marker_shadow.webp new file mode 100644 index 00000000..6c2916b0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/marker_shadow.webp differ diff --git a/app/src/main/res/drawable-mdpi/megaphone_notifications_64.webp b/app/src/main/res/drawable-mdpi/megaphone_notifications_64.webp new file mode 100644 index 00000000..61dde021 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/megaphone_notifications_64.webp differ diff --git a/app/src/main/res/drawable-mdpi/message_24dp.webp b/app/src/main/res/drawable-mdpi/message_24dp.webp new file mode 100644 index 00000000..9bdab599 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/message_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/no_contacts.png b/app/src/main/res/drawable-mdpi/no_contacts.png new file mode 100644 index 00000000..8a98e8de Binary files /dev/null and b/app/src/main/res/drawable-mdpi/no_contacts.png differ diff --git a/app/src/main/res/drawable-mdpi/notify_panel_notification_icon_bg.webp b/app/src/main/res/drawable-mdpi/notify_panel_notification_icon_bg.webp new file mode 100644 index 00000000..2a15023f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notify_panel_notification_icon_bg.webp differ diff --git a/app/src/main/res/drawable-mdpi/phone_24dp.webp b/app/src/main/res/drawable-mdpi/phone_24dp.webp new file mode 100644 index 00000000..43d532c4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/phone_24dp.webp differ diff --git a/app/src/main/res/drawable-mdpi/poweredby_giphy.webp b/app/src/main/res/drawable-mdpi/poweredby_giphy.webp new file mode 100644 index 00000000..03b55869 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/poweredby_giphy.webp differ diff --git a/app/src/main/res/drawable-mdpi/signal_research.webp b/app/src/main/res/drawable-mdpi/signal_research.webp new file mode 100644 index 00000000..f0b6a30d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-mdpi/welcome.webp b/app/src/main/res/drawable-mdpi/welcome.webp new file mode 100644 index 00000000..c0aa7727 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/welcome.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_account_box.webp b/app/src/main/res/drawable-night-hdpi/ic_account_box.webp new file mode 100644 index 00000000..2891df26 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-night-hdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..37bcf633 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_audio.webp b/app/src/main/res/drawable-night-hdpi/ic_audio.webp new file mode 100644 index 00000000..fc3999dd Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_document_large.webp b/app/src/main/res/drawable-night-hdpi/ic_document_large.webp new file mode 100644 index 00000000..72bc5d40 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_document_small.webp b/app/src/main/res/drawable-night-hdpi/ic_document_small.webp new file mode 100644 index 00000000..f66bcd59 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_info_outline.webp b/app/src/main/res/drawable-night-hdpi/ic_info_outline.webp new file mode 100644 index 00000000..df5cc352 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_video.webp b/app/src/main/res/drawable-night-hdpi/ic_video.webp new file mode 100644 index 00000000..976f3b2f Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-night-hdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..c06c2cf7 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-night-hdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..a20b6095 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-night-hdpi/ic_warning.webp b/app/src/main/res/drawable-night-hdpi/ic_warning.webp new file mode 100644 index 00000000..dc41fe89 Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_account_box.webp b/app/src/main/res/drawable-night-mdpi/ic_account_box.webp new file mode 100644 index 00000000..7c9f3bee Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-night-mdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..16d6e260 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_audio.webp b/app/src/main/res/drawable-night-mdpi/ic_audio.webp new file mode 100644 index 00000000..9044aee9 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_document_large.webp b/app/src/main/res/drawable-night-mdpi/ic_document_large.webp new file mode 100644 index 00000000..1923e704 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_document_small.webp b/app/src/main/res/drawable-night-mdpi/ic_document_small.webp new file mode 100644 index 00000000..a6bbd9b4 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_info_outline.webp b/app/src/main/res/drawable-night-mdpi/ic_info_outline.webp new file mode 100644 index 00000000..cde86a55 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_message_24.webp b/app/src/main/res/drawable-night-mdpi/ic_message_24.webp new file mode 100644 index 00000000..15703fef Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_video.webp b/app/src/main/res/drawable-night-mdpi/ic_video.webp new file mode 100644 index 00000000..c6a783f0 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-night-mdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..e6287c9f Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-night-mdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..bd1eaaae Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-night-mdpi/ic_warning.webp b/app/src/main/res/drawable-night-mdpi/ic_warning.webp new file mode 100644 index 00000000..ab9690a1 Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-night-v21/attachment_keyboard_button_background.xml b/app/src/main/res/drawable-night-v21/attachment_keyboard_button_background.xml new file mode 100644 index 00000000..59dfdd9f --- /dev/null +++ b/app/src/main/res/drawable-night-v21/attachment_keyboard_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night-v21/attachment_keyboard_button_wallpaper_background.xml b/app/src/main/res/drawable-night-v21/attachment_keyboard_button_wallpaper_background.xml new file mode 100644 index 00000000..4281db13 --- /dev/null +++ b/app/src/main/res/drawable-night-v21/attachment_keyboard_button_wallpaper_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night-v21/selectable_background.xml b/app/src/main/res/drawable-night-v21/selectable_background.xml new file mode 100644 index 00000000..9e78c5a0 --- /dev/null +++ b/app/src/main/res/drawable-night-v21/selectable_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night-v21/sticker_button.xml b/app/src/main/res/drawable-night-v21/sticker_button.xml new file mode 100644 index 00000000..faebab0d --- /dev/null +++ b/app/src/main/res/drawable-night-v21/sticker_button.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night-xhdpi/ic_account_box.webp b/app/src/main/res/drawable-night-xhdpi/ic_account_box.webp new file mode 100644 index 00000000..e1a0f8ab Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-night-xhdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..4d2e592f Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_audio.webp b/app/src/main/res/drawable-night-xhdpi/ic_audio.webp new file mode 100644 index 00000000..2a3b35a1 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_document_large.webp b/app/src/main/res/drawable-night-xhdpi/ic_document_large.webp new file mode 100644 index 00000000..a3b0c1d4 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_document_small.webp b/app/src/main/res/drawable-night-xhdpi/ic_document_small.webp new file mode 100644 index 00000000..ab317667 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_info_outline.webp b/app/src/main/res/drawable-night-xhdpi/ic_info_outline.webp new file mode 100644 index 00000000..e059b233 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_message_24.webp b/app/src/main/res/drawable-night-xhdpi/ic_message_24.webp new file mode 100644 index 00000000..51af9ca2 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_video.webp b/app/src/main/res/drawable-night-xhdpi/ic_video.webp new file mode 100644 index 00000000..00d9f034 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-night-xhdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..4bfab586 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-night-xhdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..a560f946 Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-night-xhdpi/ic_warning.webp b/app/src/main/res/drawable-night-xhdpi/ic_warning.webp new file mode 100644 index 00000000..c973ed9d Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_account_box.webp b/app/src/main/res/drawable-night-xxhdpi/ic_account_box.webp new file mode 100644 index 00000000..e937300b Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-night-xxhdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..47ac793e Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_attach_24dp.webp b/app/src/main/res/drawable-night-xxhdpi/ic_attach_24dp.webp new file mode 100644 index 00000000..40b62ed7 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_attach_24dp.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_audio.webp b/app/src/main/res/drawable-night-xxhdpi/ic_audio.webp new file mode 100644 index 00000000..2da12c63 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_document_large.webp b/app/src/main/res/drawable-night-xxhdpi/ic_document_large.webp new file mode 100644 index 00000000..7d1d9525 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_document_small.webp b/app/src/main/res/drawable-night-xxhdpi/ic_document_small.webp new file mode 100644 index 00000000..4f27c9ac Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_info_outline.webp b/app/src/main/res/drawable-night-xxhdpi/ic_info_outline.webp new file mode 100644 index 00000000..2b8039b1 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_message_24.webp b/app/src/main/res/drawable-night-xxhdpi/ic_message_24.webp new file mode 100644 index 00000000..55b62139 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_video.webp b/app/src/main/res/drawable-night-xxhdpi/ic_video.webp new file mode 100644 index 00000000..802e74da Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-night-xxhdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..0a3ed80a Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-night-xxhdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..a2cdfa19 Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-night-xxhdpi/ic_warning.webp b/app/src/main/res/drawable-night-xxhdpi/ic_warning.webp new file mode 100644 index 00000000..e279c8ba Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_document_large.webp b/app/src/main/res/drawable-night-xxxhdpi/ic_document_large.webp new file mode 100644 index 00000000..6699c61d Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_document_small.webp b/app/src/main/res/drawable-night-xxxhdpi/ic_document_small.webp new file mode 100644 index 00000000..59f526e9 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-night-xxxhdpi/ic_message_24.webp b/app/src/main/res/drawable-night-xxxhdpi/ic_message_24.webp new file mode 100644 index 00000000..079e5ec9 Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-night/attachment_keyboard_button_background.xml b/app/src/main/res/drawable-night/attachment_keyboard_button_background.xml new file mode 100644 index 00000000..accbfe6a --- /dev/null +++ b/app/src/main/res/drawable-night/attachment_keyboard_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/attachment_keyboard_button_wallpaper_background.xml b/app/src/main/res/drawable-night/attachment_keyboard_button_wallpaper_background.xml new file mode 100644 index 00000000..fc6dd40a --- /dev/null +++ b/app/src/main/res/drawable-night/attachment_keyboard_button_wallpaper_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/compose_background.xml b/app/src/main/res/drawable-night/compose_background.xml new file mode 100644 index 00000000..0c1f5b33 --- /dev/null +++ b/app/src/main/res/drawable-night/compose_background.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/compose_background_wallpaper.xml b/app/src/main/res/drawable-night/compose_background_wallpaper.xml new file mode 100644 index 00000000..0c1f5b33 --- /dev/null +++ b/app/src/main/res/drawable-night/compose_background_wallpaper.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/contact_selection_checkbox.xml b/app/src/main/res/drawable-night/contact_selection_checkbox.xml new file mode 100644 index 00000000..2f2b7f54 --- /dev/null +++ b/app/src/main/res/drawable-night/contact_selection_checkbox.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/conversation_reaction_overlay_background.xml b/app/src/main/res/drawable-night/conversation_reaction_overlay_background.xml new file mode 100644 index 00000000..0ff9bdd2 --- /dev/null +++ b/app/src/main/res/drawable-night/conversation_reaction_overlay_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/dialog_corners.xml b/app/src/main/res/drawable-night/dialog_corners.xml new file mode 100644 index 00000000..70cffb9b --- /dev/null +++ b/app/src/main/res/drawable-night/dialog_corners.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/emoji_variation_selector_background.xml b/app/src/main/res/drawable-night/emoji_variation_selector_background.xml new file mode 100644 index 00000000..b6139e6b --- /dev/null +++ b/app/src/main/res/drawable-night/emoji_variation_selector_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/help_fragment_problem_background.xml b/app/src/main/res/drawable-night/help_fragment_problem_background.xml new file mode 100644 index 00000000..db351dad --- /dev/null +++ b/app/src/main/res/drawable-night/help_fragment_problem_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_add_members_20.xml b/app/src/main/res/drawable-night/ic_add_members_20.xml new file mode 100644 index 00000000..94fd8397 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_add_members_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_add_members_circle.xml b/app/src/main/res/drawable-night/ic_add_members_circle.xml new file mode 100644 index 00000000..9ec11afa --- /dev/null +++ b/app/src/main/res/drawable-night/ic_add_members_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_any_emoji_32.xml b/app/src/main/res/drawable-night/ic_any_emoji_32.xml new file mode 100644 index 00000000..3e9a813a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_any_emoji_32.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/ic_appearance_24.xml b/app/src/main/res/drawable-night/ic_appearance_24.xml new file mode 100644 index 00000000..82bb4a30 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_appearance_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_archive_24dp.xml b/app/src/main/res/drawable-night/ic_archive_24dp.xml new file mode 100644 index 00000000..58bb82f5 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_archive_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_bell_24.xml b/app/src/main/res/drawable-night/ic_bell_24.xml new file mode 100644 index 00000000..9be80269 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_bell_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_camera_24.xml b/app/src/main/res/drawable-night/ic_camera_24.xml new file mode 100644 index 00000000..8f4e566e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_camera_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_compose_24.xml b/app/src/main/res/drawable-night/ic_compose_24.xml new file mode 100644 index 00000000..6c06b6e3 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_compose_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_compose_tinted_24.xml b/app/src/main/res/drawable-night/ic_compose_tinted_24.xml new file mode 100644 index 00000000..08d61671 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_compose_tinted_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_copy_24.xml b/app/src/main/res/drawable-night/ic_copy_24.xml new file mode 100644 index 00000000..e6edfc20 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_copy_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_copy_24_tinted.xml b/app/src/main/res/drawable-night/ic_copy_24_tinted.xml new file mode 100644 index 00000000..b9ae6fe6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_copy_24_tinted.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji.xml b/app/src/main/res/drawable-night/ic_emoji.xml new file mode 100644 index 00000000..ed160bb6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_activity_20.xml b/app/src/main/res/drawable-night/ic_emoji_activity_20.xml new file mode 100644 index 00000000..7772677c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_activity_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_animal_20.xml b/app/src/main/res/drawable-night/ic_emoji_animal_20.xml new file mode 100644 index 00000000..8ddbc2e1 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_animal_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_emoticon_20.xml b/app/src/main/res/drawable-night/ic_emoji_emoticon_20.xml new file mode 100644 index 00000000..6a1b096c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_emoticon_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_flag_20.xml b/app/src/main/res/drawable-night/ic_emoji_flag_20.xml new file mode 100644 index 00000000..1b1d730e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_flag_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_food_20.xml b/app/src/main/res/drawable-night/ic_emoji_food_20.xml new file mode 100644 index 00000000..36b786fc --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_food_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_object_20.xml b/app/src/main/res/drawable-night/ic_emoji_object_20.xml new file mode 100644 index 00000000..75634f79 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_object_20.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_people_20.xml b/app/src/main/res/drawable-night/ic_emoji_people_20.xml new file mode 100644 index 00000000..c28e8c86 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_people_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_smiley_24.xml b/app/src/main/res/drawable-night/ic_emoji_smiley_24.xml new file mode 100644 index 00000000..3781962a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_smiley_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_symbol_20.xml b/app/src/main/res/drawable-night/ic_emoji_symbol_20.xml new file mode 100644 index 00000000..01dc36a1 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_symbol_20.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_emoji_travel_20.xml b/app/src/main/res/drawable-night/ic_emoji_travel_20.xml new file mode 100644 index 00000000..c22c93c4 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_emoji_travel_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_folder_24.xml b/app/src/main/res/drawable-night/ic_folder_24.xml new file mode 100644 index 00000000..f29a69e5 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_folder_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_forward_24.xml b/app/src/main/res/drawable-night/ic_forward_24.xml new file mode 100644 index 00000000..10b92dd8 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_forward_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_grid_20.xml b/app/src/main/res/drawable-night/ic_grid_20.xml new file mode 100644 index 00000000..158e745e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_grid_20.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_group_24.xml b/app/src/main/res/drawable-night/ic_group_24.xml new file mode 100644 index 00000000..c5a6844f --- /dev/null +++ b/app/src/main/res/drawable-night/ic_group_24.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_heart_24.xml b/app/src/main/res/drawable-night/ic_heart_24.xml new file mode 100644 index 00000000..fb2ab405 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_heart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_help_24.xml b/app/src/main/res/drawable-night/ic_help_24.xml new file mode 100644 index 00000000..83914022 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_help_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_info_tinted_24.xml b/app/src/main/res/drawable-night/ic_info_tinted_24.xml new file mode 100644 index 00000000..bec7681a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_info_tinted_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_info_white_24.xml b/app/src/main/res/drawable-night/ic_info_white_24.xml new file mode 100644 index 00000000..6b57d443 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_info_white_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_invite_circle.xml b/app/src/main/res/drawable-night/ic_invite_circle.xml new file mode 100644 index 00000000..7ad05907 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_invite_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_kbs_splash.xml b/app/src/main/res/drawable-night/ic_kbs_splash.xml new file mode 100644 index 00000000..7542071a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_kbs_splash.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/ic_keyboard_24.xml b/app/src/main/res/drawable-night/ic_keyboard_24.xml new file mode 100644 index 00000000..c7a54fd8 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_keyboard_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_list_20.xml b/app/src/main/res/drawable-night/ic_list_20.xml new file mode 100644 index 00000000..459afe22 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_list_20.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_lock_24.xml b/app/src/main/res/drawable-night/ic_lock_24.xml new file mode 100644 index 00000000..faaaf874 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_lock_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_message_primary_accent_24.xml b/app/src/main/res/drawable-night/ic_message_primary_accent_24.xml new file mode 100644 index 00000000..54642648 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_message_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_message_tinted_bitmap_24.xml b/app/src/main/res/drawable-night/ic_message_tinted_bitmap_24.xml new file mode 100644 index 00000000..88903a7c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_message_tinted_bitmap_24.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_mic_24.xml b/app/src/main/res/drawable-night/ic_mic_24.xml new file mode 100644 index 00000000..bc43faea --- /dev/null +++ b/app/src/main/res/drawable-night/ic_mic_24.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_new_group_circle.xml b/app/src/main/res/drawable-night/ic_new_group_circle.xml new file mode 100644 index 00000000..e1526612 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_new_group_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_phone_right_24.xml b/app/src/main/res/drawable-night/ic_phone_right_24.xml new file mode 100644 index 00000000..a523efa0 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_phone_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_phone_right_primary_accent_24.xml b/app/src/main/res/drawable-night/ic_phone_right_primary_accent_24.xml new file mode 100644 index 00000000..011a7d7c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_phone_right_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_phone_right_unlock_primary_accent_24.xml b/app/src/main/res/drawable-night/ic_phone_right_unlock_primary_accent_24.xml new file mode 100644 index 00000000..c1b7c66b --- /dev/null +++ b/app/src/main/res/drawable-night/ic_phone_right_unlock_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_photo_24.xml b/app/src/main/res/drawable-night/ic_photo_24.xml new file mode 100644 index 00000000..676071b7 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_photo_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_pin_24.xml b/app/src/main/res/drawable-night/ic_pin_24.xml new file mode 100644 index 00000000..4d7ad357 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_pin_24.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_profile_name_24.xml b/app/src/main/res/drawable-night/ic_profile_name_24.xml new file mode 100644 index 00000000..80947675 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_profile_name_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_proxy_connected_24.xml b/app/src/main/res/drawable-night/ic_proxy_connected_24.xml new file mode 100644 index 00000000..c2427dda --- /dev/null +++ b/app/src/main/res/drawable-night/ic_proxy_connected_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_proxy_connecting_24.xml b/app/src/main/res/drawable-night/ic_proxy_connecting_24.xml new file mode 100644 index 00000000..ea644a34 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_proxy_connecting_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_proxy_failed_24.xml b/app/src/main/res/drawable-night/ic_proxy_failed_24.xml new file mode 100644 index 00000000..a08e2e75 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_proxy_failed_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_recent_20.xml b/app/src/main/res/drawable-night/ic_recent_20.xml new file mode 100644 index 00000000..7cb39ce5 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_recent_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_reply_24.xml b/app/src/main/res/drawable-night/ic_reply_24.xml new file mode 100644 index 00000000..5fd2caa4 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_reply_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_share_24.xml b/app/src/main/res/drawable-night/ic_share_24.xml new file mode 100644 index 00000000..5f97a4c2 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_share_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_share_24_tinted.xml b/app/src/main/res/drawable-night/ic_share_24_tinted.xml new file mode 100644 index 00000000..63817d8a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_share_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_sticker_24.xml b/app/src/main/res/drawable-night/ic_sticker_24.xml new file mode 100644 index 00000000..6c2e76a6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_sticker_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_trash_24.xml b/app/src/main/res/drawable-night/ic_trash_24.xml new file mode 100644 index 00000000..d5310580 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_trash_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_unpin_24.xml b/app/src/main/res/drawable-night/ic_unpin_24.xml new file mode 100644 index 00000000..8703936b --- /dev/null +++ b/app/src/main/res/drawable-night/ic_unpin_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_update_audio_call_incoming_16.xml b/app/src/main/res/drawable-night/ic_update_audio_call_incoming_16.xml new file mode 100644 index 00000000..d66707ef --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_audio_call_incoming_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_audio_call_missed_16.xml b/app/src/main/res/drawable-night/ic_update_audio_call_missed_16.xml new file mode 100644 index 00000000..cd5b800d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_audio_call_missed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_audio_call_outgoing_16.xml b/app/src/main/res/drawable-night/ic_update_audio_call_outgoing_16.xml new file mode 100644 index 00000000..77bdf848 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_audio_call_outgoing_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_16.xml b/app/src/main/res/drawable-night/ic_update_group_16.xml new file mode 100644 index 00000000..c7c92bfc --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_accept_16.xml b/app/src/main/res/drawable-night/ic_update_group_accept_16.xml new file mode 100644 index 00000000..a6b91698 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_accept_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_add_16.xml b/app/src/main/res/drawable-night/ic_update_group_add_16.xml new file mode 100644 index 00000000..b89b1b23 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_add_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_avatar_16.xml b/app/src/main/res/drawable-night/ic_update_group_avatar_16.xml new file mode 100644 index 00000000..5549da12 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_avatar_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_decline_16.xml b/app/src/main/res/drawable-night/ic_update_group_decline_16.xml new file mode 100644 index 00000000..87a3ba49 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_decline_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_leave_16.xml b/app/src/main/res/drawable-night/ic_update_group_leave_16.xml new file mode 100644 index 00000000..63520985 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_leave_16.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_name_16.xml b/app/src/main/res/drawable-night/ic_update_group_name_16.xml new file mode 100644 index 00000000..1221def2 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_name_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_remove_16.xml b/app/src/main/res/drawable-night/ic_update_group_remove_16.xml new file mode 100644 index 00000000..9bb46553 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_remove_16.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-night/ic_update_group_role_16.xml b/app/src/main/res/drawable-night/ic_update_group_role_16.xml new file mode 100644 index 00000000..9d15c214 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_group_role_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_info_16.xml b/app/src/main/res/drawable-night/ic_update_info_16.xml new file mode 100644 index 00000000..6822e13c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_info_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_profile_16.xml b/app/src/main/res/drawable-night/ic_update_profile_16.xml new file mode 100644 index 00000000..a9994245 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_profile_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_safety_number_16.xml b/app/src/main/res/drawable-night/ic_update_safety_number_16.xml new file mode 100644 index 00000000..f3d933a6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_safety_number_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_timer_16.xml b/app/src/main/res/drawable-night/ic_update_timer_16.xml new file mode 100644 index 00000000..ccd550c1 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_timer_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_timer_disabled_16.xml b/app/src/main/res/drawable-night/ic_update_timer_disabled_16.xml new file mode 100644 index 00000000..6856b4c1 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_timer_disabled_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_verified_16.xml b/app/src/main/res/drawable-night/ic_update_verified_16.xml new file mode 100644 index 00000000..52556b19 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_verified_16.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_video_call_incoming_16.xml b/app/src/main/res/drawable-night/ic_update_video_call_incoming_16.xml new file mode 100644 index 00000000..7dd80e16 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_video_call_incoming_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_video_call_missed_16.xml b/app/src/main/res/drawable-night/ic_update_video_call_missed_16.xml new file mode 100644 index 00000000..fb5255c2 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_video_call_missed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_update_video_call_outgoing_16.xml b/app/src/main/res/drawable-night/ic_update_video_call_outgoing_16.xml new file mode 100644 index 00000000..27966c64 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_video_call_outgoing_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_video_16.xml b/app/src/main/res/drawable-night/ic_video_16.xml new file mode 100644 index 00000000..6db01b7d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_video_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_video_call_24.xml b/app/src/main/res/drawable-night/ic_video_call_24.xml new file mode 100644 index 00000000..7a2fa3ec --- /dev/null +++ b/app/src/main/res/drawable-night/ic_video_call_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_video_primary_accent_24.xml b/app/src/main/res/drawable-night/ic_video_primary_accent_24.xml new file mode 100644 index 00000000..61f3a0d3 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_video_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_view_all_circle.xml b/app/src/main/res/drawable-night/ic_view_all_circle.xml new file mode 100644 index 00000000..fb46989d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_view_all_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_warning_40.xml b/app/src/main/res/drawable-night/ic_warning_40.xml new file mode 100644 index 00000000..9bf38699 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/invite_edit_text_background.xml b/app/src/main/res/drawable-night/invite_edit_text_background.xml new file mode 100644 index 00000000..c55c8527 --- /dev/null +++ b/app/src/main/res/drawable-night/invite_edit_text_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/media_keyboard_selected_background.xml b/app/src/main/res/drawable-night/media_keyboard_selected_background.xml new file mode 100644 index 00000000..b41f2cf6 --- /dev/null +++ b/app/src/main/res/drawable-night/media_keyboard_selected_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/message_request_button_background.xml b/app/src/main/res/drawable-night/message_request_button_background.xml new file mode 100644 index 00000000..03ad8b31 --- /dev/null +++ b/app/src/main/res/drawable-night/message_request_button_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/paragraph_marker.xml b/app/src/main/res/drawable-night/paragraph_marker.xml new file mode 100644 index 00000000..654064b4 --- /dev/null +++ b/app/src/main/res/drawable-night/paragraph_marker.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/preference_divider.xml b/app/src/main/res/drawable-night/preference_divider.xml new file mode 100644 index 00000000..d0366796 --- /dev/null +++ b/app/src/main/res/drawable-night/preference_divider.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/preference_username_background.xml b/app/src/main/res/drawable-night/preference_username_background.xml new file mode 100644 index 00000000..9129a90e --- /dev/null +++ b/app/src/main/res/drawable-night/preference_username_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/progress_button_state.xml b/app/src/main/res/drawable-night/progress_button_state.xml new file mode 100644 index 00000000..317f3b40 --- /dev/null +++ b/app/src/main/res/drawable-night/progress_button_state.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/reaction_pill_background.xml b/app/src/main/res/drawable-night/reaction_pill_background.xml new file mode 100644 index 00000000..210e3c9f --- /dev/null +++ b/app/src/main/res/drawable-night/reaction_pill_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/reaction_pill_background_selected.xml b/app/src/main/res/drawable-night/reaction_pill_background_selected.xml new file mode 100644 index 00000000..534d363c --- /dev/null +++ b/app/src/main/res/drawable-night/reaction_pill_background_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/reactions_old_background.xml b/app/src/main/res/drawable-night/reactions_old_background.xml new file mode 100644 index 00000000..fe3c5b6c --- /dev/null +++ b/app/src/main/res/drawable-night/reactions_old_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/review_card_outline.xml b/app/src/main/res/drawable-night/review_card_outline.xml new file mode 100644 index 00000000..ac889955 --- /dev/null +++ b/app/src/main/res/drawable-night/review_card_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/round_background.xml b/app/src/main/res/drawable-night/round_background.xml new file mode 100644 index 00000000..4dc33b3c --- /dev/null +++ b/app/src/main/res/drawable-night/round_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/rounded_rectangle.xml b/app/src/main/res/drawable-night/rounded_rectangle.xml new file mode 100644 index 00000000..1cc16839 --- /dev/null +++ b/app/src/main/res/drawable-night/rounded_rectangle.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/scroll_to_bottom_background.xml b/app/src/main/res/drawable-night/scroll_to_bottom_background.xml new file mode 100644 index 00000000..8ea26a73 --- /dev/null +++ b/app/src/main/res/drawable-night/scroll_to_bottom_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable-night/sticker_button.xml b/app/src/main/res/drawable-night/sticker_button.xml new file mode 100644 index 00000000..646748d3 --- /dev/null +++ b/app/src/main/res/drawable-night/sticker_button.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/sticker_missing_background.xml b/app/src/main/res/drawable-night/sticker_missing_background.xml new file mode 100644 index 00000000..b41f2cf6 --- /dev/null +++ b/app/src/main/res/drawable-night/sticker_missing_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/sticky_date_header_background.xml b/app/src/main/res/drawable-night/sticky_date_header_background.xml new file mode 100644 index 00000000..554dd74e --- /dev/null +++ b/app/src/main/res/drawable-night/sticky_date_header_background.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/tinted_circle.xml b/app/src/main/res/drawable-night/tinted_circle.xml new file mode 100644 index 00000000..c56381ea --- /dev/null +++ b/app/src/main/res/drawable-night/tinted_circle.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/unread_count_background.xml b/app/src/main/res/drawable-night/unread_count_background.xml new file mode 100644 index 00000000..ddaf847e --- /dev/null +++ b/app/src/main/res/drawable-night/unread_count_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/attachment_keyboard_button_background.xml b/app/src/main/res/drawable-v21/attachment_keyboard_button_background.xml new file mode 100644 index 00000000..e4ca23c0 --- /dev/null +++ b/app/src/main/res/drawable-v21/attachment_keyboard_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/attachment_keyboard_button_wallpaper_background.xml b/app/src/main/res/drawable-v21/attachment_keyboard_button_wallpaper_background.xml new file mode 100644 index 00000000..71d346bb --- /dev/null +++ b/app/src/main/res/drawable-v21/attachment_keyboard_button_wallpaper_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/camera_send_button_background.xml b/app/src/main/res/drawable-v21/camera_send_button_background.xml new file mode 100644 index 00000000..df91cfd2 --- /dev/null +++ b/app/src/main/res/drawable-v21/camera_send_button_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/circle_touch_highlight_background.xml b/app/src/main/res/drawable-v21/circle_touch_highlight_background.xml new file mode 100644 index 00000000..fe392b45 --- /dev/null +++ b/app/src/main/res/drawable-v21/circle_touch_highlight_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/conversation_list_item_background.xml b/app/src/main/res/drawable-v21/conversation_list_item_background.xml new file mode 100644 index 00000000..6ef38ac5 --- /dev/null +++ b/app/src/main/res/drawable-v21/conversation_list_item_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/cta_button_background.xml b/app/src/main/res/drawable-v21/cta_button_background.xml new file mode 100644 index 00000000..059e9cc2 --- /dev/null +++ b/app/src/main/res/drawable-v21/cta_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/dialog_background.xml b/app/src/main/res/drawable-v21/dialog_background.xml new file mode 100644 index 00000000..42fb55eb --- /dev/null +++ b/app/src/main/res/drawable-v21/dialog_background.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/media_continue_button_background.xml b/app/src/main/res/drawable-v21/media_continue_button_background.xml new file mode 100644 index 00000000..df91cfd2 --- /dev/null +++ b/app/src/main/res/drawable-v21/media_continue_button_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/media_count_button_background.xml b/app/src/main/res/drawable-v21/media_count_button_background.xml new file mode 100644 index 00000000..167a745b --- /dev/null +++ b/app/src/main/res/drawable-v21/media_count_button_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/mediarail_button_background.xml b/app/src/main/res/drawable-v21/mediarail_button_background.xml new file mode 100644 index 00000000..777372d4 --- /dev/null +++ b/app/src/main/res/drawable-v21/mediarail_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/primary_action_button_background.xml b/app/src/main/res/drawable-v21/primary_action_button_background.xml new file mode 100644 index 00000000..c9cab10f --- /dev/null +++ b/app/src/main/res/drawable-v21/primary_action_button_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/selectable_background.xml b/app/src/main/res/drawable-v21/selectable_background.xml new file mode 100644 index 00000000..0b5d0194 --- /dev/null +++ b/app/src/main/res/drawable-v21/selectable_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/sticker_button.xml b/app/src/main/res/drawable-v21/sticker_button.xml new file mode 100644 index 00000000..f20b4e89 --- /dev/null +++ b/app/src/main/res/drawable-v21/sticker_button.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/touch_highlight_background.xml b/app/src/main/res/drawable-v21/touch_highlight_background.xml new file mode 100644 index 00000000..80e27edf --- /dev/null +++ b/app/src/main/res/drawable-v21/touch_highlight_background.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml new file mode 100644 index 00000000..f9261b28 --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml new file mode 100644 index 00000000..4b1c0cae --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml new file mode 100644 index 00000000..2e938e6c --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-xhdpi/baseline_account_circle_white_24.webp b/app/src/main/res/drawable-xhdpi/baseline_account_circle_white_24.webp new file mode 100644 index 00000000..bb8453eb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_account_circle_white_24.webp differ diff --git a/app/src/main/res/drawable-xhdpi/baseline_email_white_24.webp b/app/src/main/res/drawable-xhdpi/baseline_email_white_24.webp new file mode 100644 index 00000000..cfc80508 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_email_white_24.webp differ diff --git a/app/src/main/res/drawable-xhdpi/check.webp b/app/src/main/res/drawable-xhdpi/check.webp new file mode 100644 index 00000000..afa4a36b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/check.webp differ diff --git a/app/src/main/res/drawable-xhdpi/clear_profile_avatar.webp b/app/src/main/res/drawable-xhdpi/clear_profile_avatar.webp new file mode 100644 index 00000000..1192e99c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/clear_profile_avatar.webp differ diff --git a/app/src/main/res/drawable-xhdpi/flash_auto_32.webp b/app/src/main/res/drawable-xhdpi/flash_auto_32.webp new file mode 100644 index 00000000..7b87a058 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/flash_auto_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/flash_off_32.webp b/app/src/main/res/drawable-xhdpi/flash_off_32.webp new file mode 100644 index 00000000..649fd432 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/flash_off_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/flash_on_32.webp b/app/src/main/res/drawable-xhdpi/flash_on_32.webp new file mode 100644 index 00000000..f12dd809 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/flash_on_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_account_box.webp b/app/src/main/res/drawable-xhdpi/ic_account_box.webp new file mode 100644 index 00000000..9f3de733 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_warning_red.webp b/app/src/main/res/drawable-xhdpi/ic_action_warning_red.webp new file mode 100644 index 00000000..da001810 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_warning_red.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_add_white_original_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_add_white_original_24dp.webp new file mode 100644 index 00000000..86a06eb4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_white_original_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_archive_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_archive_white_24dp.webp new file mode 100644 index 00000000..ad683834 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_archive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-xhdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..e3455883 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_right.webp b/app/src/main/res/drawable-xhdpi/ic_arrow_right.webp new file mode 100644 index 00000000..8568bd8a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_arrow_right.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_audio.webp b/app/src/main/res/drawable-xhdpi/ic_audio.webp new file mode 100644 index 00000000..4733040f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png new file mode 100644 index 00000000..12ca4588 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.webp new file mode 100644 index 00000000..d26c70e6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_block_white_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_block_white_18dp.webp new file mode 100644 index 00000000..5714e7cb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_block_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_block_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_block_white_24dp.webp new file mode 100644 index 00000000..4cf688e6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_block_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_broken_link.webp b/app/src/main/res/drawable-xhdpi/ic_broken_link.webp new file mode 100644 index 00000000..3a942103 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_broken_link.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp b/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp new file mode 100644 index 00000000..6612f233 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp b/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp new file mode 100644 index 00000000..46e4a767 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_end_grey600_32dp.webp new file mode 100644 index 00000000..5d535401 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.webp new file mode 100644 index 00000000..fe688062 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_made_grey600_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_made_grey600_24dp.webp new file mode 100644 index 00000000..3986f1f4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_made_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_missed_grey600_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_missed_grey600_24dp.webp new file mode 100644 index 00000000..e7be381a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_missed_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_received_grey600_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_received_grey600_24dp.webp new file mode 100644 index 00000000..27b2b91b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_received_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_secure_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_secure_white_24dp.webp new file mode 100644 index 00000000..d824bec4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_secure_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_call_split_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_call_split_white_24dp.webp new file mode 100644 index 00000000..e0c5655f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_call_split_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_caption_28.webp b/app/src/main/res/drawable-xhdpi/ic_caption_28.webp new file mode 100644 index 00000000..702e65fd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_caption_28.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_circle_32.webp b/app/src/main/res/drawable-xhdpi/ic_check_circle_32.webp new file mode 100644 index 00000000..2999230a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_circle_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_circle_white_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_check_circle_white_18dp.webp new file mode 100644 index 00000000..af27a233 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_circle_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_check_white_24dp.webp new file mode 100644 index 00000000..8ce408f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.webp new file mode 100644 index 00000000..44885d6f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.webp new file mode 100644 index 00000000..7f1e72cb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-xhdpi/ic_close_grey600_32dp.webp new file mode 100644 index 00000000..79c328d4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp new file mode 100644 index 00000000..541301f0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.webp new file mode 100644 index 00000000..88a57fe2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.webp new file mode 100644 index 00000000..439b8fe8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_contact_picture.webp b/app/src/main/res/drawable-xhdpi/ic_contact_picture.webp new file mode 100644 index 00000000..507c4702 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_contact_picture.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_contact_picture_large.webp b/app/src/main/res/drawable-xhdpi/ic_contact_picture_large.webp new file mode 100644 index 00000000..d6164bb9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_contact_picture_large.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_contacts_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_contacts_white_48dp.webp new file mode 100644 index 00000000..89502d59 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_contacts_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.webp new file mode 100644 index 00000000..a15beb23 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_32.webp b/app/src/main/res/drawable-xhdpi/ic_crop_32.webp new file mode 100644 index 00000000..119f5f57 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_crop_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.webp b/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.webp new file mode 100644 index 00000000..6f5b5ee6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.webp b/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.webp new file mode 100644 index 00000000..71281670 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.webp b/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.webp new file mode 100644 index 00000000..a248a924 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.webp b/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.webp new file mode 100644 index 00000000..8cb73484 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.webp b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.webp new file mode 100644 index 00000000..3d8731de Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.webp b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.webp new file mode 100644 index 00000000..611359cd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_devices_white.webp b/app/src/main/res/drawable-xhdpi/ic_devices_white.webp new file mode 100644 index 00000000..5fea96d8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_devices_white.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_document_large.webp b/app/src/main/res/drawable-xhdpi/ic_document_large.webp new file mode 100644 index 00000000..a858aafe Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_document_small.webp b/app/src/main/res/drawable-xhdpi/ic_document_small.webp new file mode 100644 index 00000000..5769a11e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_download_32.png b/app/src/main/res/drawable-xhdpi/ic_download_32.png new file mode 100644 index 00000000..7013c314 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_download_32.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.webp new file mode 100644 index 00000000..b277c130 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_error.webp b/app/src/main/res/drawable-xhdpi/ic_error.webp new file mode 100644 index 00000000..f2f24310 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_error.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_face_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_face_white_24dp.webp new file mode 100644 index 00000000..64173f95 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_face_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_favorite_grey600_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_favorite_grey600_24dp.webp new file mode 100644 index 00000000..7cc7a8fa Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_favorite_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_fingerprint_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_fingerprint_white_48dp.webp new file mode 100644 index 00000000..c5ea5557 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_fingerprint_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_flip_32.webp b/app/src/main/res/drawable-xhdpi/ic_flip_32.webp new file mode 100644 index 00000000..7b9d741c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_flip_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_folder_white_48dp.webp new file mode 100644 index 00000000..183ffe30 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_group_calls_megaphone.webp b/app/src/main/res/drawable-xhdpi/ic_group_calls_megaphone.webp new file mode 100644 index 00000000..ec7c15bf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_group_calls_megaphone.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_image_editor_blur.png b/app/src/main/res/drawable-xhdpi/ic_image_editor_blur.png new file mode 100644 index 00000000..4a7bcac0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_image_editor_blur.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline.webp b/app/src/main/res/drawable-xhdpi/ic_info_outline.webp new file mode 100644 index 00000000..1412a253 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.webp new file mode 100644 index 00000000..8edbab62 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..11cd4381 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white_36dp.webp b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white_36dp.webp new file mode 100644 index 00000000..cc4835bc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launch_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_launch_white_24dp.webp new file mode 100644 index 00000000..625ad49c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launch_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.webp new file mode 100644 index 00000000..b67c6811 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp new file mode 100644 index 00000000..e4c0c95f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_lock_white_48dp.webp b/app/src/main/res/drawable-xhdpi/ic_lock_white_48dp.webp new file mode 100644 index 00000000..2de76faa Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_lock_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_add_field_holo_light.webp b/app/src/main/res/drawable-xhdpi/ic_menu_add_field_holo_light.webp new file mode 100644 index 00000000..ef88d18e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_add_field_holo_light.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.webp b/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.webp new file mode 100644 index 00000000..8212b28a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_login.webp b/app/src/main/res/drawable-xhdpi/ic_menu_login.webp new file mode 100644 index 00000000..da2ac6c9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_login.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_search_holo_light.webp b/app/src/main/res/drawable-xhdpi/ic_menu_search_holo_light.webp new file mode 100644 index 00000000..9dd42004 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_search_holo_light.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_message_24.webp b/app/src/main/res/drawable-xhdpi/ic_message_24.webp new file mode 100644 index 00000000..98bbe5a3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.webp b/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.webp new file mode 100644 index 00000000..c2e16819 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 00000000..ea801260 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_person_add_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_person_add_white_24dp.webp new file mode 100644 index 00000000..898cd61b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_person_add_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.webp new file mode 100644 index 00000000..ee3d3561 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-xhdpi/ic_phone_grey600_32dp.webp new file mode 100644 index 00000000..09630019 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_photo_library_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_photo_library_white_24dp.webp new file mode 100644 index 00000000..ec8bf078 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_photo_library_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_28.webp b/app/src/main/res/drawable-xhdpi/ic_plus_28.webp new file mode 100644 index 00000000..0d25b48a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_plus_28.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_reply.webp b/app/src/main/res/drawable-xhdpi/ic_reply.webp new file mode 100644 index 00000000..62ebaa9f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_reply.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.webp b/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.webp new file mode 100644 index 00000000..8b2db850 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_rotate_32.webp b/app/src/main/res/drawable-xhdpi/ic_rotate_32.webp new file mode 100644 index 00000000..e24c33da Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_rotate_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_select_all_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_select_all_white_24dp.webp new file mode 100644 index 00000000..162c1150 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_select_all_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_select_off.webp b/app/src/main/res/drawable-xhdpi/ic_select_off.webp new file mode 100644 index 00000000..f67f9b98 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_select_off.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_select_on.webp b/app/src/main/res/drawable-xhdpi/ic_select_on.webp new file mode 100644 index 00000000..9e27694c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_select_on.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.webp new file mode 100644 index 00000000..fcc12b36 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_signal_background_connection.webp b/app/src/main/res/drawable-xhdpi/ic_signal_background_connection.webp new file mode 100644 index 00000000..6f27352f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_signal_background_connection.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_signal_backup.webp b/app/src/main/res/drawable-xhdpi/ic_signal_backup.webp new file mode 100644 index 00000000..89b12565 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_signal_backup.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.webp b/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.webp new file mode 100644 index 00000000..ef9bcfac Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_text_32.webp b/app/src/main/res/drawable-xhdpi/ic_text_32.webp new file mode 100644 index 00000000..cce43b52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_text_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp b/app/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp new file mode 100644 index 00000000..e3605ca3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_trash_filled_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.webp new file mode 100644 index 00000000..222fd551 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_unarchive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_unarchive_white_36dp.webp b/app/src/main/res/drawable-xhdpi/ic_unarchive_white_36dp.webp new file mode 100644 index 00000000..24575ff4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_unarchive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_undo_32.webp b/app/src/main/res/drawable-xhdpi/ic_undo_32.webp new file mode 100644 index 00000000..7c407ef0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_undo_32.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_unidentified_delivery.webp b/app/src/main/res/drawable-xhdpi/ic_unidentified_delivery.webp new file mode 100644 index 00000000..b4f6968b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_unidentified_delivery.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_unlocked_white_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_unlocked_white_18dp.webp new file mode 100644 index 00000000..f4513f52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_unlocked_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_video.webp b/app/src/main/res/drawable-xhdpi/ic_video.webp new file mode 100644 index 00000000..8e06a9b2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_view_infinite_32.png b/app/src/main/res/drawable-xhdpi/ic_view_infinite_32.png new file mode 100644 index 00000000..4db9a007 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_view_infinite_32.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_view_once_32.png b/app/src/main/res/drawable-xhdpi/ic_view_once_32.png new file mode 100644 index 00000000..cabaf709 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_view_once_32.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..b93f7829 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..29ddca7b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp new file mode 100644 index 00000000..abf5aa27 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.webp new file mode 100644 index 00000000..930fff75 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_off_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_warning.webp b/app/src/main/res/drawable-xhdpi/ic_warning.webp new file mode 100644 index 00000000..8428399f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_x_24_outlined.webp b/app/src/main/res/drawable-xhdpi/ic_x_24_outlined.webp new file mode 100644 index 00000000..74ed1e45 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_x_24_outlined.webp differ diff --git a/app/src/main/res/drawable-xhdpi/ic_x_28.webp b/app/src/main/res/drawable-xhdpi/ic_x_28.webp new file mode 100644 index 00000000..532c4c13 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_x_28.webp differ diff --git a/app/src/main/res/drawable-xhdpi/icon_cached.webp b/app/src/main/res/drawable-xhdpi/icon_cached.webp new file mode 100644 index 00000000..1adb3f6d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/icon_cached.webp differ diff --git a/app/src/main/res/drawable-xhdpi/icon_dialog.webp b/app/src/main/res/drawable-xhdpi/icon_dialog.webp new file mode 100644 index 00000000..9cde8407 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/icon_dialog.webp differ diff --git a/app/src/main/res/drawable-xhdpi/import_database.webp b/app/src/main/res/drawable-xhdpi/import_database.webp new file mode 100644 index 00000000..8cf88dca Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/import_database.webp differ diff --git a/app/src/main/res/drawable-xhdpi/kbs_pin_megaphone.webp b/app/src/main/res/drawable-xhdpi/kbs_pin_megaphone.webp new file mode 100644 index 00000000..32b287a6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/kbs_pin_megaphone.webp differ diff --git a/app/src/main/res/drawable-xhdpi/marker_shadow.webp b/app/src/main/res/drawable-xhdpi/marker_shadow.webp new file mode 100644 index 00000000..2c151367 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/marker_shadow.webp differ diff --git a/app/src/main/res/drawable-xhdpi/megaphone_notifications_64.webp b/app/src/main/res/drawable-xhdpi/megaphone_notifications_64.webp new file mode 100644 index 00000000..abb39ff0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/megaphone_notifications_64.webp differ diff --git a/app/src/main/res/drawable-xhdpi/message_24dp.webp b/app/src/main/res/drawable-xhdpi/message_24dp.webp new file mode 100644 index 00000000..cd066df2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/message_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/no_contacts.png b/app/src/main/res/drawable-xhdpi/no_contacts.png new file mode 100644 index 00000000..083b80d1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/no_contacts.png differ diff --git a/app/src/main/res/drawable-xhdpi/notify_panel_notification_icon_bg.webp b/app/src/main/res/drawable-xhdpi/notify_panel_notification_icon_bg.webp new file mode 100644 index 00000000..67e9b077 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notify_panel_notification_icon_bg.webp differ diff --git a/app/src/main/res/drawable-xhdpi/phone_24dp.webp b/app/src/main/res/drawable-xhdpi/phone_24dp.webp new file mode 100644 index 00000000..9b9a27ad Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/phone_24dp.webp differ diff --git a/app/src/main/res/drawable-xhdpi/poweredby_giphy.webp b/app/src/main/res/drawable-xhdpi/poweredby_giphy.webp new file mode 100644 index 00000000..afc22e77 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/poweredby_giphy.webp differ diff --git a/app/src/main/res/drawable-xhdpi/signal_research.webp b/app/src/main/res/drawable-xhdpi/signal_research.webp new file mode 100644 index 00000000..797342d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xhdpi/welcome.webp b/app/src/main/res/drawable-xhdpi/welcome.webp new file mode 100644 index 00000000..08e76e7c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/welcome.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_account_circle_white_24.webp b/app/src/main/res/drawable-xxhdpi/baseline_account_circle_white_24.webp new file mode 100644 index 00000000..a5563725 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_account_circle_white_24.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_email_white_24.webp b/app/src/main/res/drawable-xxhdpi/baseline_email_white_24.webp new file mode 100644 index 00000000..4c165d32 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_email_white_24.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/check.webp b/app/src/main/res/drawable-xxhdpi/check.webp new file mode 100644 index 00000000..9e90cbd0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/check.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/clear_profile_avatar.webp b/app/src/main/res/drawable-xxhdpi/clear_profile_avatar.webp new file mode 100644 index 00000000..9521c5f1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/clear_profile_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/flash_auto_32.webp b/app/src/main/res/drawable-xxhdpi/flash_auto_32.webp new file mode 100644 index 00000000..39aeefa3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/flash_auto_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/flash_off_32.webp b/app/src/main/res/drawable-xxhdpi/flash_off_32.webp new file mode 100644 index 00000000..9147665a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/flash_off_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/flash_on_32.webp b/app/src/main/res/drawable-xxhdpi/flash_on_32.webp new file mode 100644 index 00000000..d1d8424b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/flash_on_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_account_box.webp b/app/src/main/res/drawable-xxhdpi/ic_account_box.webp new file mode 100644 index 00000000..68f00288 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_account_box.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_warning_red.webp b/app/src/main/res/drawable-xxhdpi/ic_action_warning_red.webp new file mode 100644 index 00000000..59472c09 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_warning_red.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_white_original_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_add_white_original_24dp.webp new file mode 100644 index 00000000..bbbf1647 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_white_original_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.webp new file mode 100644 index 00000000..d8df37a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_forward.webp b/app/src/main/res/drawable-xxhdpi/ic_arrow_forward.webp new file mode 100644 index 00000000..df8eb7f5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_forward.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_right.webp b/app/src/main/res/drawable-xxhdpi/ic_arrow_right.webp new file mode 100644 index 00000000..3348ce3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_right.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_attach_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_attach_24dp.webp new file mode 100644 index 00000000..b904ddf0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_attach_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_audio.webp b/app/src/main/res/drawable-xxhdpi/ic_audio.webp new file mode 100644 index 00000000..da2ba301 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_audio.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.webp new file mode 100644 index 00000000..1d2cc1d6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.webp new file mode 100644 index 00000000..02a97dba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_block_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_block_white_18dp.webp new file mode 100644 index 00000000..10a1cea1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_block_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_block_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_block_white_24dp.webp new file mode 100644 index 00000000..589b0cfe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_block_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_broken_link.webp b/app/src/main/res/drawable-xxhdpi/ic_broken_link.webp new file mode 100644 index 00000000..5f6a7001 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_broken_link.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp b/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp new file mode 100644 index 00000000..7f1f6b8a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp b/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp new file mode 100644 index 00000000..2dcfc423 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_end_grey600_32dp.webp new file mode 100644 index 00000000..693ef75d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.webp new file mode 100644 index 00000000..4bfafe34 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_made_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_made_grey600_24dp.webp new file mode 100644 index 00000000..44ca8c7e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_made_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_missed_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_missed_grey600_24dp.webp new file mode 100644 index 00000000..39d3c018 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_missed_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_received_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_received_grey600_24dp.webp new file mode 100644 index 00000000..411a99e5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_received_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_secure_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_secure_white_24dp.webp new file mode 100644 index 00000000..dde80c32 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_secure_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_split_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_call_split_white_24dp.webp new file mode 100644 index 00000000..be329cc2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_split_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_caption_28.webp b/app/src/main/res/drawable-xxhdpi/ic_caption_28.webp new file mode 100644 index 00000000..3a00c0ab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_caption_28.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.webp b/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.webp new file mode 100644 index 00000000..682efc0e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_check_circle_white_18dp.webp new file mode 100644 index 00000000..3069c3e8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_circle_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_check_white_24dp.webp new file mode 100644 index 00000000..61cd93f3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.webp new file mode 100644 index 00000000..b52d8cfb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.webp new file mode 100644 index 00000000..6a06c9f8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-xxhdpi/ic_close_grey600_32dp.webp new file mode 100644 index 00000000..4c5e469c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp new file mode 100644 index 00000000..1b7a9e54 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.webp new file mode 100644 index 00000000..c69f105b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.webp new file mode 100644 index 00000000..0b4f0ba4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_contact_picture.webp b/app/src/main/res/drawable-xxhdpi/ic_contact_picture.webp new file mode 100644 index 00000000..a6a6f85c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_contact_picture.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_contact_picture_large.webp b/app/src/main/res/drawable-xxhdpi/ic_contact_picture_large.webp new file mode 100644 index 00000000..c4538cfd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_contact_picture_large.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_contacts_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_contacts_white_48dp.webp new file mode 100644 index 00000000..4f5c20c5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_contacts_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.webp new file mode 100644 index 00000000..2d489019 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_32.webp b/app/src/main/res/drawable-xxhdpi/ic_crop_32.webp new file mode 100644 index 00000000..83607ce7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_crop_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.webp b/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.webp new file mode 100644 index 00000000..01da9c92 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.webp b/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.webp new file mode 100644 index 00000000..8f9ff60a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.webp b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.webp new file mode 100644 index 00000000..09615477 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.webp b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.webp new file mode 100644 index 00000000..403c7e76 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.webp b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.webp new file mode 100644 index 00000000..46f241fd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.webp b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.webp new file mode 100644 index 00000000..f2171514 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_devices_white.webp b/app/src/main/res/drawable-xxhdpi/ic_devices_white.webp new file mode 100644 index 00000000..558e9e72 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_devices_white.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_large.webp b/app/src/main/res/drawable-xxhdpi/ic_document_large.webp new file mode 100644 index 00000000..5ffb4a6d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_small.webp b/app/src/main/res/drawable-xxhdpi/ic_document_small.webp new file mode 100644 index 00000000..d24d4138 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download_32.png b/app/src/main/res/drawable-xxhdpi/ic_download_32.png new file mode 100644 index 00000000..c5c64063 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download_32.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.webp new file mode 100644 index 00000000..151daa6e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_error.webp b/app/src/main/res/drawable-xxhdpi/ic_error.webp new file mode 100644 index 00000000..eccb4cd9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_error.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_error_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_error_white_18dp.webp new file mode 100644 index 00000000..b316c7f9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_error_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_face_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_face_white_24dp.webp new file mode 100644 index 00000000..cf8fce09 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_face_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_favorite_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_favorite_grey600_24dp.webp new file mode 100644 index 00000000..a776137a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_favorite_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_fingerprint_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_fingerprint_white_48dp.webp new file mode 100644 index 00000000..dc141669 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_fingerprint_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_flip_32.webp b/app/src/main/res/drawable-xxhdpi/ic_flip_32.webp new file mode 100644 index 00000000..7133b2d6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_flip_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_folder_white_48dp.webp new file mode 100644 index 00000000..85ad8726 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_group_calls_megaphone.webp b/app/src/main/res/drawable-xxhdpi/ic_group_calls_megaphone.webp new file mode 100644 index 00000000..c48fae3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_group_calls_megaphone.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_image_editor_blur.png b/app/src/main/res/drawable-xxhdpi/ic_image_editor_blur.png new file mode 100644 index 00000000..613d1db9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_image_editor_blur.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline.webp b/app/src/main/res/drawable-xxhdpi/ic_info_outline.webp new file mode 100644 index 00000000..f12bbc6e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_info_outline.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.webp new file mode 100644 index 00000000..fde8f220 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..27deddbc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white_36dp.webp b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white_36dp.webp new file mode 100644 index 00000000..3296214c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.webp new file mode 100644 index 00000000..d0ed7800 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launch_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.webp new file mode 100644 index 00000000..9fd0c697 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp new file mode 100644 index 00000000..efb7f01a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_lock_white_48dp.webp b/app/src/main/res/drawable-xxhdpi/ic_lock_white_48dp.webp new file mode 100644 index 00000000..e5df3827 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_lock_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.webp b/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.webp new file mode 100644 index 00000000..c374dee7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_search_holo_light.webp b/app/src/main/res/drawable-xxhdpi/ic_menu_search_holo_light.webp new file mode 100644 index 00000000..758a2376 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_search_holo_light.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_message_24.webp b/app/src/main/res/drawable-xxhdpi/ic_message_24.webp new file mode 100644 index 00000000..b04fd8c4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.webp b/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.webp new file mode 100644 index 00000000..dac8e83f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 00000000..fd227e36 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_person_add_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_person_add_white_24dp.webp new file mode 100644 index 00000000..f2c06c44 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_person_add_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.webp new file mode 100644 index 00000000..9170a6be Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-xxhdpi/ic_phone_grey600_32dp.webp new file mode 100644 index 00000000..b6951bb1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_photo_library_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_photo_library_white_24dp.webp new file mode 100644 index 00000000..cbe2339b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_photo_library_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus_28.webp b/app/src/main/res/drawable-xxhdpi/ic_plus_28.webp new file mode 100644 index 00000000..0f405464 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_plus_28.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_refresh_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_refresh_white_18dp.webp new file mode 100644 index 00000000..da23759f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_refresh_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_reply.webp b/app/src/main/res/drawable-xxhdpi/ic_reply.webp new file mode 100644 index 00000000..0b7dcec5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_reply.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.webp b/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.webp new file mode 100644 index 00000000..5872a85d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rotate_32.webp b/app/src/main/res/drawable-xxhdpi/ic_rotate_32.webp new file mode 100644 index 00000000..886e5541 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_rotate_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_all_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_select_all_white_24dp.webp new file mode 100644 index 00000000..243243f3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_select_all_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_off.webp b/app/src/main/res/drawable-xxhdpi/ic_select_off.webp new file mode 100644 index 00000000..9835cbc3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_select_off.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_on.webp b/app/src/main/res/drawable-xxhdpi/ic_select_on.webp new file mode 100644 index 00000000..d6761ec7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_select_on.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.webp new file mode 100644 index 00000000..49d546d2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_signal_background_connection.webp b/app/src/main/res/drawable-xxhdpi/ic_signal_background_connection.webp new file mode 100644 index 00000000..098fb5ac Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_signal_background_connection.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_signal_backup.webp b/app/src/main/res/drawable-xxhdpi/ic_signal_backup.webp new file mode 100644 index 00000000..12e25e70 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_signal_backup.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.webp b/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.webp new file mode 100644 index 00000000..89d7f6e5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_text_32.webp b/app/src/main/res/drawable-xxhdpi/ic_text_32.webp new file mode 100644 index 00000000..e4ced150 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_text_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp b/app/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp new file mode 100644 index 00000000..c1a09482 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_trash_filled_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.webp new file mode 100644 index 00000000..24575ff4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_36dp.webp b/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_36dp.webp new file mode 100644 index 00000000..61d1d6e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_unarchive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_undo_32.webp b/app/src/main/res/drawable-xxhdpi/ic_undo_32.webp new file mode 100644 index 00000000..7611c26c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_undo_32.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_unidentified_delivery.webp b/app/src/main/res/drawable-xxhdpi/ic_unidentified_delivery.webp new file mode 100644 index 00000000..34f9d02e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_unidentified_delivery.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_unlocked_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_unlocked_white_18dp.webp new file mode 100644 index 00000000..412c176f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_unlocked_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_video.webp b/app/src/main/res/drawable-xxhdpi/ic_video.webp new file mode 100644 index 00000000..4d80745e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_video.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_view_infinite_32.png b/app/src/main/res/drawable-xxhdpi/ic_view_infinite_32.png new file mode 100644 index 00000000..812c7a79 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_view_infinite_32.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_view_once_32.png b/app/src/main/res/drawable-xxhdpi/ic_view_once_32.png new file mode 100644 index 00000000..e77b837b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_view_once_32.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_visibility_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_visibility_24dp.webp new file mode 100644 index 00000000..6f635ce7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_visibility_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_visibility_off_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_visibility_off_24dp.webp new file mode 100644 index 00000000..dc82c42e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_visibility_off_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.webp new file mode 100644 index 00000000..9e661678 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.webp b/app/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.webp new file mode 100644 index 00000000..ee8c3479 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_off_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_warning.webp b/app/src/main/res/drawable-xxhdpi/ic_warning.webp new file mode 100644 index 00000000..1d4af43b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_warning.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_x_24_outlined.webp b/app/src/main/res/drawable-xxhdpi/ic_x_24_outlined.webp new file mode 100644 index 00000000..ef1a7a98 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_x_24_outlined.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_x_28.webp b/app/src/main/res/drawable-xxhdpi/ic_x_28.webp new file mode 100644 index 00000000..54406739 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_x_28.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_cached.webp b/app/src/main/res/drawable-xxhdpi/icon_cached.webp new file mode 100644 index 00000000..f04bb608 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/icon_cached.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_dialog.webp b/app/src/main/res/drawable-xxhdpi/icon_dialog.webp new file mode 100644 index 00000000..5a6e15a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/icon_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/kbs_pin_megaphone.webp b/app/src/main/res/drawable-xxhdpi/kbs_pin_megaphone.webp new file mode 100644 index 00000000..9cd7921c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/kbs_pin_megaphone.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/marker_shadow.webp b/app/src/main/res/drawable-xxhdpi/marker_shadow.webp new file mode 100644 index 00000000..b06951c2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/marker_shadow.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/megaphone_notifications_64.webp b/app/src/main/res/drawable-xxhdpi/megaphone_notifications_64.webp new file mode 100644 index 00000000..f168031e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/megaphone_notifications_64.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/message_24dp.webp b/app/src/main/res/drawable-xxhdpi/message_24dp.webp new file mode 100644 index 00000000..869faf2a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/message_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/no_contacts.png b/app/src/main/res/drawable-xxhdpi/no_contacts.png new file mode 100644 index 00000000..5bf45a4b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/no_contacts.png differ diff --git a/app/src/main/res/drawable-xxhdpi/phone_24dp.webp b/app/src/main/res/drawable-xxhdpi/phone_24dp.webp new file mode 100644 index 00000000..344db696 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/phone_24dp.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/poweredby_giphy.webp b/app/src/main/res/drawable-xxhdpi/poweredby_giphy.webp new file mode 100644 index 00000000..0f6567e3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/poweredby_giphy.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/signal_research.webp b/app/src/main/res/drawable-xxhdpi/signal_research.webp new file mode 100644 index 00000000..319a3d14 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/welcome.webp b/app/src/main/res/drawable-xxhdpi/welcome.webp new file mode 100644 index 00000000..5ef25706 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/welcome.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_account_circle_white_24.webp b/app/src/main/res/drawable-xxxhdpi/baseline_account_circle_white_24.webp new file mode 100644 index 00000000..efe2d51c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_account_circle_white_24.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_email_white_24.webp b/app/src/main/res/drawable-xxxhdpi/baseline_email_white_24.webp new file mode 100644 index 00000000..623920e3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_email_white_24.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/clear_profile_avatar.webp b/app/src/main/res/drawable-xxxhdpi/clear_profile_avatar.webp new file mode 100644 index 00000000..015fcf80 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/clear_profile_avatar.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/flash_auto_32.webp b/app/src/main/res/drawable-xxxhdpi/flash_auto_32.webp new file mode 100644 index 00000000..f59bc305 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/flash_auto_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/flash_off_32.webp b/app/src/main/res/drawable-xxxhdpi/flash_off_32.webp new file mode 100644 index 00000000..ab1edbee Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/flash_off_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/flash_on_32.webp b/app/src/main/res/drawable-xxxhdpi/flash_on_32.webp new file mode 100644 index 00000000..a2d36e22 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/flash_on_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_add_white_original_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_add_white_original_24dp.webp new file mode 100644 index 00000000..918078e2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_add_white_original_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.webp new file mode 100644 index 00000000..8c31168f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_archive_white_36dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_archive_white_36dp.webp new file mode 100644 index 00000000..66df77c1 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_archive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.webp b/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.webp new file mode 100644 index 00000000..baa64040 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.webp new file mode 100644 index 00000000..92ff7720 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_block_white_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_block_white_18dp.webp new file mode 100644 index 00000000..84997486 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_block_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_block_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_block_white_24dp.webp new file mode 100644 index 00000000..ad36d02e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_block_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_broken_link.webp b/app/src/main/res/drawable-xxxhdpi/ic_broken_link.webp new file mode 100644 index 00000000..4711f691 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_broken_link.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp new file mode 100644 index 00000000..67f8ca11 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp new file mode 100644 index 00000000..ee6f21d6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_call_end_grey600_32dp.webp new file mode 100644 index 00000000..4f2c8b5d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_call_end_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.webp new file mode 100644 index 00000000..f7ed9310 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_caption_28.webp b/app/src/main/res/drawable-xxxhdpi/ic_caption_28.webp new file mode 100644 index 00000000..46cc43cb Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_caption_28.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.webp new file mode 100644 index 00000000..09564d6f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_circle_white_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_white_18dp.webp new file mode 100644 index 00000000..644877b6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.webp new file mode 100644 index 00000000..75bd23c3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_check_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.webp new file mode 100644 index 00000000..a8abeefa Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.webp new file mode 100644 index 00000000..4f84702d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_close_grey600_32dp.webp new file mode 100644 index 00000000..c1431def Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp new file mode 100644 index 00000000..1ae09899 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.webp new file mode 100644 index 00000000..75309d24 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.webp new file mode 100644 index 00000000..22e78ba8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_contact_picture_large.webp b/app/src/main/res/drawable-xxxhdpi/ic_contact_picture_large.webp new file mode 100644 index 00000000..edbba47b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_contact_picture_large.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_contacts_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_contacts_white_48dp.webp new file mode 100644 index 00000000..c113d2d0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_contacts_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_crop_32.webp new file mode 100644 index 00000000..39832a05 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_crop_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.webp new file mode 100644 index 00000000..cc13b791 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.webp new file mode 100644 index 00000000..28b5edbc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.webp b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.webp new file mode 100644 index 00000000..42ab8f1f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.webp b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.webp new file mode 100644 index 00000000..3ca1497f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.webp b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.webp new file mode 100644 index 00000000..898d4c21 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.webp b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.webp new file mode 100644 index 00000000..791dbf01 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_devices_white.webp b/app/src/main/res/drawable-xxxhdpi/ic_devices_white.webp new file mode 100644 index 00000000..051bfe48 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_devices_white.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_large.webp b/app/src/main/res/drawable-xxxhdpi/ic_document_large.webp new file mode 100644 index 00000000..e43c9518 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_document_large.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_small.webp b/app/src/main/res/drawable-xxxhdpi/ic_document_small.webp new file mode 100644 index 00000000..6b96e9b5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_document_small.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_download_32.png b/app/src/main/res/drawable-xxxhdpi/ic_download_32.png new file mode 100644 index 00000000..d6972b48 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_download_32.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.webp new file mode 100644 index 00000000..f941c1a3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_error.webp b/app/src/main/res/drawable-xxxhdpi/ic_error.webp new file mode 100644 index 00000000..04af4a3d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_error.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_face_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_face_white_24dp.webp new file mode 100644 index 00000000..7b4be0f6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_face_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_favorite_grey600_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_favorite_grey600_24dp.webp new file mode 100644 index 00000000..c574e138 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_favorite_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_fingerprint_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_fingerprint_white_48dp.webp new file mode 100644 index 00000000..972eb67f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_fingerprint_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_flip_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_flip_32.webp new file mode 100644 index 00000000..21eaff32 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_flip_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_folder_white_48dp.webp new file mode 100644 index 00000000..1155b331 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_image_editor_blur.png b/app/src/main/res/drawable-xxxhdpi/ic_image_editor_blur.png new file mode 100644 index 00000000..f5a893e9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_image_editor_blur.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.webp new file mode 100644 index 00000000..f28ed255 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp new file mode 100644 index 00000000..aeba3b2d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white_36dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white_36dp.webp new file mode 100644 index 00000000..020e5283 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.webp new file mode 100644 index 00000000..f971614a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launch_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.webp new file mode 100644 index 00000000..a995f7dc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_lock_white_48dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_lock_white_48dp.webp new file mode 100644 index 00000000..6889b617 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_lock_white_48dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.webp b/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.webp new file mode 100644 index 00000000..c4322f94 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_message_24.webp b/app/src/main/res/drawable-xxxhdpi/ic_message_24.webp new file mode 100644 index 00000000..330cc223 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_message_24.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 00000000..066a06ed Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_person_add_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_person_add_white_24dp.webp new file mode 100644 index 00000000..440753ea Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_person_add_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.webp new file mode 100644 index 00000000..027cf0e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_phone_grey600_32dp.webp new file mode 100644 index 00000000..2fab3223 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_phone_grey600_32dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_photo_library_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_photo_library_white_24dp.webp new file mode 100644 index 00000000..32fbcf24 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_photo_library_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.webp new file mode 100644 index 00000000..593155d0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp new file mode 100644 index 00000000..a41dcbe6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_select_off.webp b/app/src/main/res/drawable-xxxhdpi/ic_select_off.webp new file mode 100644 index 00000000..8eeeae1e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_select_off.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_select_on.webp b/app/src/main/res/drawable-xxxhdpi/ic_select_on.webp new file mode 100644 index 00000000..ce10145f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_select_on.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.webp new file mode 100644 index 00000000..2f9c9d9e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_share_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_signal_background_connection.webp b/app/src/main/res/drawable-xxxhdpi/ic_signal_background_connection.webp new file mode 100644 index 00000000..104aad5d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_signal_background_connection.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_signal_backup.webp b/app/src/main/res/drawable-xxxhdpi/ic_signal_backup.webp new file mode 100644 index 00000000..1185ff85 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_signal_backup.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.webp new file mode 100644 index 00000000..529cf941 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_text_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_text_32.webp new file mode 100644 index 00000000..19833035 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_text_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp new file mode 100644 index 00000000..f9c3bd01 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_trash_filled_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.webp new file mode 100644 index 00000000..fdebd03a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_36dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_36dp.webp new file mode 100644 index 00000000..43fe07fb Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_unarchive_white_36dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_undo_32.webp b/app/src/main/res/drawable-xxxhdpi/ic_undo_32.webp new file mode 100644 index 00000000..fd43a332 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_undo_32.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_unidentified_delivery.webp b/app/src/main/res/drawable-xxxhdpi/ic_unidentified_delivery.webp new file mode 100644 index 00000000..ec5ecc48 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_unidentified_delivery.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_unlocked_white_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_unlocked_white_18dp.webp new file mode 100644 index 00000000..817ccf82 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_unlocked_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_view_infinite_32.png b/app/src/main/res/drawable-xxxhdpi/ic_view_infinite_32.png new file mode 100644 index 00000000..f7ebc266 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_view_infinite_32.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_view_once_32.png b/app/src/main/res/drawable-xxxhdpi/ic_view_once_32.png new file mode 100644 index 00000000..fe549441 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_view_once_32.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.webp new file mode 100644 index 00000000..fe2cfedd Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.webp new file mode 100644 index 00000000..091bfdad Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_white_18dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_x_24_outlined.webp b/app/src/main/res/drawable-xxxhdpi/ic_x_24_outlined.webp new file mode 100644 index 00000000..8d224476 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_x_24_outlined.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_x_28.webp b/app/src/main/res/drawable-xxxhdpi/ic_x_28.webp new file mode 100644 index 00000000..6ccdd0fa Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_x_28.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/icon_cached.webp b/app/src/main/res/drawable-xxxhdpi/icon_cached.webp new file mode 100644 index 00000000..44e78ed4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/icon_cached.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/kbs_pin_megaphone.webp b/app/src/main/res/drawable-xxxhdpi/kbs_pin_megaphone.webp new file mode 100644 index 00000000..1160a548 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/kbs_pin_megaphone.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/marker_shadow.webp b/app/src/main/res/drawable-xxxhdpi/marker_shadow.webp new file mode 100644 index 00000000..6c7b09e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/marker_shadow.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/message_24dp.webp b/app/src/main/res/drawable-xxxhdpi/message_24dp.webp new file mode 100644 index 00000000..d2d20431 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/message_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/no_contacts.webp b/app/src/main/res/drawable-xxxhdpi/no_contacts.webp new file mode 100644 index 00000000..df93c934 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/no_contacts.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/phone_24dp.webp b/app/src/main/res/drawable-xxxhdpi/phone_24dp.webp new file mode 100644 index 00000000..3d5109b0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/phone_24dp.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/poweredby_giphy.webp b/app/src/main/res/drawable-xxxhdpi/poweredby_giphy.webp new file mode 100644 index 00000000..e1832d4e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/poweredby_giphy.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/signal_research.webp b/app/src/main/res/drawable-xxxhdpi/signal_research.webp new file mode 100644 index 00000000..6dadb13f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/welcome.webp b/app/src/main/res/drawable-xxxhdpi/welcome.webp new file mode 100644 index 00000000..0a0b7ff2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/welcome.webp differ diff --git a/app/src/main/res/drawable/archived_indicator_background.xml b/app/src/main/res/drawable/archived_indicator_background.xml new file mode 100644 index 00000000..6ef8fb4f --- /dev/null +++ b/app/src/main/res/drawable/archived_indicator_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/attachment_keyboard_button_background.xml b/app/src/main/res/drawable/attachment_keyboard_button_background.xml new file mode 100644 index 00000000..9617c71c --- /dev/null +++ b/app/src/main/res/drawable/attachment_keyboard_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/attachment_keyboard_button_wallpaper_background.xml b/app/src/main/res/drawable/attachment_keyboard_button_wallpaper_background.xml new file mode 100644 index 00000000..1c60482d --- /dev/null +++ b/app/src/main/res/drawable/attachment_keyboard_button_wallpaper_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_read_indicator_circle.xml b/app/src/main/res/drawable/audio_read_indicator_circle.xml new file mode 100644 index 00000000..dbfdf4f3 --- /dev/null +++ b/app/src/main/res/drawable/audio_read_indicator_circle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/audio_wave_thumb.xml b/app/src/main/res/drawable/audio_wave_thumb.xml new file mode 100644 index 00000000..2ca27e6a --- /dev/null +++ b/app/src/main/res/drawable/audio_wave_thumb.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_gradient.xml b/app/src/main/res/drawable/avatar_gradient.xml new file mode 100644 index 00000000..c21a15a7 --- /dev/null +++ b/app/src/main/res/drawable/avatar_gradient.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_pattern.png b/app/src/main/res/drawable/background_pattern.png new file mode 100644 index 00000000..9035edeb Binary files /dev/null and b/app/src/main/res/drawable/background_pattern.png differ diff --git a/app/src/main/res/drawable/background_pattern_repeat.xml b/app/src/main/res/drawable/background_pattern_repeat.xml new file mode 100644 index 00000000..5e3165a4 --- /dev/null +++ b/app/src/main/res/drawable/background_pattern_repeat.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/drawable/call_participant_update_window_background.xml b/app/src/main/res/drawable/call_participant_update_window_background.xml new file mode 100644 index 00000000..99759155 --- /dev/null +++ b/app/src/main/res/drawable/call_participant_update_window_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/callee_dialog_button_background.xml b/app/src/main/res/drawable/callee_dialog_button_background.xml new file mode 100644 index 00000000..4406e796 --- /dev/null +++ b/app/src/main/res/drawable/callee_dialog_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/camera_send_button_background.xml b/app/src/main/res/drawable/camera_send_button_background.xml new file mode 100644 index 00000000..7bcb1e9d --- /dev/null +++ b/app/src/main/res/drawable/camera_send_button_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/camerax_flash_toggle.xml b/app/src/main/res/drawable/camerax_flash_toggle.xml new file mode 100644 index 00000000..48310299 --- /dev/null +++ b/app/src/main/res/drawable/camerax_flash_toggle.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_session_refresh_banner.xml b/app/src/main/res/drawable/chat_session_refresh_banner.xml new file mode 100644 index 00000000..4e978627 --- /dev/null +++ b/app/src/main/res/drawable/chat_session_refresh_banner.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_bottom_bar.xml b/app/src/main/res/drawable/chat_wallpaper_preview_bottom_bar.xml new file mode 100644 index 00000000..66b110a2 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_bottom_bar.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_bubble_10.xml b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_10.xml new file mode 100644 index 00000000..5400fcd0 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_10.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_bubble_8.xml b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_8.xml new file mode 100644 index 00000000..0974eab3 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_8.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background.xml b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background.xml new file mode 100644 index 00000000..33cf2cb8 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background_accent.xml b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background_accent.xml new file mode 100644 index 00000000..6f94ec3a --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_bubble_background_accent.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_date_background.xml b/app/src/main/res/drawable/chat_wallpaper_preview_date_background.xml new file mode 100644 index 00000000..47b735ab --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_date_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_input.xml b/app/src/main/res/drawable/chat_wallpaper_preview_input.xml new file mode 100644 index 00000000..9979888a --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_input.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_outline.xml b/app/src/main/res/drawable/chat_wallpaper_preview_outline.xml new file mode 100644 index 00000000..fead0432 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_wallpaper_preview_top_bar.xml b/app/src/main/res/drawable/chat_wallpaper_preview_top_bar.xml new file mode 100644 index 00000000..bb049f44 --- /dev/null +++ b/app/src/main/res/drawable/chat_wallpaper_preview_top_bar.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/circle_alpha.xml b/app/src/main/res/drawable/circle_alpha.xml new file mode 100644 index 00000000..f38e6822 --- /dev/null +++ b/app/src/main/res/drawable/circle_alpha.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/circle_tintable.xml b/app/src/main/res/drawable/circle_tintable.xml new file mode 100644 index 00000000..6c5c3606 --- /dev/null +++ b/app/src/main/res/drawable/circle_tintable.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/circle_tintable_padded.xml b/app/src/main/res/drawable/circle_tintable_padded.xml new file mode 100644 index 00000000..e20e33be --- /dev/null +++ b/app/src/main/res/drawable/circle_tintable_padded.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/circle_touch_highlight_background.xml b/app/src/main/res/drawable/circle_touch_highlight_background.xml new file mode 100644 index 00000000..96f6b341 --- /dev/null +++ b/app/src/main/res/drawable/circle_touch_highlight_background.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_ultramarine.xml b/app/src/main/res/drawable/circle_ultramarine.xml new file mode 100644 index 00000000..a852d08b --- /dev/null +++ b/app/src/main/res/drawable/circle_ultramarine.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/circle_white.xml b/app/src/main/res/drawable/circle_white.xml new file mode 100644 index 00000000..631057c4 --- /dev/null +++ b/app/src/main/res/drawable/circle_white.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/colorpickerpreference_pref_swatch.xml b/app/src/main/res/drawable/colorpickerpreference_pref_swatch.xml new file mode 100644 index 00000000..927bb9f3 --- /dev/null +++ b/app/src/main/res/drawable/colorpickerpreference_pref_swatch.xml @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_background.xml b/app/src/main/res/drawable/compose_background.xml new file mode 100644 index 00000000..93437fbf --- /dev/null +++ b/app/src/main/res/drawable/compose_background.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_background_camera.xml b/app/src/main/res/drawable/compose_background_camera.xml new file mode 100644 index 00000000..e9b6aa69 --- /dev/null +++ b/app/src/main/res/drawable/compose_background_camera.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_background_wallpaper.xml b/app/src/main/res/drawable/compose_background_wallpaper.xml new file mode 100644 index 00000000..9739546b --- /dev/null +++ b/app/src/main/res/drawable/compose_background_wallpaper.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_divider_background.xml b/app/src/main/res/drawable/compose_divider_background.xml new file mode 100644 index 00000000..0046fe4e --- /dev/null +++ b/app/src/main/res/drawable/compose_divider_background.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/contact_photo_background.xml b/app/src/main/res/drawable/contact_photo_background.xml new file mode 100644 index 00000000..ff1153bf --- /dev/null +++ b/app/src/main/res/drawable/contact_photo_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/contact_selection_checkbox.xml b/app/src/main/res/drawable/contact_selection_checkbox.xml new file mode 100644 index 00000000..9a87f5d8 --- /dev/null +++ b/app/src/main/res/drawable/contact_selection_checkbox.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_attachment_close_circle.xml b/app/src/main/res/drawable/conversation_attachment_close_circle.xml new file mode 100644 index 00000000..86741f86 --- /dev/null +++ b/app/src/main/res/drawable/conversation_attachment_close_circle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/conversation_attachment_edit.xml b/app/src/main/res/drawable/conversation_attachment_edit.xml new file mode 100644 index 00000000..4e3413c1 --- /dev/null +++ b/app/src/main/res/drawable/conversation_attachment_edit.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/conversation_item_background.xml b/app/src/main/res/drawable/conversation_item_background.xml new file mode 100644 index 00000000..31d6730d --- /dev/null +++ b/app/src/main/res/drawable/conversation_item_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/conversation_item_background_animated.xml b/app/src/main/res/drawable/conversation_item_background_animated.xml new file mode 100644 index 00000000..0116af86 --- /dev/null +++ b/app/src/main/res/drawable/conversation_item_background_animated.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/conversation_list_item_background.xml b/app/src/main/res/drawable/conversation_list_item_background.xml new file mode 100644 index 00000000..2b88d4b9 --- /dev/null +++ b/app/src/main/res/drawable/conversation_list_item_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/conversation_reaction_overlay_background.xml b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml new file mode 100644 index 00000000..d92e2eb7 --- /dev/null +++ b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_update_wallpaper_background_bottom.xml b/app/src/main/res/drawable/conversation_update_wallpaper_background_bottom.xml new file mode 100644 index 00000000..15e6273b --- /dev/null +++ b/app/src/main/res/drawable/conversation_update_wallpaper_background_bottom.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/conversation_update_wallpaper_background_middle.xml b/app/src/main/res/drawable/conversation_update_wallpaper_background_middle.xml new file mode 100644 index 00000000..ecd7f083 --- /dev/null +++ b/app/src/main/res/drawable/conversation_update_wallpaper_background_middle.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/conversation_update_wallpaper_background_singular.xml b/app/src/main/res/drawable/conversation_update_wallpaper_background_singular.xml new file mode 100644 index 00000000..c95f561d --- /dev/null +++ b/app/src/main/res/drawable/conversation_update_wallpaper_background_singular.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/conversation_update_wallpaper_background_top.xml b/app/src/main/res/drawable/conversation_update_wallpaper_background_top.xml new file mode 100644 index 00000000..45f4f75e --- /dev/null +++ b/app/src/main/res/drawable/conversation_update_wallpaper_background_top.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/cta_button_background.xml b/app/src/main/res/drawable/cta_button_background.xml new file mode 100644 index 00000000..44e80001 --- /dev/null +++ b/app/src/main/res/drawable/cta_button_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 00000000..1c3a6cfb --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_corners.xml b/app/src/main/res/drawable/dialog_corners.xml new file mode 100644 index 00000000..08f450dc --- /dev/null +++ b/app/src/main/res/drawable/dialog_corners.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dismiss_background.xml b/app/src/main/res/drawable/dismiss_background.xml new file mode 100644 index 00000000..fed2698e --- /dev/null +++ b/app/src/main/res/drawable/dismiss_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/emoji_variation_selector_background.xml b/app/src/main/res/drawable/emoji_variation_selector_background.xml new file mode 100644 index 00000000..415acd68 --- /dev/null +++ b/app/src/main/res/drawable/emoji_variation_selector_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/error_round.xml b/app/src/main/res/drawable/error_round.xml new file mode 100644 index 00000000..56cc7529 --- /dev/null +++ b/app/src/main/res/drawable/error_round.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_link_admin_approval_border.xml b/app/src/main/res/drawable/group_link_admin_approval_border.xml new file mode 100644 index 00000000..c2984634 --- /dev/null +++ b/app/src/main/res/drawable/group_link_admin_approval_border.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml b/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml new file mode 100644 index 00000000..efc885f3 --- /dev/null +++ b/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/help_fragment_problem_background.xml b/app/src/main/res/drawable/help_fragment_problem_background.xml new file mode 100644 index 00000000..2abc1a16 --- /dev/null +++ b/app/src/main/res/drawable/help_fragment_problem_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_emoji.xml b/app/src/main/res/drawable/ic_add_emoji.xml new file mode 100644 index 00000000..626e613c --- /dev/null +++ b/app/src/main/res/drawable/ic_add_emoji.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_members_20.xml b/app/src/main/res/drawable/ic_add_members_20.xml new file mode 100644 index 00000000..88cf2df9 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_members_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_members_circle.xml b/app/src/main/res/drawable/ic_add_members_circle.xml new file mode 100644 index 00000000..50991a93 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_members_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_advanced_24.xml b/app/src/main/res/drawable/ic_advanced_24.xml new file mode 100644 index 00000000..49779315 --- /dev/null +++ b/app/src/main/res/drawable/ic_advanced_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_any_emoji_32.xml b/app/src/main/res/drawable/ic_any_emoji_32.xml new file mode 100644 index 00000000..ab794464 --- /dev/null +++ b/app/src/main/res/drawable/ic_any_emoji_32.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_appearance_24.xml b/app/src/main/res/drawable/ic_appearance_24.xml new file mode 100644 index 00000000..979485e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_appearance_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_archive_24dp.xml b/app/src/main/res/drawable/ic_archive_24dp.xml new file mode 100644 index 00000000..25b27483 --- /dev/null +++ b/app/src/main/res/drawable/ic_archive_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 00000000..2afc6136 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down_14.xml b/app/src/main/res/drawable/ic_arrow_down_14.xml new file mode 100644 index 00000000..e395ff17 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down_14.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml b/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml new file mode 100644 index 00000000..be0bc5b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down_circle_outline_24.xml b/app/src/main/res/drawable/ic_arrow_down_circle_outline_24.xml new file mode 100644 index 00000000..4e181689 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down_circle_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_end_24.xml b/app/src/main/res/drawable/ic_arrow_end_24.xml new file mode 100644 index 00000000..db728e15 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_end_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_left_24.xml b/app/src/main/res/drawable/ic_arrow_left_24.xml new file mode 100644 index 00000000..a87f1d8f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_left_conversation_24.xml b/app/src/main/res/drawable/ic_arrow_left_conversation_24.xml new file mode 100644 index 00000000..e69b2c95 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left_conversation_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_up_16.xml b/app/src/main/res/drawable/ic_arrow_up_16.xml new file mode 100644 index 00000000..c3b4dd50 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_at_24.xml b/app/src/main/res/drawable/ic_at_24.xml new file mode 100644 index 00000000..9891dd4b --- /dev/null +++ b/app/src/main/res/drawable/ic_at_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_outline_60.xml b/app/src/main/res/drawable/ic_backup_outline_60.xml new file mode 100644 index 00000000..98266d63 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_outline_60.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_bell_24.xml b/app/src/main/res/drawable/ic_bell_24.xml new file mode 100644 index 00000000..dbd3fee7 --- /dev/null +++ b/app/src/main/res/drawable/ic_bell_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_block_tinted_24.xml b/app/src/main/res/drawable/ic_block_tinted_24.xml new file mode 100644 index 00000000..a8e919ca --- /dev/null +++ b/app/src/main/res/drawable/ic_block_tinted_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_24.xml b/app/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 00000000..426d3e7b --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml b/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml new file mode 100644 index 00000000..82feba12 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_camera_solid_white_24.xml b/app/src/main/res/drawable/ic_camera_solid_white_24.xml new file mode 100644 index 00000000..9a7a7fef --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_solid_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_24.xml b/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 00000000..3f70fa3f --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_28_tinted.xml b/app/src/main/res/drawable/ic_check_28_tinted.xml new file mode 100644 index 00000000..592e092f --- /dev/null +++ b/app/src/main/res/drawable/ic_check_28_tinted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_circle_solid_20.xml b/app/src/main/res/drawable/ic_check_circle_solid_20.xml new file mode 100644 index 00000000..9a0af2a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_solid_20.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_outline_22.xml b/app/src/main/res/drawable/ic_check_outline_22.xml new file mode 100644 index 00000000..9f2ebd3d --- /dev/null +++ b/app/src/main/res/drawable/ic_check_outline_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_down_20.xml b/app/src/main/res/drawable/ic_chevron_down_20.xml new file mode 100644 index 00000000..2766d4df --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_left_black_8dp.xml b/app/src/main/res/drawable/ic_chevron_left_black_8dp.xml new file mode 100644 index 00000000..d26121fd --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left_black_8dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right_black_8dp.xml b/app/src/main/res/drawable/ic_chevron_right_black_8dp.xml new file mode 100644 index 00000000..602d5469 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right_black_8dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_up.xml b/app/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 00000000..c54db3bc --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_14.xml b/app/src/main/res/drawable/ic_close_14.xml new file mode 100644 index 00000000..14f37e2d --- /dev/null +++ b/app/src/main/res/drawable/ic_close_14.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_compose_24.xml b/app/src/main/res/drawable/ic_compose_24.xml new file mode 100644 index 00000000..3112150a --- /dev/null +++ b/app/src/main/res/drawable/ic_compose_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_compose_solid_24.xml b/app/src/main/res/drawable/ic_compose_solid_24.xml new file mode 100644 index 00000000..6c06b6e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_compose_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_compose_tinted_24.xml b/app/src/main/res/drawable/ic_compose_tinted_24.xml new file mode 100644 index 00000000..83e360a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_compose_tinted_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_contact_circle_outline_32.xml b/app/src/main/res/drawable/ic_contact_circle_outline_32.xml new file mode 100644 index 00000000..20a1c5a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_contact_circle_outline_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_continue_24.xml b/app/src/main/res/drawable/ic_continue_24.xml new file mode 100644 index 00000000..7c3cdcb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_continue_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 00000000..70a745ef --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy_24_tinted.xml b/app/src/main/res/drawable/ic_copy_24_tinted.xml new file mode 100644 index 00000000..d989fa91 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24_tinted.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dashboard_24.xml b/app/src/main/res/drawable/ic_dashboard_24.xml new file mode 100644 index 00000000..5fd8e360 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_deny_28_tinted.xml b/app/src/main/res/drawable/ic_deny_28_tinted.xml new file mode 100644 index 00000000..986e7d10 --- /dev/null +++ b/app/src/main/res/drawable/ic_deny_28_tinted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_donate_megaphone.xml b/app/src/main/res/drawable/ic_donate_megaphone.xml new file mode 100644 index 00000000..6f216055 --- /dev/null +++ b/app/src/main/res/drawable/ic_donate_megaphone.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_download_filled_white_24.xml b/app/src/main/res/drawable/ic_download_filled_white_24.xml new file mode 100644 index 00000000..3c472c44 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_filled_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji.xml b/app/src/main/res/drawable/ic_emoji.xml new file mode 100644 index 00000000..943277e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_activity_20.xml b/app/src/main/res/drawable/ic_emoji_activity_20.xml new file mode 100644 index 00000000..809d801e --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_activity_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_animal_20.xml b/app/src/main/res/drawable/ic_emoji_animal_20.xml new file mode 100644 index 00000000..63f2f9e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_animal_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_emoticon_20.xml b/app/src/main/res/drawable/ic_emoji_emoticon_20.xml new file mode 100644 index 00000000..8b5df632 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_emoticon_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_flag_20.xml b/app/src/main/res/drawable/ic_emoji_flag_20.xml new file mode 100644 index 00000000..f5b87e49 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_flag_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_food_20.xml b/app/src/main/res/drawable/ic_emoji_food_20.xml new file mode 100644 index 00000000..49898b5a --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_food_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_object_20.xml b/app/src/main/res/drawable/ic_emoji_object_20.xml new file mode 100644 index 00000000..c1690a06 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_object_20.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_people_20.xml b/app/src/main/res/drawable/ic_emoji_people_20.xml new file mode 100644 index 00000000..26867afd --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_people_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_smiley_24.xml b/app/src/main/res/drawable/ic_emoji_smiley_24.xml new file mode 100644 index 00000000..170e8c57 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_smiley_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_symbol_20.xml b/app/src/main/res/drawable/ic_emoji_symbol_20.xml new file mode 100644 index 00000000..cff518ed --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_symbol_20.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_travel_20.xml b/app/src/main/res/drawable/ic_emoji_travel_20.xml new file mode 100644 index 00000000..6a0448e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_travel_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_outline_14_ultramarine.xml b/app/src/main/res/drawable/ic_error_outline_14_ultramarine.xml new file mode 100644 index 00000000..66049ea0 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_outline_14_ultramarine.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_outline_24.xml b/app/src/main/res/drawable/ic_error_outline_24.xml new file mode 100644 index 00000000..953583bc --- /dev/null +++ b/app/src/main/res/drawable/ic_error_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_solid_24.xml b/app/src/main/res/drawable/ic_error_solid_24.xml new file mode 100644 index 00000000..f8654bf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_outline_32.xml b/app/src/main/res/drawable/ic_file_outline_32.xml new file mode 100644 index 00000000..aae0a895 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_outline_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_24.xml b/app/src/main/res/drawable/ic_folder_24.xml new file mode 100644 index 00000000..49a6b2a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_forward_24.xml b/app/src/main/res/drawable/ic_forward_24.xml new file mode 100644 index 00000000..a19dc526 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_gif_outline_32.xml b/app/src/main/res/drawable/ic_gif_outline_32.xml new file mode 100644 index 00000000..03260f34 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif_outline_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_grid_20.xml b/app/src/main/res/drawable/ic_grid_20.xml new file mode 100644 index 00000000..136b041f --- /dev/null +++ b/app/src/main/res/drawable/ic_grid_20.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_group_24.xml b/app/src/main/res/drawable/ic_group_24.xml new file mode 100644 index 00000000..1763496f --- /dev/null +++ b/app/src/main/res/drawable/ic_group_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_80.xml b/app/src/main/res/drawable/ic_group_80.xml new file mode 100644 index 00000000..700ca216 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_20.xml b/app/src/main/res/drawable/ic_group_outline_20.xml new file mode 100644 index 00000000..c8b2a514 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_34.xml b/app/src/main/res/drawable/ic_group_outline_34.xml new file mode 100644 index 00000000..36dfd969 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_34.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_40.xml b/app/src/main/res/drawable/ic_group_outline_40.xml new file mode 100644 index 00000000..3d8cfb77 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_48.xml b/app/src/main/res/drawable/ic_group_outline_48.xml new file mode 100644 index 00000000..cd3038ff --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_48.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_ultramarine_28.xml b/app/src/main/res/drawable/ic_group_outline_ultramarine_28.xml new file mode 100644 index 00000000..0b5cdec3 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_ultramarine_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_solid_24.xml b/app/src/main/res/drawable/ic_group_solid_24.xml new file mode 100644 index 00000000..5d7e9219 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_solid_highlight_24.xml b/app/src/main/res/drawable/ic_group_solid_highlight_24.xml new file mode 100644 index 00000000..c969ca0a --- /dev/null +++ b/app/src/main/res/drawable/ic_group_solid_highlight_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_handset_solid_24.xml b/app/src/main/res/drawable/ic_handset_solid_24.xml new file mode 100644 index 00000000..3877cb3a --- /dev/null +++ b/app/src/main/res/drawable/ic_handset_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_handset_solid_28.xml b/app/src/main/res/drawable/ic_handset_solid_28.xml new file mode 100644 index 00000000..c759884f --- /dev/null +++ b/app/src/main/res/drawable/ic_handset_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_24.xml b/app/src/main/res/drawable/ic_heart_24.xml new file mode 100644 index 00000000..262dcade --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_24.xml b/app/src/main/res/drawable/ic_help_24.xml new file mode 100644 index 00000000..08c9d55f --- /dev/null +++ b/app/src/main/res/drawable/ic_help_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_message_details_24.xml b/app/src/main/res/drawable/ic_info_outline_message_details_24.xml new file mode 100644 index 00000000..128119c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_message_details_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_tinted_24.xml b/app/src/main/res/drawable/ic_info_tinted_24.xml new file mode 100644 index 00000000..fb601a2e --- /dev/null +++ b/app/src/main/res/drawable/ic_info_tinted_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_white_24.xml b/app/src/main/res/drawable/ic_info_white_24.xml new file mode 100644 index 00000000..e0e1e9d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_invite_circle.xml b/app/src/main/res/drawable/ic_invite_circle.xml new file mode 100644 index 00000000..d29426cf --- /dev/null +++ b/app/src/main/res/drawable/ic_invite_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_invite_ultramarine_28.xml b/app/src/main/res/drawable/ic_invite_ultramarine_28.xml new file mode 100644 index 00000000..d37fce44 --- /dev/null +++ b/app/src/main/res/drawable/ic_invite_ultramarine_28.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_kbs_splash.xml b/app/src/main/res/drawable/ic_kbs_splash.xml new file mode 100644 index 00000000..b22785dc --- /dev/null +++ b/app/src/main/res/drawable/ic_kbs_splash.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_keyboard_24.xml b/app/src/main/res/drawable/ic_keyboard_24.xml new file mode 100644 index 00000000..142edd1d --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..b5907086 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_leave_tinted_24.xml b/app/src/main/res/drawable/ic_leave_tinted_24.xml new file mode 100644 index 00000000..99978fb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_leave_tinted_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_linked_devices_24.xml b/app/src/main/res/drawable/ic_linked_devices_24.xml new file mode 100644 index 00000000..2211fd58 --- /dev/null +++ b/app/src/main/res/drawable/ic_linked_devices_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_list_20.xml b/app/src/main/res/drawable/ic_list_20.xml new file mode 100644 index 00000000..08491aeb --- /dev/null +++ b/app/src/main/res/drawable/ic_list_20.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_location_outline_32.xml b/app/src/main/res/drawable/ic_location_outline_32.xml new file mode 100644 index 00000000..00c94ebc --- /dev/null +++ b/app/src/main/res/drawable/ic_location_outline_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 00000000..b642ae75 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_24.xml b/app/src/main/res/drawable/ic_lock_24.xml new file mode 100644 index 00000000..c014e057 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_map_marker.xml b/app/src/main/res/drawable/ic_map_marker.xml new file mode 100644 index 00000000..a2c1b00d --- /dev/null +++ b/app/src/main/res/drawable/ic_map_marker.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_megaphone_invite_friends.xml b/app/src/main/res/drawable/ic_megaphone_invite_friends.xml new file mode 100644 index 00000000..b45c333b --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_invite_friends.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_megaphone_link_previews.xml b/app/src/main/res/drawable/ic_megaphone_link_previews.xml new file mode 100644 index 00000000..b40dd02f --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_link_previews.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_megaphone_start_group.xml b/app/src/main/res/drawable/ic_megaphone_start_group.xml new file mode 100644 index 00000000..9c5e5ac7 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_start_group.xml @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_megaphone_use_sms.xml b/app/src/main/res/drawable/ic_megaphone_use_sms.xml new file mode 100644 index 00000000..d7c2057e --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_use_sms.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_message_primary_accent_24.xml b/app/src/main/res/drawable/ic_message_primary_accent_24.xml new file mode 100644 index 00000000..b7d6f1bd --- /dev/null +++ b/app/src/main/res/drawable/ic_message_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_tinted_bitmap_24.xml b/app/src/main/res/drawable/ic_message_tinted_bitmap_24.xml new file mode 100644 index 00000000..88903a7c --- /dev/null +++ b/app/src/main/res/drawable/ic_message_tinted_bitmap_24.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mic_24.xml b/app/src/main/res/drawable/ic_mic_24.xml new file mode 100644 index 00000000..c598df67 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_solid_18.xml b/app/src/main/res/drawable/ic_mic_off_solid_18.xml new file mode 100644 index 00000000..215c565c --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_solid_18.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_solid_28.xml b/app/src/main/res/drawable/ic_mic_off_solid_28.xml new file mode 100644 index 00000000..4c260196 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_solid_28_white.xml b/app/src/main/res/drawable/ic_mic_off_solid_28_white.xml new file mode 100644 index 00000000..4f0a0596 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_solid_28_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_solid_24.xml b/app/src/main/res/drawable/ic_mic_solid_24.xml new file mode 100644 index 00000000..f83e52e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_24.xml b/app/src/main/res/drawable/ic_more_vert_24.xml new file mode 100644 index 00000000..961145fc --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_conversation_24.xml b/app/src/main/res/drawable/ic_more_vert_conversation_24.xml new file mode 100644 index 00000000..633b8cf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_conversation_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_new_group_circle.xml b/app/src/main/res/drawable/ic_new_group_circle.xml new file mode 100644 index 00000000..a55c5795 --- /dev/null +++ b/app/src/main/res/drawable/ic_new_group_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_note_24.xml b/app/src/main/res/drawable/ic_note_24.xml new file mode 100644 index 00000000..c4ed1357 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_34.xml b/app/src/main/res/drawable/ic_note_34.xml new file mode 100644 index 00000000..18696ac8 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_34.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_80.xml b/app/src/main/res/drawable/ic_note_80.xml new file mode 100644 index 00000000..b230d7e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_number_pad_conversation_filter_24.xml b/app/src/main/res/drawable/ic_number_pad_conversation_filter_24.xml new file mode 100644 index 00000000..b9d9ef2b --- /dev/null +++ b/app/src/main/res/drawable/ic_number_pad_conversation_filter_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_20.xml b/app/src/main/res/drawable/ic_open_20.xml new file mode 100644 index 00000000..867c529e --- /dev/null +++ b/app/src/main/res/drawable/ic_open_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_large.xml b/app/src/main/res/drawable/ic_person_large.xml new file mode 100644 index 00000000..2c96c83a --- /dev/null +++ b/app/src/main/res/drawable/ic_person_large.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_down_28.xml b/app/src/main/res/drawable/ic_phone_down_28.xml new file mode 100644 index 00000000..be65a1aa --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_down_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_right_24.xml b/app/src/main/res/drawable/ic_phone_right_24.xml new file mode 100644 index 00000000..8da23e83 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_right_primary_accent_24.xml b/app/src/main/res/drawable/ic_phone_right_primary_accent_24.xml new file mode 100644 index 00000000..1c974bc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_right_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_right_unlock_primary_accent_24.xml b/app/src/main/res/drawable/ic_phone_right_unlock_primary_accent_24.xml new file mode 100644 index 00000000..b7e9afb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_right_unlock_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_right_unlock_solid_24.xml b/app/src/main/res/drawable/ic_phone_right_unlock_solid_24.xml new file mode 100644 index 00000000..e3321a71 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_right_unlock_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_photo_24.xml b/app/src/main/res/drawable/ic_photo_24.xml new file mode 100644 index 00000000..ab1daaf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_photo_album_outline_32.xml b/app/src/main/res/drawable/ic_photo_album_outline_32.xml new file mode 100644 index 00000000..3acdfd97 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_album_outline_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pin_24.xml b/app/src/main/res/drawable/ic_pin_24.xml new file mode 100644 index 00000000..170b0b3e --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_plus_24.xml b/app/src/main/res/drawable/ic_plus_24.xml new file mode 100644 index 00000000..80b31e7a --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_80.xml b/app/src/main/res/drawable/ic_profile_80.xml new file mode 100644 index 00000000..da93d762 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_circle_outline_16.xml b/app/src/main/res/drawable/ic_profile_circle_outline_16.xml new file mode 100644 index 00000000..49614206 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_circle_outline_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_circle_outline_24.xml b/app/src/main/res/drawable/ic_profile_circle_outline_24.xml new file mode 100644 index 00000000..ad989a35 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_circle_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_name_24.xml b/app/src/main/res/drawable/ic_profile_name_24.xml new file mode 100644 index 00000000..23dc66e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_name_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_120.xml b/app/src/main/res/drawable/ic_profile_outline_120.xml new file mode 100644 index 00000000..124d8919 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_120.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_20.xml b/app/src/main/res/drawable/ic_profile_outline_20.xml new file mode 100644 index 00000000..9e92175e --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_40.xml b/app/src/main/res/drawable/ic_profile_outline_40.xml new file mode 100644 index 00000000..36be61e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_48.xml b/app/src/main/res/drawable/ic_profile_outline_48.xml new file mode 100644 index 00000000..cf334cb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_48.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profilename_64.xml b/app/src/main/res/drawable/ic_profilename_64.xml new file mode 100644 index 00000000..5f0a523f --- /dev/null +++ b/app/src/main/res/drawable/ic_profilename_64.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_proxy_connected_24.xml b/app/src/main/res/drawable/ic_proxy_connected_24.xml new file mode 100644 index 00000000..beda943e --- /dev/null +++ b/app/src/main/res/drawable/ic_proxy_connected_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_proxy_connecting_24.xml b/app/src/main/res/drawable/ic_proxy_connecting_24.xml new file mode 100644 index 00000000..5668ea09 --- /dev/null +++ b/app/src/main/res/drawable/ic_proxy_connecting_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_proxy_failed_24.xml b/app/src/main/res/drawable/ic_proxy_failed_24.xml new file mode 100644 index 00000000..c202dcea --- /dev/null +++ b/app/src/main/res/drawable/ic_proxy_failed_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_qrcode_24_tinted.xml b/app/src/main/res/drawable/ic_qrcode_24_tinted.xml new file mode 100644 index 00000000..799b8c11 --- /dev/null +++ b/app/src/main/res/drawable/ic_qrcode_24_tinted.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_recent_20.xml b/app/src/main/res/drawable/ic_recent_20.xml new file mode 100644 index 00000000..1e888e4f --- /dev/null +++ b/app/src/main/res/drawable/ic_recent_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_16.xml b/app/src/main/res/drawable/ic_refresh_16.xml new file mode 100644 index 00000000..d9492c90 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_24.xml b/app/src/main/res/drawable/ic_reply_24.xml new file mode 100644 index 00000000..535336fd --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_research_megaphone.xml b/app/src/main/res/drawable/ic_research_megaphone.xml new file mode 100644 index 00000000..308238f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_research_megaphone.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_reset_24_tinted.xml b/app/src/main/res/drawable/ic_reset_24_tinted.xml new file mode 100644 index 00000000..f074c4cc --- /dev/null +++ b/app/src/main/res/drawable/ic_reset_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml new file mode 100644 index 00000000..32a936e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 00000000..c2818e41 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_conversation_24.xml b/app/src/main/res/drawable/ic_search_conversation_24.xml new file mode 100644 index 00000000..b20bf2cf --- /dev/null +++ b/app/src/main/res/drawable/ic_search_conversation_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_select_24.xml b/app/src/main/res/drawable/ic_select_24.xml new file mode 100644 index 00000000..8520b0ac --- /dev/null +++ b/app/src/main/res/drawable/ic_select_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_lock_24.xml b/app/src/main/res/drawable/ic_send_lock_24.xml new file mode 100644 index 00000000..23f5573e --- /dev/null +++ b/app/src/main/res/drawable/ic_send_lock_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_unlock_24.xml b/app/src/main/res/drawable/ic_send_unlock_24.xml new file mode 100644 index 00000000..96320849 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_unlock_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_outline_24.xml b/app/src/main/res/drawable/ic_settings_outline_24.xml new file mode 100644 index 00000000..dad0eb5d --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_outline_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share_24.xml b/app/src/main/res/drawable/ic_share_24.xml new file mode 100644 index 00000000..3018fe04 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_24_tinted.xml b/app/src/main/res/drawable/ic_share_24_tinted.xml new file mode 100644 index 00000000..a629ed71 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_signal_logo_large.xml b/app/src/main/res/drawable/ic_signal_logo_large.xml new file mode 100644 index 00000000..cab7595e --- /dev/null +++ b/app/src/main/res/drawable/ic_signal_logo_large.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_bt_solid_24.xml b/app/src/main/res/drawable/ic_speaker_bt_solid_24.xml new file mode 100644 index 00000000..6d00b83b --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_bt_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml b/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml new file mode 100644 index 00000000..f59ef1eb --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_solid_28.xml b/app/src/main/res/drawable/ic_speaker_solid_28.xml new file mode 100644 index 00000000..bbebce47 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_solid_white_28.xml b/app/src/main/res/drawable/ic_speaker_solid_white_28.xml new file mode 100644 index 00000000..220d9d05 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_solid_white_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sticker_24.xml b/app/src/main/res/drawable/ic_sticker_24.xml new file mode 100644 index 00000000..94189573 --- /dev/null +++ b/app/src/main/res/drawable/ic_sticker_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_switch_camera_28.xml b/app/src/main/res/drawable/ic_switch_camera_28.xml new file mode 100644 index 00000000..ae216a9f --- /dev/null +++ b/app/src/main/res/drawable/ic_switch_camera_28.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_timer_00_12.xml b/app/src/main/res/drawable/ic_timer_00_12.xml new file mode 100644 index 00000000..de6d32d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_00_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_05_12.xml b/app/src/main/res/drawable/ic_timer_05_12.xml new file mode 100644 index 00000000..b401c124 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_05_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_10_12.xml b/app/src/main/res/drawable/ic_timer_10_12.xml new file mode 100644 index 00000000..83bfa773 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_10_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_15_12.xml b/app/src/main/res/drawable/ic_timer_15_12.xml new file mode 100644 index 00000000..55b41292 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_15_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_20_12.xml b/app/src/main/res/drawable/ic_timer_20_12.xml new file mode 100644 index 00000000..9f985f5b --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_20_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_24.xml b/app/src/main/res/drawable/ic_timer_24.xml new file mode 100644 index 00000000..0785f318 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_25_12.xml b/app/src/main/res/drawable/ic_timer_25_12.xml new file mode 100644 index 00000000..7628fab7 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_25_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_30_12.xml b/app/src/main/res/drawable/ic_timer_30_12.xml new file mode 100644 index 00000000..13639f7f --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_30_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_35_12.xml b/app/src/main/res/drawable/ic_timer_35_12.xml new file mode 100644 index 00000000..ff02bad9 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_35_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_40_12.xml b/app/src/main/res/drawable/ic_timer_40_12.xml new file mode 100644 index 00000000..4cbcac25 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_40_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_45_12.xml b/app/src/main/res/drawable/ic_timer_45_12.xml new file mode 100644 index 00000000..9e868025 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_45_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_50_12.xml b/app/src/main/res/drawable/ic_timer_50_12.xml new file mode 100644 index 00000000..00bf75c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_50_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_55_12.xml b/app/src/main/res/drawable/ic_timer_55_12.xml new file mode 100644 index 00000000..bb66f6a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_55_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_60_12.xml b/app/src/main/res/drawable/ic_timer_60_12.xml new file mode 100644 index 00000000..57d33169 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_60_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_conversation_badge.xml b/app/src/main/res/drawable/ic_timer_conversation_badge.xml new file mode 100644 index 00000000..dc4c9cfb --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_conversation_badge.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer_disabled_24.xml b/app/src/main/res/drawable/ic_timer_disabled_24.xml new file mode 100644 index 00000000..cbaff4f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_disabled_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_trash_24.xml b/app/src/main/res/drawable/ic_trash_24.xml new file mode 100644 index 00000000..f297fee2 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_triangle_down.xml b/app/src/main/res/drawable/ic_triangle_down.xml new file mode 100644 index 00000000..9ff7fc1c --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_down.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_triangle_left.xml b/app/src/main/res/drawable/ic_triangle_left.xml new file mode 100644 index 00000000..571c2b3c --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_left.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_triangle_right.xml b/app/src/main/res/drawable/ic_triangle_right.xml new file mode 100644 index 00000000..f14e6941 --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_right.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_triangle_up.xml b/app/src/main/res/drawable/ic_triangle_up.xml new file mode 100644 index 00000000..b8736e34 --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_up.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_unpin_24.xml b/app/src/main/res/drawable/ic_unpin_24.xml new file mode 100644 index 00000000..ea730906 --- /dev/null +++ b/app/src/main/res/drawable/ic_unpin_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_update_audio_call_incoming_16.xml b/app/src/main/res/drawable/ic_update_audio_call_incoming_16.xml new file mode 100644 index 00000000..16e6eb6f --- /dev/null +++ b/app/src/main/res/drawable/ic_update_audio_call_incoming_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_audio_call_missed_16.xml b/app/src/main/res/drawable/ic_update_audio_call_missed_16.xml new file mode 100644 index 00000000..f58e9fbf --- /dev/null +++ b/app/src/main/res/drawable/ic_update_audio_call_missed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_audio_call_outgoing_16.xml b/app/src/main/res/drawable/ic_update_audio_call_outgoing_16.xml new file mode 100644 index 00000000..24f27e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_audio_call_outgoing_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_16.xml b/app/src/main/res/drawable/ic_update_group_16.xml new file mode 100644 index 00000000..f6684613 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_accept_16.xml b/app/src/main/res/drawable/ic_update_group_accept_16.xml new file mode 100644 index 00000000..fbaa99bc --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_accept_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_add_16.xml b/app/src/main/res/drawable/ic_update_group_add_16.xml new file mode 100644 index 00000000..a52e8ce2 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_add_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_update_group_avatar_16.xml b/app/src/main/res/drawable/ic_update_group_avatar_16.xml new file mode 100644 index 00000000..7791e304 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_avatar_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_decline_16.xml b/app/src/main/res/drawable/ic_update_group_decline_16.xml new file mode 100644 index 00000000..ca6e7f0a --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_decline_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_update_group_leave_16.xml b/app/src/main/res/drawable/ic_update_group_leave_16.xml new file mode 100644 index 00000000..31370fe9 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_leave_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_name_16.xml b/app/src/main/res/drawable/ic_update_group_name_16.xml new file mode 100644 index 00000000..aa01d9fc --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_name_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_group_remove_16.xml b/app/src/main/res/drawable/ic_update_group_remove_16.xml new file mode 100644 index 00000000..5ac10df1 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_remove_16.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_update_group_role_16.xml b/app/src/main/res/drawable/ic_update_group_role_16.xml new file mode 100644 index 00000000..15375371 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_group_role_16.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_update_info_16.xml b/app/src/main/res/drawable/ic_update_info_16.xml new file mode 100644 index 00000000..12491010 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_info_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_profile_16.xml b/app/src/main/res/drawable/ic_update_profile_16.xml new file mode 100644 index 00000000..f3299fb3 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_profile_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_safety_number_16.xml b/app/src/main/res/drawable/ic_update_safety_number_16.xml new file mode 100644 index 00000000..e48e26c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_safety_number_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_timer_16.xml b/app/src/main/res/drawable/ic_update_timer_16.xml new file mode 100644 index 00000000..1b87e2f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_timer_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_timer_disabled_16.xml b/app/src/main/res/drawable/ic_update_timer_disabled_16.xml new file mode 100644 index 00000000..c0bf388d --- /dev/null +++ b/app/src/main/res/drawable/ic_update_timer_disabled_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_verified_16.xml b/app/src/main/res/drawable/ic_update_verified_16.xml new file mode 100644 index 00000000..df6d2d86 --- /dev/null +++ b/app/src/main/res/drawable/ic_update_verified_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_video_call_incoming_16.xml b/app/src/main/res/drawable/ic_update_video_call_incoming_16.xml new file mode 100644 index 00000000..3000011f --- /dev/null +++ b/app/src/main/res/drawable/ic_update_video_call_incoming_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_video_call_missed_16.xml b/app/src/main/res/drawable/ic_update_video_call_missed_16.xml new file mode 100644 index 00000000..fe3dd7ba --- /dev/null +++ b/app/src/main/res/drawable/ic_update_video_call_missed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_video_call_outgoing_16.xml b/app/src/main/res/drawable/ic_update_video_call_outgoing_16.xml new file mode 100644 index 00000000..3ec9fa2f --- /dev/null +++ b/app/src/main/res/drawable/ic_update_video_call_outgoing_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_16.xml b/app/src/main/res/drawable/ic_video_16.xml new file mode 100644 index 00000000..9a2e3b07 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_call_24.xml b/app/src/main/res/drawable/ic_video_call_24.xml new file mode 100644 index 00000000..bda78876 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_call_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_off_solid_white_18.xml b/app/src/main/res/drawable/ic_video_off_solid_white_18.xml new file mode 100644 index 00000000..47a40f91 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off_solid_white_18.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_off_solid_white_28.xml b/app/src/main/res/drawable/ic_video_off_solid_white_28.xml new file mode 100644 index 00000000..90edf900 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off_solid_white_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_primary_accent_24.xml b/app/src/main/res/drawable/ic_video_primary_accent_24.xml new file mode 100644 index 00000000..b4ac22da --- /dev/null +++ b/app/src/main/res/drawable/ic_video_primary_accent_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_solid_18.xml b/app/src/main/res/drawable/ic_video_solid_18.xml new file mode 100644 index 00000000..89aa5ebf --- /dev/null +++ b/app/src/main/res/drawable/ic_video_solid_18.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_solid_24_tinted.xml b/app/src/main/res/drawable/ic_video_solid_24_tinted.xml new file mode 100644 index 00000000..d48cd6d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_solid_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_solid_28.xml b/app/src/main/res/drawable/ic_video_solid_28.xml new file mode 100644 index 00000000..0fccfd85 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_solid_grey_28.xml b/app/src/main/res/drawable/ic_video_solid_grey_28.xml new file mode 100644 index 00000000..a78fb676 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_solid_grey_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_all_20.xml b/app/src/main/res/drawable/ic_view_all_20.xml new file mode 100644 index 00000000..64481809 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_all_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_all_circle.xml b/app/src/main/res/drawable/ic_view_all_circle.xml new file mode 100644 index 00000000..820436cf --- /dev/null +++ b/app/src/main/res/drawable/ic_view_all_circle.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_view_once_24.xml b/app/src/main/res/drawable/ic_view_once_24.xml new file mode 100644 index 00000000..47b179e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_once_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_stream_24.xml b/app/src/main/res/drawable/ic_view_stream_24.xml new file mode 100644 index 00000000..464a7328 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_stream_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_viewed_once_24.xml b/app/src/main/res/drawable/ic_viewed_once_24.xml new file mode 100644 index 00000000..f500ddc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_viewed_once_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning_40.xml b/app/src/main/res/drawable/ic_warning_40.xml new file mode 100644 index 00000000..ee05474a --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x.xml b/app/src/main/res/drawable/ic_x.xml new file mode 100644 index 00000000..4fa8aa41 --- /dev/null +++ b/app/src/main/res/drawable/ic_x.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_20.xml b/app/src/main/res/drawable/ic_x_20.xml new file mode 100644 index 00000000..46a2fedb --- /dev/null +++ b/app/src/main/res/drawable/ic_x_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_conversation.xml b/app/src/main/res/drawable/ic_x_conversation.xml new file mode 100644 index 00000000..17a76fce --- /dev/null +++ b/app/src/main/res/drawable/ic_x_conversation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_conversation_20.xml b/app/src/main/res/drawable/ic_x_conversation_20.xml new file mode 100644 index 00000000..b98b3311 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_conversation_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_conversation_filter_24.xml b/app/src/main/res/drawable/ic_x_conversation_filter_24.xml new file mode 100644 index 00000000..0187c865 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_conversation_filter_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_reaction_overlay.xml b/app/src/main/res/drawable/ic_x_reaction_overlay.xml new file mode 100644 index 00000000..0187c865 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_reaction_overlay.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_x_tinted.xml b/app/src/main/res/drawable/ic_x_tinted.xml new file mode 100644 index 00000000..0187c865 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/image_preview_shade.xml b/app/src/main/res/drawable/image_preview_shade.xml new file mode 100644 index 00000000..70c52a4a --- /dev/null +++ b/app/src/main/res/drawable/image_preview_shade.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/image_shade.xml b/app/src/main/res/drawable/image_shade.xml new file mode 100644 index 00000000..e7616a18 --- /dev/null +++ b/app/src/main/res/drawable/image_shade.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/info_round.xml b/app/src/main/res/drawable/info_round.xml new file mode 100644 index 00000000..4dc3e2fa --- /dev/null +++ b/app/src/main/res/drawable/info_round.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/insight_modal_background.xml b/app/src/main/res/drawable/insight_modal_background.xml new file mode 100644 index 00000000..689e96b8 --- /dev/null +++ b/app/src/main/res/drawable/insight_modal_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/insights_cta_button_background.xml b/app/src/main/res/drawable/insights_cta_button_background.xml new file mode 100644 index 00000000..8992c530 --- /dev/null +++ b/app/src/main/res/drawable/insights_cta_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/insights_modal_background.xml b/app/src/main/res/drawable/insights_modal_background.xml new file mode 100644 index 00000000..3fa21373 --- /dev/null +++ b/app/src/main/res/drawable/insights_modal_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/insights_modal_background_dark.xml b/app/src/main/res/drawable/insights_modal_background_dark.xml new file mode 100644 index 00000000..854e599b --- /dev/null +++ b/app/src/main/res/drawable/insights_modal_background_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/invite_edit_text_background.xml b/app/src/main/res/drawable/invite_edit_text_background.xml new file mode 100644 index 00000000..87574597 --- /dev/null +++ b/app/src/main/res/drawable/invite_edit_text_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/labeled_edit_text_background_active.xml b/app/src/main/res/drawable/labeled_edit_text_background_active.xml new file mode 100644 index 00000000..3bb9c5f1 --- /dev/null +++ b/app/src/main/res/drawable/labeled_edit_text_background_active.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml b/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml new file mode 100644 index 00000000..6360ef28 --- /dev/null +++ b/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/media_continue_button_background.xml b/app/src/main/res/drawable/media_continue_button_background.xml new file mode 100644 index 00000000..7bcb1e9d --- /dev/null +++ b/app/src/main/res/drawable/media_continue_button_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_count_button_background.xml b/app/src/main/res/drawable/media_count_button_background.xml new file mode 100644 index 00000000..febeaab9 --- /dev/null +++ b/app/src/main/res/drawable/media_count_button_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/media_count_number_background.xml b/app/src/main/res/drawable/media_count_number_background.xml new file mode 100644 index 00000000..1ffe6715 --- /dev/null +++ b/app/src/main/res/drawable/media_count_number_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_keyboard_selected_background.xml b/app/src/main/res/drawable/media_keyboard_selected_background.xml new file mode 100644 index 00000000..e0afb0fc --- /dev/null +++ b/app/src/main/res/drawable/media_keyboard_selected_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_overview_size_pill_background.xml b/app/src/main/res/drawable/media_overview_size_pill_background.xml new file mode 100644 index 00000000..2aeeddc4 --- /dev/null +++ b/app/src/main/res/drawable/media_overview_size_pill_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mediapicker_item_border_dark.xml b/app/src/main/res/drawable/mediapicker_item_border_dark.xml new file mode 100644 index 00000000..8a0a1242 --- /dev/null +++ b/app/src/main/res/drawable/mediapicker_item_border_dark.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mediarail_button_background.xml b/app/src/main/res/drawable/mediarail_button_background.xml new file mode 100644 index 00000000..85e1a959 --- /dev/null +++ b/app/src/main/res/drawable/mediarail_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mediarail_media_outline.xml b/app/src/main/res/drawable/mediarail_media_outline.xml new file mode 100644 index 00000000..54e19058 --- /dev/null +++ b/app/src/main/res/drawable/mediarail_media_outline.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/megaphone_onboarding_gradient.xml b/app/src/main/res/drawable/megaphone_onboarding_gradient.xml new file mode 100644 index 00000000..30d5020f --- /dev/null +++ b/app/src/main/res/drawable/megaphone_onboarding_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/mention_megaphone.xml b/app/src/main/res/drawable/mention_megaphone.xml new file mode 100644 index 00000000..6959aded --- /dev/null +++ b/app/src/main/res/drawable/mention_megaphone.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/mention_text_bg.xml b/app/src/main/res/drawable/mention_text_bg.xml new file mode 100644 index 00000000..215a40b8 --- /dev/null +++ b/app/src/main/res/drawable/mention_text_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/mention_text_bg_left.xml b/app/src/main/res/drawable/mention_text_bg_left.xml new file mode 100644 index 00000000..180c03c5 --- /dev/null +++ b/app/src/main/res/drawable/mention_text_bg_left.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/mention_text_bg_mid.xml b/app/src/main/res/drawable/mention_text_bg_mid.xml new file mode 100644 index 00000000..7af835ea --- /dev/null +++ b/app/src/main/res/drawable/mention_text_bg_mid.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/mention_text_bg_right.xml b/app/src/main/res/drawable/mention_text_bg_right.xml new file mode 100644 index 00000000..ff29dd5f --- /dev/null +++ b/app/src/main/res/drawable/mention_text_bg_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background.xml b/app/src/main/res/drawable/message_bubble_background.xml new file mode 100644 index 00000000..cda641b1 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background_received_alone.xml b/app/src/main/res/drawable/message_bubble_background_received_alone.xml new file mode 100644 index 00000000..b7186e4f --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_received_alone.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background_received_end.xml b/app/src/main/res/drawable/message_bubble_background_received_end.xml new file mode 100644 index 00000000..b5d1714e --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_received_end.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background_received_middle.xml b/app/src/main/res/drawable/message_bubble_background_received_middle.xml new file mode 100644 index 00000000..627c3c6d --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_received_middle.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background_received_start.xml b/app/src/main/res/drawable/message_bubble_background_received_start.xml new file mode 100644 index 00000000..f02feea0 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_received_start.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background_sent_alone.xml b/app/src/main/res/drawable/message_bubble_background_sent_alone.xml new file mode 100644 index 00000000..6f19f4f1 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_sent_alone.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background_sent_end.xml b/app/src/main/res/drawable/message_bubble_background_sent_end.xml new file mode 100644 index 00000000..5bc4597c --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_sent_end.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background_sent_middle.xml b/app/src/main/res/drawable/message_bubble_background_sent_middle.xml new file mode 100644 index 00000000..aea42645 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_sent_middle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background_sent_start.xml b/app/src/main/res/drawable/message_bubble_background_sent_start.xml new file mode 100644 index 00000000..4719d235 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background_sent_start.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/message_request_button_background.xml b/app/src/main/res/drawable/message_request_button_background.xml new file mode 100644 index 00000000..19aee464 --- /dev/null +++ b/app/src/main/res/drawable/message_request_button_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/paragraph_marker.xml b/app/src/main/res/drawable/paragraph_marker.xml new file mode 100644 index 00000000..e02a5a3b --- /dev/null +++ b/app/src/main/res/drawable/paragraph_marker.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill.xml b/app/src/main/res/drawable/pill.xml new file mode 100644 index 00000000..febeaab9 --- /dev/null +++ b/app/src/main/res/drawable/pill.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/preference_divider.xml b/app/src/main/res/drawable/preference_divider.xml new file mode 100644 index 00000000..1d1c3378 --- /dev/null +++ b/app/src/main/res/drawable/preference_divider.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_username_background.xml b/app/src/main/res/drawable/preference_username_background.xml new file mode 100644 index 00000000..11e0349e --- /dev/null +++ b/app/src/main/res/drawable/preference_username_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_action_button_background.xml b/app/src/main/res/drawable/primary_action_button_background.xml new file mode 100644 index 00000000..63855710 --- /dev/null +++ b/app/src/main/res/drawable/primary_action_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_action_button_background_36dp.xml b/app/src/main/res/drawable/primary_action_button_background_36dp.xml new file mode 100644 index 00000000..c0608756 --- /dev/null +++ b/app/src/main/res/drawable/primary_action_button_background_36dp.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_button_state.xml b/app/src/main/res/drawable/progress_button_state.xml new file mode 100644 index 00000000..1db6b41d --- /dev/null +++ b/app/src/main/res/drawable/progress_button_state.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_button_state_red.xml b/app/src/main/res/drawable/progress_button_state_red.xml new file mode 100644 index 00000000..4bfd3eb6 --- /dev/null +++ b/app/src/main/res/drawable/progress_button_state_red.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/proxy_avatar_96.xml b/app/src/main/res/drawable/proxy_avatar_96.xml new file mode 100644 index 00000000..1cc237f1 --- /dev/null +++ b/app/src/main/res/drawable/proxy_avatar_96.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/qr_code_background.xml b/app/src/main/res/drawable/qr_code_background.xml new file mode 100644 index 00000000..a5266a70 --- /dev/null +++ b/app/src/main/res/drawable/qr_code_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/reaction_pill_background.xml b/app/src/main/res/drawable/reaction_pill_background.xml new file mode 100644 index 00000000..3ff04217 --- /dev/null +++ b/app/src/main/res/drawable/reaction_pill_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/reaction_pill_background_selected.xml b/app/src/main/res/drawable/reaction_pill_background_selected.xml new file mode 100644 index 00000000..a76054a8 --- /dev/null +++ b/app/src/main/res/drawable/reaction_pill_background_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/reactions_megaphone_background.xml b/app/src/main/res/drawable/reactions_megaphone_background.xml new file mode 100644 index 00000000..8fa1174e --- /dev/null +++ b/app/src/main/res/drawable/reactions_megaphone_background.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/reactions_old_background.xml b/app/src/main/res/drawable/reactions_old_background.xml new file mode 100644 index 00000000..ddb1cd66 --- /dev/null +++ b/app/src/main/res/drawable/reactions_old_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recycler_view_fast_scroller_bubble.xml b/app/src/main/res/drawable/recycler_view_fast_scroller_bubble.xml new file mode 100644 index 00000000..f135c5c6 --- /dev/null +++ b/app/src/main/res/drawable/recycler_view_fast_scroller_bubble.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recycler_view_fast_scroller_handle.xml b/app/src/main/res/drawable/recycler_view_fast_scroller_handle.xml new file mode 100644 index 00000000..fa2ca8a5 --- /dev/null +++ b/app/src/main/res/drawable/recycler_view_fast_scroller_handle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/registration_no_cell_service_warning_border.xml b/app/src/main/res/drawable/registration_no_cell_service_warning_border.xml new file mode 100644 index 00000000..f447a27c --- /dev/null +++ b/app/src/main/res/drawable/registration_no_cell_service_warning_border.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/reminder_background_error.xml b/app/src/main/res/drawable/reminder_background_error.xml new file mode 100644 index 00000000..0d20ba5f --- /dev/null +++ b/app/src/main/res/drawable/reminder_background_error.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/reminder_background_normal.xml b/app/src/main/res/drawable/reminder_background_normal.xml new file mode 100644 index 00000000..45628500 --- /dev/null +++ b/app/src/main/res/drawable/reminder_background_normal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/reminder_background_terminal.xml b/app/src/main/res/drawable/reminder_background_terminal.xml new file mode 100644 index 00000000..795dc3c2 --- /dev/null +++ b/app/src/main/res/drawable/reminder_background_terminal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/reminder_progress_ring.xml b/app/src/main/res/drawable/reminder_progress_ring.xml new file mode 100644 index 00000000..d2b4d253 --- /dev/null +++ b/app/src/main/res/drawable/reminder_progress_ring.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/remove_button_state.xml b/app/src/main/res/drawable/remove_button_state.xml new file mode 100644 index 00000000..89c87b25 --- /dev/null +++ b/app/src/main/res/drawable/remove_button_state.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/review_card_outline.xml b/app/src/main/res/drawable/review_card_outline.xml new file mode 100644 index 00000000..84c7aac1 --- /dev/null +++ b/app/src/main/res/drawable/review_card_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/round_background.xml b/app/src/main/res/drawable/round_background.xml new file mode 100644 index 00000000..da9df74b --- /dev/null +++ b/app/src/main/res/drawable/round_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_rectangle.xml b/app/src/main/res/drawable/rounded_rectangle.xml new file mode 100644 index 00000000..337bd7d8 --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/safety_number_change_banner.xml b/app/src/main/res/drawable/safety_number_change_banner.xml new file mode 100644 index 00000000..15a144f3 --- /dev/null +++ b/app/src/main/res/drawable/safety_number_change_banner.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/scribble_toast_background.xml b/app/src/main/res/drawable/scribble_toast_background.xml new file mode 100644 index 00000000..b67a8174 --- /dev/null +++ b/app/src/main/res/drawable/scribble_toast_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/scroll_to_bottom_background.xml b/app/src/main/res/drawable/scroll_to_bottom_background.xml new file mode 100644 index 00000000..ea8e7987 --- /dev/null +++ b/app/src/main/res/drawable/scroll_to_bottom_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/sticker_button.xml b/app/src/main/res/drawable/sticker_button.xml new file mode 100644 index 00000000..97fcffe0 --- /dev/null +++ b/app/src/main/res/drawable/sticker_button.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sticker_management_empty_background.xml b/app/src/main/res/drawable/sticker_management_empty_background.xml new file mode 100644 index 00000000..63855710 --- /dev/null +++ b/app/src/main/res/drawable/sticker_management_empty_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sticker_missing_background.xml b/app/src/main/res/drawable/sticker_missing_background.xml new file mode 100644 index 00000000..fdc4810c --- /dev/null +++ b/app/src/main/res/drawable/sticker_missing_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sticky_date_header_background.xml b/app/src/main/res/drawable/sticky_date_header_background.xml new file mode 100644 index 00000000..3a924692 --- /dev/null +++ b/app/src/main/res/drawable/sticky_date_header_background.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/storage_legend_audio.xml b/app/src/main/res/drawable/storage_legend_audio.xml new file mode 100644 index 00000000..4857e501 --- /dev/null +++ b/app/src/main/res/drawable/storage_legend_audio.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/storage_legend_files.xml b/app/src/main/res/drawable/storage_legend_files.xml new file mode 100644 index 00000000..b40e8481 --- /dev/null +++ b/app/src/main/res/drawable/storage_legend_files.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/storage_legend_photos.xml b/app/src/main/res/drawable/storage_legend_photos.xml new file mode 100644 index 00000000..f9f87c08 --- /dev/null +++ b/app/src/main/res/drawable/storage_legend_photos.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/storage_legend_videos.xml b/app/src/main/res/drawable/storage_legend_videos.xml new file mode 100644 index 00000000..b4d16120 --- /dev/null +++ b/app/src/main/res/drawable/storage_legend_videos.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/test_gradient.xml b/app/src/main/res/drawable/test_gradient.xml new file mode 100644 index 00000000..655faf87 --- /dev/null +++ b/app/src/main/res/drawable/test_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/tinted_circle.xml b/app/src/main/res/drawable/tinted_circle.xml new file mode 100644 index 00000000..ddb1cd66 --- /dev/null +++ b/app/src/main/res/drawable/tinted_circle.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toolbar_shadow.xml b/app/src/main/res/drawable/toolbar_shadow.xml new file mode 100644 index 00000000..1a43a7f3 --- /dev/null +++ b/app/src/main/res/drawable/toolbar_shadow.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/tooltip_background.xml b/app/src/main/res/drawable/tooltip_background.xml new file mode 100644 index 00000000..900668e0 --- /dev/null +++ b/app/src/main/res/drawable/tooltip_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/touch_highlight_background.xml b/app/src/main/res/drawable/touch_highlight_background.xml new file mode 100644 index 00000000..b5a98171 --- /dev/null +++ b/app/src/main/res/drawable/touch_highlight_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/transfer_controls_background.xml b/app/src/main/res/drawable/transfer_controls_background.xml new file mode 100644 index 00000000..1ba1aa2e --- /dev/null +++ b/app/src/main/res/drawable/transfer_controls_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/transparent_black_pill.xml b/app/src/main/res/drawable/transparent_black_pill.xml new file mode 100644 index 00000000..22b877ce --- /dev/null +++ b/app/src/main/res/drawable/transparent_black_pill.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/triangle_bottom_right_corner.xml b/app/src/main/res/drawable/triangle_bottom_right_corner.xml new file mode 100644 index 00000000..eb3aa079 --- /dev/null +++ b/app/src/main/res/drawable/triangle_bottom_right_corner.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/triangle_right.xml b/app/src/main/res/drawable/triangle_right.xml new file mode 100644 index 00000000..d6f003ef --- /dev/null +++ b/app/src/main/res/drawable/triangle_right.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/unread_count_background.xml b/app/src/main/res/drawable/unread_count_background.xml new file mode 100644 index 00000000..7afc58ba --- /dev/null +++ b/app/src/main/res/drawable/unread_count_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/wallpaper_bubble_background_11.xml b/app/src/main/res/drawable/wallpaper_bubble_background_11.xml new file mode 100644 index 00000000..9951fdec --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_bubble_background_11.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/wallpaper_bubble_background_12.xml b/app/src/main/res/drawable/wallpaper_bubble_background_12.xml new file mode 100644 index 00000000..c95f561d --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_bubble_background_12.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/wallpaper_bubble_background_8.xml b/app/src/main/res/drawable/wallpaper_bubble_background_8.xml new file mode 100644 index 00000000..2868bf81 --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_bubble_background_8.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/wallpaper_bubble_background_tintable_11.xml b/app/src/main/res/drawable/wallpaper_bubble_background_tintable_11.xml new file mode 100644 index 00000000..6bb1316b --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_bubble_background_tintable_11.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/wallpaper_crop_bubble_background.xml b/app/src/main/res/drawable/wallpaper_crop_bubble_background.xml new file mode 100644 index 00000000..b195cc3d --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_crop_bubble_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/wallpaper_message_decoration_background.xml b/app/src/main/res/drawable/wallpaper_message_decoration_background.xml new file mode 100644 index 00000000..825fb502 --- /dev/null +++ b/app/src/main/res/drawable/wallpaper_message_decoration_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer.xml b/app/src/main/res/drawable/webrtc_call_screen_answer.xml new file mode 100644 index 00000000..125e498d --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml b/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml new file mode 100644 index 00000000..8e3e6edc --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml b/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml new file mode 100644 index 00000000..dbe0d829 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_camera_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_camera_toggle.xml new file mode 100644 index 00000000..1b5dbfd0 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_camera_toggle.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_camera_toggle_small.xml b/app/src/main/res/drawable/webrtc_call_screen_camera_toggle_small.xml new file mode 100644 index 00000000..754cbcb9 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_camera_toggle_small.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml new file mode 100644 index 00000000..c7902555 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml new file mode 100644 index 00000000..1f5a81ec --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml new file mode 100644 index 00000000..a271ea19 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml new file mode 100644 index 00000000..29275bbe --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_hangup.xml b/app/src/main/res/drawable/webrtc_call_screen_hangup.xml new file mode 100644 index 00000000..c514308b --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_hangup.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_hangup_small.xml b/app/src/main/res/drawable/webrtc_call_screen_hangup_small.xml new file mode 100644 index 00000000..5e92c324 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_hangup_small.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml b/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml new file mode 100644 index 00000000..6877f48b --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml new file mode 100644 index 00000000..2c5ead26 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_mic_toggle_small.xml b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle_small.xml new file mode 100644 index 00000000..28767a55 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle_small.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml new file mode 100644 index 00000000..c3f05a24 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle_small.xml b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle_small.xml new file mode 100644 index 00000000..76f2b8f8 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle_small.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml new file mode 100644 index 00000000..8a617650 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_video_toggle_small.xml b/app/src/main/res/drawable/webrtc_call_screen_video_toggle_small.xml new file mode 100644 index 00000000..e65cbbbe --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_video_toggle_small.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_triangle_grey.xml b/app/src/main/res/drawable/webrtc_triangle_grey.xml new file mode 100644 index 00000000..17e5c3a7 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_triangle_grey.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/webrtc_triangle_white.xml b/app/src/main/res/drawable/webrtc_triangle_white.xml new file mode 100644 index 00000000..0dd10ec3 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_triangle_white.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout/about_preset_item.xml b/app/src/main/res/layout/about_preset_item.xml new file mode 100644 index 00000000..e4848ea4 --- /dev/null +++ b/app/src/main/res/layout/about_preset_item.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_locked_fragment.xml b/app/src/main/res/layout/account_locked_fragment.xml new file mode 100644 index 00000000..726fd6e0 --- /dev/null +++ b/app/src/main/res/layout/account_locked_fragment.xml @@ -0,0 +1,62 @@ + + + + + + + +